From bc5fbd6ba91b0447d9d57cab1026f3db52db576c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Thu, 29 Oct 2026 15:24:59 +0100 Subject: [PATCH] first commit --- .env.example | 24 + .envrc | 1 + .github/workflows/ci.yml | 29 + .gitignore | 33 + CHANGELOG.md | 165 + Cargo.lock | 4768 +++++++++++ Cargo.toml | 46 + NOTICE.md | 13 + README.md | 166 + RELEASE-MANIFEST.txt | 49 + UPSTREAM_BASE | 1 + build.rs | 58 + config.dev.toml | 23 + config.example.toml | 44 + dev.sh | 28 + docs/IMPLEMENTATION_STATUS.md | 157 + docs/RELEASING.md | 23 + docs/ROADMAP_GAPS.md | 107 + docs/RUSTBOX_PLAN.md | 1357 ++++ docs/UI.md | 21 + docs/UPSTREAM.md | 134 + docs/VALIDATION.md | 28 + docs/imap-pool.md | 54 + docs/releases/v0.0.1.md | 26 + examples/test_email_theme.rs | 97 + make_zip.py | 70 + migrations/V001__initial_schema.sql | 6 + migrations/V002__folders_and_messages.sql | 41 + migrations/V003__add_date_epoch.sql | 6 + .../V004__folder_messages_updated_at.sql | 3 + migrations/V005__drafts_and_attachments.sql | 24 + .../V006__cache_attachments_and_headers.sql | 3 + migrations/V007__contacts.sql | 18 + migrations/V008__identities.sql | 9 + migrations/V009__notification_preferences.sql | 9 + migrations/V010__contact_groups.sql | 16 + migrations/V011__message_reaction.sql | 1 + migrations/V012__tags.sql | 20 + migrations/V013__display_preferences.sql | 7 + migrations/V014__compose_format.sql | 1 + migrations/V015__deep_index.sql | 1 + migrations/V016__calendar.sql | 51 + migrations/V017__add_email_theme.sql | 4 + migrations/V018__animation_mode.sql | 2 + migrations/V019__known_addresses.sql | 34 + .../V020__appearance_personalization.sql | 17 + migrations/V021__special_folders.sql | 5 + migrations/V022__identity_options.sql | 8 + migrations/V023__saved_searches.sql | 9 + migrations/V024__notification_dnd.sql | 3 + migrations/V025__custom_theme_colors.sql | 3 + migrations/V026__mail_preferences.sql | 14 + migrations/V027__contact_profile.sql | 13 + migrations/V028__contact_multi_values.sql | 3 + migrations/V029__draft_send_options.sql | 2 + migrations/V030__folder_list_preferences.sql | 7 + migrations/V031__compose_preferences.sql | 4 + migrations/V032__advanced_appearance.sql | 15 + migrations/V033__draft_compose_format.sql | 1 + migrations/V034__imap_keyword_sync.sql | 4 + .../V035__compose_behavior_preferences.sql | 5 + migrations/V036__remote_draft_tracking.sql | 3 + migrations/V037__openpgp_keyring.sql | 24 + migrations/V038__identity_openpgp.sql | 4 + migrations/V039__draft_openpgp_options.sql | 2 + migrations/V040__draft_openpgp_attach_key.sql | 1 + .../V041__draft_openpgp_recipient_keys.sql | 1 + packaging/systemd/rustbox.service | 30 + rust-toolchain.toml | 2 + scripts/build-release.sh | 23 + scripts/finish-upstream.sh | 81 + scripts/install-systemd.sh | 26 + scripts/oxi-port-commit.sh | 59 + scripts/oxi-status.sh | 42 + scripts/setup-upstream.sh | 41 + scripts/sync-upstream.sh | 90 + src/AGENTS.md | 43 + src/auth/csrf.rs | 189 + src/auth/imap_auth.rs | 174 + src/auth/middleware.rs | 558 ++ src/auth/mod.rs | 5 + src/auth/session.rs | 1331 ++++ src/auth/user_data.rs | 139 + src/calendar/ics.rs | 301 + src/calendar/mod.rs | 1 + src/config.rs | 538 ++ src/db/calendar.rs | 800 ++ src/db/contact_groups.rs | 300 + src/db/contacts.rs | 686 ++ src/db/display_preferences.rs | 318 + src/db/drafts.rs | 469 ++ src/db/folder_preferences.rs | 148 + src/db/folders.rs | 465 ++ src/db/header_sync.rs | 51 + src/db/identities.rs | 204 + src/db/mail_preferences.rs | 152 + src/db/messages.rs | 1180 +++ src/db/mod.rs | 18 + src/db/notification_preferences.rs | 227 + src/db/pgp.rs | 176 + src/db/pool.rs | 231 + src/db/saved_searches.rs | 78 + src/db/special_folders.rs | 125 + src/db/tags.rs | 501 ++ src/email_theme.rs | 898 +++ src/error.rs | 173 + src/imap/client.rs | 1347 ++++ src/imap/connection.rs | 198 + src/imap/error.rs | 32 + src/imap/mock.rs | 832 ++ src/imap/mod.rs | 6 + src/imap/parse.rs | 193 + src/imap/pool.rs | 551 ++ src/imap/types.rs | 124 + src/main.rs | 149 + src/pgp/discovery.rs | 315 + src/pgp/mod.rs | 1152 +++ src/realtime/events.rs | 163 + src/realtime/idle.rs | 974 +++ src/realtime/mod.rs | 4 + src/realtime/sync.rs | 528 ++ src/realtime/ws.rs | 194 + src/routes/attachments.rs | 255 + src/routes/auth.rs | 1050 +++ src/routes/calendar.rs | 452 ++ src/routes/contact_groups.rs | 184 + src/routes/contacts.rs | 1152 +++ src/routes/diagnostics.rs | 344 + src/routes/display_preferences.rs | 251 + src/routes/drafts.rs | 592 ++ src/routes/folder_mgmt.rs | 337 + src/routes/folder_preferences.rs | 47 + src/routes/folders.rs | 130 + src/routes/health.rs | 149 + src/routes/identities.rs | 120 + src/routes/mail_preferences.rs | 33 + src/routes/messages/mod.rs | 1988 +++++ src/routes/messages/types.rs | 152 + src/routes/mod.rs | 501 ++ src/routes/notification_preferences.rs | 39 + src/routes/pgp.rs | 884 +++ src/routes/quota.rs | 111 + src/routes/search.rs | 705 ++ src/routes/send.rs | 590 ++ src/routes/sieve.rs | 261 + src/routes/special_folders.rs | 39 + src/routes/tags.rs | 578 ++ src/routes/tests.rs | 2238 ++++++ src/routes/unsubscribe.rs | 366 + src/search/engine.rs | 678 ++ src/search/mod.rs | 1 + src/sieve/client.rs | 746 ++ src/sieve/error.rs | 37 + src/sieve/mod.rs | 5 + src/smtp/builder.rs | 222 + src/smtp/client.rs | 157 + src/smtp/error.rs | 27 + src/smtp/mock.rs | 242 + src/smtp/mod.rs | 4 + src/smtp/types.rs | 167 + src/ui.rs | 93 + src/web/app.js | 7050 +++++++++++++++++ src/web/css/00-tokens.css | 114 + src/web/css/10-base.css | 206 + src/web/css/20-shell.css | 326 + src/web/css/30-mail.css | 1035 +++ src/web/css/40-compose.css | 786 ++ src/web/css/50-settings.css | 2360 ++++++ src/web/css/60-responsive.css | 253 + src/web/css/components.css | 108 + src/web/css/manifest.txt | 7 + src/web/i18n/en.json | 40 + src/web/i18n/pl.json | 40 + src/web/icons.svg | 56 + src/web/index.html | 698 ++ tests/fixtures/account-session-expired.json | 8 + ui/app.css | 1373 ++++ ui/app.js | 7048 ++++++++++++++++ ui/icons.svg | 56 + ui/index.html | 698 ++ 180 files changed, 63750 insertions(+) create mode 100644 .env.example create mode 100644 .envrc create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 NOTICE.md create mode 100644 README.md create mode 100644 RELEASE-MANIFEST.txt create mode 100644 UPSTREAM_BASE create mode 100644 build.rs create mode 100644 config.dev.toml create mode 100644 config.example.toml create mode 100755 dev.sh create mode 100644 docs/IMPLEMENTATION_STATUS.md create mode 100644 docs/RELEASING.md create mode 100644 docs/ROADMAP_GAPS.md create mode 100644 docs/RUSTBOX_PLAN.md create mode 100644 docs/UI.md create mode 100644 docs/UPSTREAM.md create mode 100644 docs/VALIDATION.md create mode 100644 docs/imap-pool.md create mode 100644 docs/releases/v0.0.1.md create mode 100644 examples/test_email_theme.rs create mode 100644 make_zip.py create mode 100644 migrations/V001__initial_schema.sql create mode 100644 migrations/V002__folders_and_messages.sql create mode 100644 migrations/V003__add_date_epoch.sql create mode 100644 migrations/V004__folder_messages_updated_at.sql create mode 100644 migrations/V005__drafts_and_attachments.sql create mode 100644 migrations/V006__cache_attachments_and_headers.sql create mode 100644 migrations/V007__contacts.sql create mode 100644 migrations/V008__identities.sql create mode 100644 migrations/V009__notification_preferences.sql create mode 100644 migrations/V010__contact_groups.sql create mode 100644 migrations/V011__message_reaction.sql create mode 100644 migrations/V012__tags.sql create mode 100644 migrations/V013__display_preferences.sql create mode 100644 migrations/V014__compose_format.sql create mode 100644 migrations/V015__deep_index.sql create mode 100644 migrations/V016__calendar.sql create mode 100644 migrations/V017__add_email_theme.sql create mode 100644 migrations/V018__animation_mode.sql create mode 100644 migrations/V019__known_addresses.sql create mode 100644 migrations/V020__appearance_personalization.sql create mode 100644 migrations/V021__special_folders.sql create mode 100644 migrations/V022__identity_options.sql create mode 100644 migrations/V023__saved_searches.sql create mode 100644 migrations/V024__notification_dnd.sql create mode 100644 migrations/V025__custom_theme_colors.sql create mode 100644 migrations/V026__mail_preferences.sql create mode 100644 migrations/V027__contact_profile.sql create mode 100644 migrations/V028__contact_multi_values.sql create mode 100644 migrations/V029__draft_send_options.sql create mode 100644 migrations/V030__folder_list_preferences.sql create mode 100644 migrations/V031__compose_preferences.sql create mode 100644 migrations/V032__advanced_appearance.sql create mode 100644 migrations/V033__draft_compose_format.sql create mode 100644 migrations/V034__imap_keyword_sync.sql create mode 100644 migrations/V035__compose_behavior_preferences.sql create mode 100644 migrations/V036__remote_draft_tracking.sql create mode 100644 migrations/V037__openpgp_keyring.sql create mode 100644 migrations/V038__identity_openpgp.sql create mode 100644 migrations/V039__draft_openpgp_options.sql create mode 100644 migrations/V040__draft_openpgp_attach_key.sql create mode 100644 migrations/V041__draft_openpgp_recipient_keys.sql create mode 100644 packaging/systemd/rustbox.service create mode 100644 rust-toolchain.toml create mode 100755 scripts/build-release.sh create mode 100755 scripts/finish-upstream.sh create mode 100755 scripts/install-systemd.sh create mode 100755 scripts/oxi-port-commit.sh create mode 100755 scripts/oxi-status.sh create mode 100755 scripts/setup-upstream.sh create mode 100755 scripts/sync-upstream.sh create mode 100644 src/AGENTS.md create mode 100644 src/auth/csrf.rs create mode 100644 src/auth/imap_auth.rs create mode 100644 src/auth/middleware.rs create mode 100644 src/auth/mod.rs create mode 100644 src/auth/session.rs create mode 100644 src/auth/user_data.rs create mode 100644 src/calendar/ics.rs create mode 100644 src/calendar/mod.rs create mode 100644 src/config.rs create mode 100644 src/db/calendar.rs create mode 100644 src/db/contact_groups.rs create mode 100644 src/db/contacts.rs create mode 100644 src/db/display_preferences.rs create mode 100644 src/db/drafts.rs create mode 100644 src/db/folder_preferences.rs create mode 100644 src/db/folders.rs create mode 100644 src/db/header_sync.rs create mode 100644 src/db/identities.rs create mode 100644 src/db/mail_preferences.rs create mode 100644 src/db/messages.rs create mode 100644 src/db/mod.rs create mode 100644 src/db/notification_preferences.rs create mode 100644 src/db/pgp.rs create mode 100644 src/db/pool.rs create mode 100644 src/db/saved_searches.rs create mode 100644 src/db/special_folders.rs create mode 100644 src/db/tags.rs create mode 100644 src/email_theme.rs create mode 100644 src/error.rs create mode 100644 src/imap/client.rs create mode 100644 src/imap/connection.rs create mode 100644 src/imap/error.rs create mode 100644 src/imap/mock.rs create mode 100644 src/imap/mod.rs create mode 100644 src/imap/parse.rs create mode 100644 src/imap/pool.rs create mode 100644 src/imap/types.rs create mode 100644 src/main.rs create mode 100644 src/pgp/discovery.rs create mode 100644 src/pgp/mod.rs create mode 100644 src/realtime/events.rs create mode 100644 src/realtime/idle.rs create mode 100644 src/realtime/mod.rs create mode 100644 src/realtime/sync.rs create mode 100644 src/realtime/ws.rs create mode 100644 src/routes/attachments.rs create mode 100644 src/routes/auth.rs create mode 100644 src/routes/calendar.rs create mode 100644 src/routes/contact_groups.rs create mode 100644 src/routes/contacts.rs create mode 100644 src/routes/diagnostics.rs create mode 100644 src/routes/display_preferences.rs create mode 100644 src/routes/drafts.rs create mode 100644 src/routes/folder_mgmt.rs create mode 100644 src/routes/folder_preferences.rs create mode 100644 src/routes/folders.rs create mode 100644 src/routes/health.rs create mode 100644 src/routes/identities.rs create mode 100644 src/routes/mail_preferences.rs create mode 100644 src/routes/messages/mod.rs create mode 100644 src/routes/messages/types.rs create mode 100644 src/routes/mod.rs create mode 100644 src/routes/notification_preferences.rs create mode 100644 src/routes/pgp.rs create mode 100644 src/routes/quota.rs create mode 100644 src/routes/search.rs create mode 100644 src/routes/send.rs create mode 100644 src/routes/sieve.rs create mode 100644 src/routes/special_folders.rs create mode 100644 src/routes/tags.rs create mode 100644 src/routes/tests.rs create mode 100644 src/routes/unsubscribe.rs create mode 100644 src/search/engine.rs create mode 100644 src/search/mod.rs create mode 100644 src/sieve/client.rs create mode 100644 src/sieve/error.rs create mode 100644 src/sieve/mod.rs create mode 100644 src/smtp/builder.rs create mode 100644 src/smtp/client.rs create mode 100644 src/smtp/error.rs create mode 100644 src/smtp/mock.rs create mode 100644 src/smtp/mod.rs create mode 100644 src/smtp/types.rs create mode 100644 src/ui.rs create mode 100644 src/web/app.js create mode 100644 src/web/css/00-tokens.css create mode 100644 src/web/css/10-base.css create mode 100644 src/web/css/20-shell.css create mode 100644 src/web/css/30-mail.css create mode 100644 src/web/css/40-compose.css create mode 100644 src/web/css/50-settings.css create mode 100644 src/web/css/60-responsive.css create mode 100644 src/web/css/components.css create mode 100644 src/web/css/manifest.txt create mode 100644 src/web/i18n/en.json create mode 100644 src/web/i18n/pl.json create mode 100644 src/web/icons.svg create mode 100644 src/web/index.html create mode 100644 tests/fixtures/account-session-expired.json create mode 100644 ui/app.css create mode 100644 ui/app.js create mode 100644 ui/icons.svg create mode 100644 ui/index.html diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..02c6e48 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..2c57cda --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +dotenv_if_exists diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..45400c8 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6dc44d2 --- /dev/null +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..408962b --- /dev/null +++ b/CHANGELOG.md @@ -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. diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..b98544a --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,4768 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "bytes", + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + +[[package]] +name = "aes-kw" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69fa2b352dcefb5f7f3a5fb840e02665d311d878955380515e4fd50095dd3d8c" +dependencies = [ + "aes", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "ar_archive_writer" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eb93bbb63b9c227414f6eb3a0adfddca591a8ce1e9b60661bb08969b87e340b" +dependencies = [ + "object", +] + +[[package]] +name = "arc-swap" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f3647c145568cec02c42054e07bdf9a5a698e15b466fb2341bfc393cd24aa5" +dependencies = [ + "rustversion", +] + +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures", + "password-hash", + "zeroize", +] + +[[package]] +name = "async-channel" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81953c529336010edd6d8e358f886d9581267795c61b19475b71314bffa46d35" +dependencies = [ + "concurrent-queue", + "event-listener 2.5.3", + "futures-core", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-compression" +version = "0.4.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0f9ee0f6e02ffd7ad5816e9464499fba7b3effd01123b515c41d1697c43dad1" +dependencies = [ + "compression-codecs", + "compression-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "async-imap" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca726c61b73c471f531b65e83e161776ba62c2b6ba4ec73d51fad357009ed00a" +dependencies = [ + "async-channel 2.5.0", + "async-compression", + "base64", + "bytes", + "chrono", + "futures", + "imap-proto", + "log", + "nom 7.1.3", + "pin-project", + "pin-utils", + "self_cell", + "stop-token", + "thiserror 1.0.69", + "tokio", +] + +[[package]] +name = "async-native-tls" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9343dc5acf07e79ff82d0c37899f079db3534d99f189a1837c8e549c99405bec" +dependencies = [ + "native-tls", + "thiserror 1.0.69", + "tokio", + "url", +] + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "axum" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" +dependencies = [ + "axum-core", + "base64", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "multer", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sha1", + "sync_wrapper", + "tokio", + "tokio-tungstenite", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-extra" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9963ff19f40c6102c76756ef0a46004c0d58957d87259fc9208ff8441c12ab96" +dependencies = [ + "axum", + "axum-core", + "bytes", + "cookie", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "serde_core", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitfields" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef6e59298da389bc0649c7463856b34c6e17fe542f88939426ede4436c6b1195" +dependencies = [ + "bitfields-impl", +] + +[[package]] +name = "bitfields-impl" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2c044f98f86f15414668d6c8187c7e4fadab1ad2b31680f648703e0fe07c555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "thiserror 2.0.18", +] + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "bitpacking" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96a7139abd3d9cebf8cd6f920a389cf3dc9576172e32f4563f188cae3c3eb019" +dependencies = [ + "crunchy", +] + +[[package]] +name = "bitvec" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + +[[package]] +name = "blowfish" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7" +dependencies = [ + "byteorder", + "cipher", +] + +[[package]] +name = "buffer-redux" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "431a9cc8d7efa49bc326729264537f5e60affce816c66edf434350778c9f4f54" +dependencies = [ + "memchr", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "camellia" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3264e2574e9ef2b53ce6f536dea83a69ac0bc600b762d1523ff83fe07230ce30" +dependencies = [ + "byteorder", + "cipher", +] + +[[package]] +name = "cast5" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b07d673db1ccf000e90f54b819db9e75a8348d6eb056e9b8ab53231b7a9911" +dependencies = [ + "cipher", +] + +[[package]] +name = "cc" +version = "1.2.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "census" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f4c707c6a209cbe82d10abd08e1ea8995e9ea937d2550646e02798948992be0" + +[[package]] +name = "cfb-mode" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "738b8d467867f80a71351933f70461f5b56f24d5c93e0cf216e59229c968d330" +dependencies = [ + "cipher", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "num-traits", +] + +[[package]] +name = "chumsky" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eebd66744a15ded14960ab4ccdbfb51ad3b81f51f3f04a80adac98c985396c9" +dependencies = [ + "hashbrown 0.14.5", + "stacker", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "cmac" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8543454e3c3f5126effff9cd44d562af4e31fb8ce1cc0d3dcd8f084515dbc1aa" +dependencies = [ + "cipher", + "dbl", + "digest", +] + +[[package]] +name = "compression-codecs" +version = "0.4.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb7b51a7d9c967fc26773061ba86150f19c50c0d65c887cb1fbe295fd16619b7" +dependencies = [ + "compression-core", + "flate2", +] + +[[package]] +name = "compression-core" +version = "0.4.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d" + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc24" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd121741cf3eb82c08dd3023eb55bf2665e5f60ec20f89760cf836ae4562e6a0" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "typenum", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "cx448" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4c0cf476284b03eb6c10e78787b21c7abb7d7d43cb2f02532ba6b831ed892fa" +dependencies = [ + "crypto-bigint", + "elliptic-curve", + "pkcs8", + "rand_core 0.6.4", + "serdect 0.3.0", + "sha3", + "signature", + "subtle", + "zeroize", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dashmap" +version = "6.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + +[[package]] +name = "dbl" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd2735a791158376708f9347fe8faba9667589d82427ef3aed6794a8981de3d9" +dependencies = [ + "generic-array", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", + "serde_core", +] + +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn 2.0.117", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.117", + "unicode-xid", +] + +[[package]] +name = "des" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffdd80ce8ce993de27e9f063a444a4d53ce8e8db4c1f00cc03af5ad5a9867a1e" +dependencies = [ + "cipher", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "dsa" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48bc224a9084ad760195584ce5abb3c2c34a225fa312a128ad245a6b412b7689" +dependencies = [ + "digest", + "num-bigint-dig", + "num-traits", + "pkcs8", + "rfc6979", + "sha2", + "signature", + "zeroize", +] + +[[package]] +name = "eax" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9954fabd903b82b9d7a68f65f97dc96dd9ad368e40ccc907a7c19d53e6bfac28" +dependencies = [ + "aead", + "cipher", + "cmac", + "ctr", + "subtle", +] + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core 0.6.4", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "base64ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "hkdf", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "serde_json", + "serdect 0.2.0", + "subtle", + "tap", + "zeroize", +] + +[[package]] +name = "email-encoding" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9298e6504d9b9e780ed3f7dfd43a61be8cd0e09eb07f7706a945b0072b6670b6" +dependencies = [ + "base64", + "memchr", +] + +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "2.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener 5.4.1", + "pin-project-lite", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fastdivide" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afc2bd4d5a73106dd53d10d73d3401c2f32730ba2c0b93ddb888a8983680471" + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "bitvec", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "figment" +version = "0.10.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cb01cd46b0cf372153850f4c6c272d9cbea2da513e07538405148f95bd789f3" +dependencies = [ + "atomic", + "pear", + "serde", + "toml", + "uncased", + "version_check", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "forwarded-header-value" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8835f84f38484cc86f110a805655697908257fb9a7af005234060891557198e9" +dependencies = [ + "nonempty", + "thiserror 1.0.69", +] + +[[package]] +name = "fs4" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7e180ac76c23b45e767bd7ae9579bc0bb458618c4bc71835926e098e61d15f8" +dependencies = [ + "rustix 0.38.44", + "windows-sys 0.52.0", +] + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-timer" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] +name = "governor" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9efcab3c1958580ff1f25a2a41be1668f7603d849bb63af523b208a3cc1223b8" +dependencies = [ + "cfg-if", + "dashmap", + "futures-sink", + "futures-timer", + "futures-util", + "getrandom 0.3.4", + "hashbrown 0.16.1", + "nonzero_ext", + "parking_lot", + "portable-atomic", + "quanta", + "rand 0.9.2", + "smallvec", + "spinning_top", + "web-time", +] + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", + "allocator-api2", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + +[[package]] +name = "hashify" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "149e3ea90eb5a26ad354cfe3cb7f7401b9329032d0235f2687d03a35f30e5d4c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "htmlescape" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9025058dae765dee5070ec375f591e2ba14638c63feff74f13805a72e523163" + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "libc", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "ical" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b7cab7543a8b7729a19e2c04309f902861293dcdae6558dfbeb634454d279f6" +dependencies = [ + "thiserror 1.0.69", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "idea" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "075557004419d7f2031b8bb7f44bb43e55a83ca7b63076a8fb8fe75753836477" +dependencies = [ + "cipher", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "imap-proto" +version = "0.16.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba1f9b30846c3d04371159ef3a0413ce7c1ae0a8c619cd255c60b3d902553f22" +dependencies = [ + "nom 7.1.3", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "inlinable_string" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb" + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", + "js-sys", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.90" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14dc6f6450b3f6d4ed5b16327f38fed626d375a886159ca555bd7822c0c3a5a6" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "k256" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b" +dependencies = [ + "cfg-if", + "ecdsa", + "elliptic-curve", + "once_cell", + "sha2", + "signature", +] + +[[package]] +name = "keccak" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" +dependencies = [ + "cpufeatures", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "lettre" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e13e10e8818f8b2a60f52cb127041d388b89f3a96a62be9ceaffa22262fef7f" +dependencies = [ + "async-trait", + "base64", + "chumsky", + "email-encoding", + "email_address", + "fastrand", + "futures-io", + "futures-util", + "httpdate", + "idna", + "mime", + "native-tls", + "nom 8.0.0", + "percent-encoding", + "quoted_printable", + "socket2", + "tokio", + "tokio-native-tls", + "url", +] + +[[package]] +name = "levenshtein_automata" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c2cdeb66e45e9f36bfad5bbdb4d2384e70936afbee843c6f6543f0c551ebb25" + +[[package]] +name = "libc" +version = "0.2.182" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libsqlite3-sys" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbb8270bb4060bd76c6e96f20c52d80620f1d82a3470885694e41e0f81ef6fe7" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "lz4_flex" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08ab2867e3eeeca90e844d1940eab391c9dc5228783db2ed999acbc0a9ed375a" + +[[package]] +name = "mail-parser" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f82a3d6522697593ba4c683e0a6ee5a40fee93bc1a525e3cc6eeb3da11fd8897" +dependencies = [ + "hashify", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "measure_time" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbefd235b0aadd181626f281e1d684e116972988c14c264e42069d5e8a5775cc" +dependencies = [ + "instant", + "log", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "memmap2" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" +dependencies = [ + "libc", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "multer" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http", + "httparse", + "memchr", + "mime", + "spin", + "version_check", +] + +[[package]] +name = "murmurhash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2195bf6aa996a481483b29d62a7663eed3fe39600c460e323f8ff41e90bdd89b" + +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "nonempty" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9e591e719385e6ebaeb5ce5d3887f7d5676fceca6411d1925ccc95745f3d6f7" + +[[package]] +name = "nonzero_ext" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21" + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.8", + "serde", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-conv" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + +[[package]] +name = "ocb3" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c196e0276c471c843dd5777e7543a36a298a4be942a2a688d8111cd43390dedb" +dependencies = [ + "aead", + "cipher", + "ctr", + "subtle", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "oneshot" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "269bca4c2591a28585d6bf10d9ed0332b7d76900a1b02bec41bdc3a2cdcda107" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "openssl" +version = "0.10.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "ownedbytes" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3a059efb063b8f425b948e042e6b9bd85edfe60e913630ed727b23e2dfcc558" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p521" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fc9e2161f1f215afdfce23677034ae137bbd45016a880c2eb3ba8eb95f085b2" +dependencies = [ + "base16ct", + "ecdsa", + "elliptic-curve", + "primeorder", + "rand_core 0.6.4", + "sha2", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "pear" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdeeaa00ce488657faba8ebf44ab9361f9365a97bd39ffb8a60663f57ff4b467" +dependencies = [ + "inlinable_string", + "pear_codegen", + "yansi", +] + +[[package]] +name = "pear_codegen" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bab5b985dc082b345f812b7df84e1bef27e7207b39e448439ba8bd69c93f147" +dependencies = [ + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pgp" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae70f4d9325a391db30d115d6191a7bb67cec856aa0bba131290f0c4e09a532a" +dependencies = [ + "aead", + "aes", + "aes-gcm", + "aes-kw", + "argon2", + "base64", + "bitfields", + "block-padding", + "blowfish", + "buffer-redux", + "byteorder", + "bytes", + "camellia", + "cast5", + "cfb-mode", + "cipher", + "const-oid", + "crc24", + "curve25519-dalek", + "cx448", + "derive_builder", + "derive_more", + "des", + "digest", + "dsa", + "eax", + "ecdsa", + "ed25519-dalek", + "elliptic-curve", + "flate2", + "generic-array", + "hex", + "hkdf", + "idea", + "k256", + "log", + "md-5", + "memchr", + "nom 8.0.0", + "num-bigint-dig", + "num-traits", + "num_enum", + "ocb3", + "p256", + "p384", + "p521", + "rand 0.8.8", + "replace_with", + "ripemd", + "rsa", + "sha1", + "sha1-checked", + "sha2", + "sha3", + "signature", + "smallvec", + "snafu", + "subtle", + "twofish", + "x25519-dalek", + "zeroize", +] + +[[package]] +name = "pin-project" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proc-macro2-diagnostics" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "version_check", + "yansi", +] + +[[package]] +name = "psm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3852766467df634d74f0b2d7819bf8dc483a0eb2e3b0f50f756f9cfe8b0d18d8" +dependencies = [ + "ar_archive_writer", + "cc", +] + +[[package]] +name = "quanta" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3ab5a9d756f0d97bdc89019bd2e4ea098cf9cde50ee7564dde6b81ccc8f06c7" +dependencies = [ + "crossbeam-utils", + "libc", + "once_cell", + "raw-cpuid", + "wasi", + "web-sys", + "winapi", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "quoted_printable" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "640c9bd8497b02465aeef5375144c26062e0dcd5939dfcbb0f5db76cb8c17c73" + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_distr" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32cb0b9bc82b0a0876c2dd994a7e7a2683d3e7390ca40e6886785ef0c7e3ee31" +dependencies = [ + "num-traits", + "rand 0.8.8", +] + +[[package]] +name = "raw-cpuid" +version = "11.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" +dependencies = [ + "bitflags", +] + +[[package]] +name = "rayon" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "refinery" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52c427f2572afe5c6cbfa2b1bf40071c89bf1a8539e958ea582842f6f38dcfae" +dependencies = [ + "refinery-core", + "refinery-macros", +] + +[[package]] +name = "refinery-core" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "702655abfc67f93a6f735e9fa4ace7d2e580633f8961f28acbfd7583ddce936c" +dependencies = [ + "async-trait", + "cfg-if", + "log", + "regex", + "rusqlite", + "serde", + "siphasher", + "thiserror 2.0.18", + "time", + "toml", + "url", + "walkdir", +] + +[[package]] +name = "refinery-macros" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5145756cdf293b5089dc6b4f103f1a1229cc55d67082c866f8c8289531c4b983" +dependencies = [ + "proc-macro2", + "quote", + "refinery-core", + "regex", + "syn 2.0.117", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "replace_with" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51743d3e274e2b18df81c4dc6caf8a5b8e15dbe799e0dca05c7617380094e884" + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + +[[package]] +name = "ripemd" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd124222d17ad93a644ed9d011a40f4fb64aa54275c08cc216524a9ea82fb09f" +dependencies = [ + "digest", +] + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rusqlite" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37e34486da88d8e051c7c0e23c3f15fd806ea8546260aa2fec247e97242ec143" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "rust-stemmers" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e46a2036019fdb888131db7a4c847a1063a7493f971ed94ea82c67eada63ca54" +dependencies = [ + "serde", + "serde_derive", +] + +[[package]] +name = "rustbox" +version = "0.0.1" +dependencies = [ + "async-imap", + "async-native-tls", + "async-trait", + "axum", + "axum-extra", + "base64", + "dashmap", + "figment", + "futures", + "governor", + "http-body-util", + "ical", + "ipnet", + "lettre", + "mail-parser", + "pgp", + "rand 0.8.8", + "rand 0.9.2", + "refinery", + "regex", + "rusqlite", + "serde", + "serde_json", + "sha1", + "sha2", + "smallvec", + "tantivy", + "tempfile", + "tokio", + "tower", + "tower-http", + "tower_governor", + "tracing", + "tracing-subscriber", + "url", + "uuid", +] + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "serdect 0.2.0", + "subtle", + "zeroize", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "self_cell" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89" + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serdect" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a84f14a19e9a014bb9f4512488d9829a68e04ecabffb0f9904cd1ace94598177" +dependencies = [ + "base16ct", + "serde", +] + +[[package]] +name = "serdect" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f42f67da2385b51a5f9652db9c93d78aeaf7610bf5ec366080b6de810604af53" +dependencies = [ + "base16ct", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha1-checked" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89f599ac0c323ebb1c6082821a54962b839832b03984598375bff3975b804423" +dependencies = [ + "digest", + "sha1", + "zeroize", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha3" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" +dependencies = [ + "digest", + "keccak", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + +[[package]] +name = "siphasher" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" + +[[package]] +name = "sketches-ddsketch" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85636c14b73d81f541e525f585c0a2109e6744e1565b5c1668e31c70c10ed65c" +dependencies = [ + "serde", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "snafu" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e45cb604038abb7b926b679887b3226d8d0f23874b66623625a0454be425a4b7" +dependencies = [ + "snafu-derive", +] + +[[package]] +name = "snafu-derive" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "287f59010008f0d7cf5e3b03196d666c1acc46c8d3e9cf34c28a1a7157601e72" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "socket2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + +[[package]] +name = "spinning_top" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d96d2d1d716fb500937168cc09353ffdc7a012be8475ac7308e1bdf0e3923300" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stacker" +version = "0.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d74a23609d509411d10e2176dc2a4346e3b4aea2e7b1869f19fdedbc71c013" +dependencies = [ + "cc", + "cfg-if", + "libc", + "psm", + "windows-sys 0.59.0", +] + +[[package]] +name = "stop-token" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af91f480ee899ab2d9f8435bfdfc14d08a5754bd9d3fef1f1a1c23336aad6c8b" +dependencies = [ + "async-channel 1.9.0", + "cfg-if", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tantivy" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96599ea6fccd844fc833fed21d2eecac2e6a7c1afd9e044057391d78b1feb141" +dependencies = [ + "aho-corasick", + "arc-swap", + "base64", + "bitpacking", + "byteorder", + "census", + "crc32fast", + "crossbeam-channel", + "downcast-rs", + "fastdivide", + "fnv", + "fs4", + "htmlescape", + "itertools", + "levenshtein_automata", + "log", + "lru", + "lz4_flex", + "measure_time", + "memmap2", + "num_cpus", + "once_cell", + "oneshot", + "rayon", + "regex", + "rust-stemmers", + "rustc-hash", + "serde", + "serde_json", + "sketches-ddsketch", + "smallvec", + "tantivy-bitpacker", + "tantivy-columnar", + "tantivy-common", + "tantivy-fst", + "tantivy-query-grammar", + "tantivy-stacker", + "tantivy-tokenizer-api", + "tempfile", + "thiserror 1.0.69", + "time", + "uuid", + "winapi", +] + +[[package]] +name = "tantivy-bitpacker" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "284899c2325d6832203ac6ff5891b297fc5239c3dc754c5bc1977855b23c10df" +dependencies = [ + "bitpacking", +] + +[[package]] +name = "tantivy-columnar" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12722224ffbe346c7fec3275c699e508fd0d4710e629e933d5736ec524a1f44e" +dependencies = [ + "downcast-rs", + "fastdivide", + "itertools", + "serde", + "tantivy-bitpacker", + "tantivy-common", + "tantivy-sstable", + "tantivy-stacker", +] + +[[package]] +name = "tantivy-common" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8019e3cabcfd20a1380b491e13ff42f57bb38bf97c3d5fa5c07e50816e0621f4" +dependencies = [ + "async-trait", + "byteorder", + "ownedbytes", + "serde", + "time", +] + +[[package]] +name = "tantivy-fst" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d60769b80ad7953d8a7b2c70cdfe722bbcdcac6bccc8ac934c40c034d866fc18" +dependencies = [ + "byteorder", + "regex-syntax", + "utf8-ranges", +] + +[[package]] +name = "tantivy-query-grammar" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "847434d4af57b32e309f4ab1b4f1707a6c566656264caa427ff4285c4d9d0b82" +dependencies = [ + "nom 7.1.3", +] + +[[package]] +name = "tantivy-sstable" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c69578242e8e9fc989119f522ba5b49a38ac20f576fc778035b96cc94f41f98e" +dependencies = [ + "tantivy-bitpacker", + "tantivy-common", + "tantivy-fst", + "zstd", +] + +[[package]] +name = "tantivy-stacker" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56d6ff5591fc332739b3ce7035b57995a3ce29a93ffd6012660e0949c956ea8" +dependencies = [ + "murmurhash32", + "rand_distr", + "tantivy-common", +] + +[[package]] +name = "tantivy-tokenizer-api" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a0dcade25819a89cfe6f17d932c9cedff11989936bf6dd4f336d50392053b04" +dependencies = [ + "serde", +] + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "tempfile" +version = "3.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0" +dependencies = [ + "fastrand", + "getrandom 0.4.1", + "once_cell", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tonic" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fec7c61a0695dc1887c1b53952990f3ad2e3a31453e1f49f10e75424943a93ec" +dependencies = [ + "async-trait", + "axum", + "base64", + "bytes", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "socket2", + "sync_wrapper", + "tokio", + "tokio-stream", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "indexmap", + "pin-project-lite", + "slab", + "sync_wrapper", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags", + "bytes", + "http", + "http-body", + "pin-project-lite", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tower_governor" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44de9b94d849d3c46e06a883d72d408c2de6403367b39df2b1c9d9e7b6736fe6" +dependencies = [ + "axum", + "forwarded-header-value", + "governor", + "http", + "pin-project", + "thiserror 2.0.18", + "tonic", + "tower", + "tracing", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +dependencies = [ + "serde", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "serde", + "serde_json", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", + "tracing-serde", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.2", + "sha1", + "thiserror 2.0.18", + "utf-8", +] + +[[package]] +name = "twofish" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a78e83a30223c757c3947cd144a31014ff04298d8719ae10d03c31c0448c8013" +dependencies = [ + "cipher", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "uncased" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1b88fcfe09e89d3866a5c11019378088af2d24c3fbd4f0543f96b479ec90697" +dependencies = [ + "version_check", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8-ranges" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fcfc827f90e53a02eaef5e535ee14266c1d569214c6aa70133a624d8a3164ba" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb" +dependencies = [ + "getrandom 0.4.1", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.113" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60722a937f594b7fde9adb894d7c092fc1bb6612897c46368d18e7a20208eff2" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.113" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fac8c6395094b6b91c4af293f4c79371c163f9a6f56184d2c9a85f5a95f3950" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.113" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab3fabce6159dc20728033842636887e4877688ae94382766e00b180abac9d60" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.113" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de0e091bdb824da87dc01d967388880d017a0a9bc4f3bdc0d86ee9f9336e3bb5" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.90" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "705eceb4ce901230f8625bd1d665128056ccbe4b7408faa625eec1ba80f59a97" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.7.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn 2.0.117", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek", + "rand_core 0.6.4", + "serde", + "zeroize", +] + +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..717788f --- /dev/null +++ b/Cargo.toml @@ -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" diff --git a/NOTICE.md b/NOTICE.md new file mode 100644 index 0000000..10e2e77 --- /dev/null +++ b/NOTICE.md @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..ef93d64 --- /dev/null +++ b/README.md @@ -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 +``` + +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. diff --git a/RELEASE-MANIFEST.txt b/RELEASE-MANIFEST.txt new file mode 100644 index 0000000..b115aee --- /dev/null +++ b/RELEASE-MANIFEST.txt @@ -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. diff --git a/UPSTREAM_BASE b/UPSTREAM_BASE new file mode 100644 index 0000000..72b6656 --- /dev/null +++ b/UPSTREAM_BASE @@ -0,0 +1 @@ +7f1507c486b78cf33e54beb701dd460d3bd2bd5d diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..915deaf --- /dev/null +++ b/build.rs @@ -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"); +} diff --git a/config.dev.toml b/config.dev.toml new file mode 100644 index 0000000..a500e7b --- /dev/null +++ b/config.dev.toml @@ -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" diff --git a/config.example.toml b/config.example.toml new file mode 100644 index 0000000..fe49658 --- /dev/null +++ b/config.example.toml @@ -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 + diff --git a/dev.sh b/dev.sh new file mode 100755 index 0000000..bb81d8c --- /dev/null +++ b/dev.sh @@ -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 -- "$@" diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md new file mode 100644 index 0000000..dfdbd69 --- /dev/null +++ b/docs/IMPLEMENTATION_STATUS.md @@ -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. diff --git a/docs/RELEASING.md b/docs/RELEASING.md new file mode 100644 index 0000000..535c7c3 --- /dev/null +++ b/docs/RELEASING.md @@ -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. diff --git a/docs/ROADMAP_GAPS.md b/docs/ROADMAP_GAPS.md new file mode 100644 index 0000000..7645525 --- /dev/null +++ b/docs/ROADMAP_GAPS.md @@ -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. diff --git a/docs/RUSTBOX_PLAN.md b/docs/RUSTBOX_PLAN.md new file mode 100644 index 0000000..80eed01 --- /dev/null +++ b/docs/RUSTBOX_PLAN.md @@ -0,0 +1,1357 @@ +# RustBox 0.0.1 — plan funkcjonalny bazowego wydania + +> **Status:** wersja wewnętrzna / przed publikacją +> **Wersja:** `0.0.1` +> **Cel:** doprowadzić RustBox do poziomu funkcjonalnego zbliżonego do Roundcube wraz z typowymi funkcjami zapewnianymi przez jego oficjalne i popularne wtyczki — przed pierwszym publicznym wydaniem. + +## 0. wielojezykowosc, +## wstepnie angielski i polski, wszystko jak w rustproxy, czyli w plkikach jezykow, wybor nalezy do usera + + + +## 1. Zasada wydania 0.0.1 + +RustBox `0.0.1` jest wydaniem bazowym i nie jest przeznaczony do publikacji przed zakończeniem całego poniższego zakresu. + +Wszystkie funkcje opisane w tym dokumencie należą do `0.0.1`. Kolejne sekcje są etapami realizacyjnymi wewnątrz tej samej wersji, a nie osobnymi wydaniami. + +Podstawowe założenia: + +- jedna aplikacja Rust; +- jeden proces runtime; +- brak Node.js, Bun, React, Next.js i TypeScriptu w źródłach produktu; +- interfejs HTML/CSS/vanilla JavaScript osadzony w binarce; +- konfiguracja serwerów IMAP/SMTP/ManageSieve wyłącznie po stronie serwera w pliku konfiguracyjnym; +- ekran logowania zawiera tylko dane użytkownika potrzebne do uwierzytelnienia, domyślnie e-mail i hasło; +- obsługa IMAP, SMTP, ManageSieve, WebSocket, sesji i UI w jednym procesie; +- zachowanie możliwości śledzenia rozwoju Oxi i portowania jego nowych funkcji do RustBox; +- brak automatycznego przywracania starego frontendu Oxi podczas synchronizacji z upstreamem; +- własny spójny interfejs RustBox inspirowany układem klasycznego webmaila typu Roundcube; +- brak gradientów; +- brak emoji i znaków Unicode udających ikony; +- wyłącznie spójne, popularne ikony SVG; +- ciemny, jasny i systemowy motyw; +- pełna obsługa desktopu i urządzeń mobilnych. + +--- + +# 2. Etap A — pełny fundament klienta IMAP + +To jest najwyższy priorytet. RustBox musi być pełnoprawnym klientem pocztowym zanim rozbudowane zostaną funkcje dodatkowe. + +## 2.1. Foldery IMAP + +Pełny menedżer folderów musi obsługiwać: + +- hierarchiczną listę folderów zgodnie z delimiterem zgłaszanym przez IMAP; +- tworzenie folderów; +- tworzenie podfolderów; +- zmianę nazwy folderu; +- usuwanie folderu z potwierdzeniem; +- subskrypcję folderu; +- anulowanie subskrypcji; +- przenoszenie folderów, jeśli serwer i protokół to umożliwiają; +- opróżnianie folderu; +- oznaczenie wszystkich wiadomości jako przeczytane; +- liczniki wiadomości wszystkich i nieprzeczytanych; +- automatyczne odświeżanie liczników; +- obsługę IMAP IDLE; +- specjalne foldery; +- foldery współdzielone; +- namespaces IMAP; +- właściwe kodowanie nazw folderów; +- Unicode w nazwach folderów; +- możliwość ustawienia folderów specjalnych przez użytkownika. + +### Foldery specjalne + +Użytkownik musi móc wybrać folder dla: + +- Sent; +- Drafts; +- Trash; +- Junk/Spam; +- Archive. + +Układ ustawień: + +```text +Settings +└── Folders + ├── Subscriptions + ├── Special folders + │ ├── Sent + │ ├── Drafts + │ ├── Trash + │ ├── Junk + │ └── Archive + ├── Create folder + ├── Rename + ├── Delete + └── Folder properties +``` + +## 2.2. Operacje na wiadomościach + +RustBox musi obsługiwać: + +- otwarcie wiadomości; +- odpowiedź; +- odpowiedź wszystkim; +- przekazanie wiadomości; +- przekazanie jako załącznik; +- oznaczenie przeczytane/nieprzeczytane; +- flagowanie; +- usunięcie flagi; +- przeniesienie; +- kopiowanie; +- archiwizację; +- usuwanie; +- przeniesienie do spamu; +- oznaczenie jako nie-spam; +- zaznaczanie wielu wiadomości; +- zaznaczanie wszystkich wyników; +- operacje grupowe; +- drag & drop między folderami; +- Undo po możliwych do cofnięcia operacjach; +- zachowanie pozycji scrolla i stanu listy po powrocie do folderu. + +## 2.3. Menu kontekstowe — prawy przycisk myszy + +### Wiadomość + +```text +Open +Reply +Reply all +Forward +Forward as attachment +--- +Mark read / unread +Flag / unflag +--- +Move to… +Copy to… +Archive +Delete +Spam / Not spam +--- +Create filter from message +Add sender to contacts +Search messages from sender +--- +Show headers +Show source +Download .eml +Print +``` + +### Folder + +```text +Open +Open in new view +--- +Mark all read +Create subfolder +Rename +Subscribe / Unsubscribe +Empty +Delete +--- +Properties +Permissions +``` + +### Załącznik + +```text +Open +Download +Save +Download all as ZIP +``` + +## 2.4. Wątki + +Obsługa wątków musi obejmować: + +- widok threaded i flat; +- poprawne użycie `Message-ID`, `In-Reply-To`, `References`; +- rozwijanie i zwijanie wątku; +- automatyczne rozwijanie zgodnie z preferencją użytkownika; +- przechodzenie między wiadomościami w wątku; +- liczbę wiadomości w wątku; +- status unread/flagged na poziomie wątku; +- poprawne zachowanie po przeniesieniu części wątku do innego folderu. + +--- + +# 3. Etap B — pełny podgląd wiadomości + +## 3.1. Podstawowe nagłówki + +Domyślnie pokazywane: + +- From; +- To; +- Cc; +- Date; +- Subject. + +## 3.2. Rozszerzone nagłówki + +Po wybraniu `Show details` / `Show headers`: + +- From; +- Sender; +- Reply-To; +- To; +- Cc; +- Bcc, jeśli wiadomość lokalnie to umożliwia; +- Date; +- Message-ID; +- In-Reply-To; +- References; +- Return-Path; +- Delivered-To; +- Envelope-To, jeśli występuje; +- List-ID; +- List-Unsubscribe; +- User-Agent; +- X-Mailer; +- Authentication-Results; +- DKIM-Signature; +- Received; +- Content-Type; +- Content-Transfer-Encoding; +- pozostałe nagłówki w trybie pełnym. + +## 3.3. Surowa wiadomość + +RustBox musi oferować: + +- `Show source`; +- wyświetlenie dokładnego RFC822/MIME source; +- brak przepuszczania source przez renderer HTML; +- pobranie jako `.eml`; +- kopiowanie Message-ID; +- kopiowanie adresów; +- przełączanie HTML / plain text; +- drukowanie; +- obsługę `message/rfc822`. + +## 3.4. HTML wiadomości + +Renderer wiadomości HTML musi: + +- sanitizować HTML; +- sanitizować CSS; +- blokować skrypty; +- blokować niebezpieczne SVG; +- blokować zewnętrzne zasoby domyślnie; +- obsługiwać CID; +- pozwalać użytkownikowi jednorazowo załadować obrazy; +- mieć politykę: nigdy / kontakty / pytaj / zawsze; +- izolować treść wiadomości od interfejsu RustBox; +- poprawnie pokazywać tekstową alternatywę; +- nie uruchamiać aktywnej zawartości wiadomości. + +## 3.5. Cytaty + +- zwijanie cytowanej treści; +- rozwinięcie na żądanie; +- sensowna detekcja poprzednich odpowiedzi; +- obsługa cytowania plain text i HTML. + +--- + +# 4. Etap C — pełny composer + +## 4.1. Pola wiadomości + +Composer musi obsługiwać: + +- From / identity; +- To; +- Cc; +- Bcc; +- Reply-To; +- Subject; +- priorytet wiadomości; +- żądanie potwierdzenia przeczytania; +- opcjonalne DSN, jeśli backend SMTP pozwala; +- własne dodatkowe nagłówki skonfigurowane przez administratora. + +## 4.2. Tryby edycji + +- HTML; +- plain text; +- Markdown; +- przełączanie trybu z ostrzeżeniem, jeśli może dojść do utraty formatowania. + +## 4.3. Edytor HTML + +Toolbar: + +- Bold; +- Italic; +- Underline; +- Strike; +- font; +- rozmiar; +- kolor tekstu; +- wyrównanie; +- lista numerowana; +- lista punktowana; +- cytat; +- link; +- obraz; +- usuń formatowanie; +- Undo; +- Redo. + +Edytor nie może wymagać Node.js/Bun w runtime. Jeżeli wykorzystywana jest biblioteka edytora, ma być vendored jako statyczny asset i włączona do binarki. + +## 4.4. Drafty + +- automatyczny zapis draftu; +- konfigurowalny interwał; +- informacja o ostatnim zapisie; +- odzyskiwanie po utracie połączenia; +- odzyskiwanie po zamknięciu karty; +- ostrzeżenie przed utratą niezapisanego tekstu; +- draft recovery po błędzie SMTP; +- zapis do wybranego folderu Drafts. + +## 4.5. Załączniki + +- wybór pliku; +- drag & drop; +- wklejanie ze schowka; +- pasek postępu uploadu; +- możliwość usunięcia przed wysłaniem; +- limity rozmiaru; +- kontrola MIME; +- bezpieczne nazwy plików; +- obsługa dużych plików bez niepotrzebnego trzymania całości w pamięci; +- tymczasowy magazyn izolowany per sesja; +- automatyczne sprzątanie plików tymczasowych; +- pobranie wszystkich załączników jako ZIP. + +## 4.6. Obrazy inline + +Proces: + +```text +Insert image +→ upload +→ walidacja MIME +→ zapis tymczasowy +→ CID attachment +→ +``` + +Należy również obsłużyć: + +- wklejenie obrazu ze schowka; +- przeciągnięcie obrazu do composera; +- usuwanie nieużywanego CID attachmentu; +- skalowanie po stronie UI bez destrukcji oryginalnego pliku. + +## 4.7. Attachment reminder + +Jeżeli treść sugeruje załącznik, np. „w załączniku”, „załączam”, a wiadomość nie ma załącznika, RustBox powinien ostrzec przed wysłaniem. + +--- + +# 5. Etap D — tożsamości i stopki + +## 5.1. Tożsamości + +Użytkownik może posiadać wiele identities: + +```text +Settings +└── Identities + ├── Personal + ├── Work + └── support@example.com +``` + +Każda identity zawiera: + +- Display name; +- Email; +- Reply-To; +- Bcc; +- Organization; +- podpis plain text; +- podpis HTML; +- ustawienie domyślnej identity. + +## 5.2. Automatyczny wybór identity + +Przy odpowiedzi RustBox powinien dobierać identity na podstawie: + +- To; +- Cc; +- Delivered-To; +- Envelope-To; +- aliases użytkownika; +- innych dostępnych nagłówków odbiorcy. + +## 5.3. Stopki / signatures + +Opcje: + +- plain text; +- HTML; +- automatyczne wstawianie; +- tylko nowa wiadomość; +- odpowiedź; +- forward; +- pozycja nad cytatem; +- pozycja pod cytatem; +- osobna stopka dla każdej identity; +- możliwość wyłączenia stopki per wiadomość. + +--- + +# 6. Etap E — ManageSieve, szybkie filtry i autoresponder + +## 6.1. Menedżer filtrów + +Widok: + +```text +Filters +├── Spam +├── GitLab +├── Mailing lists +└── Invoices +``` + +Funkcje: + +- lista skryptów; +- aktywny skrypt; +- tworzenie; +- edycja; +- usuwanie; +- zmiana nazwy; +- aktywowanie; +- dezaktywowanie; +- walidacja; +- raw editor; +- wizualny rule builder; +- podgląd wynikowego Sieve; +- wykrywanie capabilities serwera; +- ostrzeganie o niedostępnych rozszerzeniach. + +## 6.2. Warunki filtrów + +- From; +- To; +- Cc; +- Recipient; +- Subject; +- dowolny header; +- Body, jeśli capability pozwala; +- size; +- date; +- List-ID; +- address; +- domain; +- exists; +- spam headers; +- kombinacje AND / OR; +- negacja; +- kolejność reguł; +- stop processing. + +## 6.3. Akcje filtrów + +- Move; +- Copy; +- Discard; +- Reject; +- Redirect; +- Keep; +- Mark read; +- Add flag; +- Remove flag; +- Vacation; +- Stop. + +## 6.4. Create filter from message + +Prawoklik wiadomości: + +```text +Create filter → + From this sender + From this domain + To this address + Subject contains… + Mailing list +``` + +Przykład: + +```text +IF From = invoices@example.com +THEN Move to Invoices +``` + +Formularz musi być automatycznie uzupełniony na podstawie wybranej wiadomości. + +## 6.5. Autoresponder / Vacation + +Pola: + +- enabled; +- from; +- until; +- subject; +- message; +- days between replies; +- zakres dat; +- nie odpowiadaj mailing listom; +- nie odpowiadaj automatom; +- dodatkowe warunki dostępności. + +W trybie Advanced użytkownik powinien móc zobaczyć wygenerowany skrypt Sieve. + +--- + +# 7. Etap F — kontakty i książka adresowa + +## 7.1. Funkcje podstawowe + +- lista kontaktów; +- grupy; +- wyszukiwanie; +- autocomplete; +- import vCard; +- export vCard; +- zdjęcia kontaktów; +- duplicate detection; +- dodanie nadawcy z wiadomości; +- edycja; +- usuwanie; +- wiele adresów e-mail; +- wiele numerów telefonu. + +## 7.2. Dane kontaktu + +- Name; +- Surname; +- Display name; +- Email(s); +- Phone(s); +- Organization; +- Job title; +- Address; +- Website; +- Notes; +- Birthday; +- Photo. + +## 7.3. Upload zdjęcia kontaktu + +- upload pliku; +- walidacja MIME; +- limit rozmiaru; +- bezpieczne dekodowanie; +- miniatura; +- usunięcie zdjęcia; +- zachowanie oryginalnych proporcji. + +## 7.4. Akcje na adresie + +Po kliknięciu adresu: + +```text +Compose +Add to contacts +Copy address +Search messages +Create filter +``` + +## 7.5. Źródła danych + +Docelowo: + +- lokalna książka adresowa; +- CardDAV; +- LDAP read-only; +- LDAP read-write, jeżeli konfiguracja na to pozwala. + +--- + +# 8. Etap G — wyszukiwanie + +## 8.1. Szybkie wyszukiwanie + +Globalne pole: + +```text +Search mail… +``` + +## 8.2. Wyszukiwanie zaawansowane + +- From; +- To; +- Cc; +- Subject; +- Body; +- Date from/to; +- Folder; +- Has attachment; +- Unread; +- Flagged; +- Size. + +## 8.3. Składnia wyszukiwania + +Przykłady: + +```text +from:john@example.com +subject:invoice +has:attachment +is:unread +folder:Archive +after:2026-01-01 +``` + +## 8.4. Zapisane wyszukiwania + +- zapis kryteriów; +- nazwa użytkownika; +- szybkie uruchomienie; +- usuwanie; +- edycja. + +## 8.5. Lokalny indeks + +Tantivy powinien indeksować przyrostowo: + +- nadawcę; +- odbiorców; +- temat; +- treść; +- datę; +- folder; +- informacje o załącznikach; +- Message-ID; +- wybrane nagłówki. + +Indeks musi pozostawać spójny z IMAP po: + +- restartach; +- reconnect; +- przenoszeniu; +- usuwaniu; +- zmianie flag; +- UIDVALIDITY; +- zmianie schematu indeksu. + +--- + +# 9. Etap H — OpenPGP / PGP + +RustBox nie implementuje własnych algorytmów kryptograficznych. Należy użyć sprawdzonej biblioteki. + +## 9.1. Zarządzanie kluczami + +```text +Settings +└── Encryption + └── OpenPGP +``` + +Funkcje: + +- Generate key; +- Import private key; +- Import public key; +- Export public key; +- Export private key, jeśli polityka na to pozwala; +- Delete key; +- Change passphrase; +- fingerprint; +- przypisanie klucza do identity. + +## 9.2. Wysyłanie + +- Sign message; +- Encrypt message; +- Sign + encrypt; +- wybór klucza odbiorcy; +- ostrzeżenie o braku klucza; +- możliwość dołączenia public key. + +## 9.3. Odbieranie + +- Verify signature; +- Decrypt message; +- pokazanie tożsamości podpisującego; +- pokazanie fingerprintu; +- status podpisu; +- status szyfrowania; +- czytelne ostrzeżenie o niepoprawnym podpisie. + +## 9.4. Discovery + +Docelowo: + +- WKD; +- HKP; +- lookup po odbiorcy. + +## 9.5. Ochrona kluczy + +- prywatne klucze nie mogą trafić do logów; +- nie mogą trafić do indeksu wyszukiwania; +- muszą być chronione oddzielnie od sesji przeglądarki; +- hasła kluczy nie mogą być trwałe w plaintext; +- operacje kryptograficzne muszą mieć osobny model zagrożeń. + +--- + +# 10. Etap I — zaawansowane możliwości IMAP + +RustBox powinien działać capability-driven. + +Obsługiwane capability i zachowanie: + +| Capability | RustBox | +|---|---| +| IDLE | natychmiastowe nowe wiadomości | +| MOVE | natywne przenoszenie | +| UIDPLUS | poprawne UID | +| CONDSTORE | wydajna kontrola zmian | +| QRESYNC | szybka resynchronizacja | +| SORT | sortowanie serwerowe | +| THREAD | wątki | +| NAMESPACE | namespaces | +| SPECIAL-USE | Sent/Junk/Trash/etc. | +| QUOTA | quota użytkownika | +| ACL | prawa folderów | +| LIST-EXTENDED | rozszerzona lista folderów | +| UTF8 | międzynarodowe nazwy | +| custom keywords | tagi / dodatkowe flagi | + +Jeśli capability nie istnieje, UI nie może udawać działania funkcji. + +--- + +# 11. Etap J — shared folders i ACL + +Ustawienia folderu: + +```text +Permissions +``` + +Przykładowe prawa: + +```text +user@example.com + lookup + read + seen + write + insert + post + create + delete + administer +``` + +Funkcje: + +- odczyt ACL; +- modyfikacja ACL; +- usuwanie wpisów; +- inherited/default rights, jeśli serwer je wspiera; +- shared folders; +- global folders; +- czytelne rozróżnienie folderów własnych i współdzielonych. + +--- + +# 12. Etap K — funkcjonalne odpowiedniki pluginów Roundcube + +RustBox nie kopiuje PHP plugin API. Funkcje mają być natywnymi modułami jednej aplikacji. + +| Funkcja z ekosystemu Roundcube | Odpowiednik w RustBox | +|---|---| +| ACL | ACL folderów | +| Archive | archiwizacja | +| Attachment reminder | wykrycie braku deklarowanego załącznika | +| Enigma | OpenPGP | +| Hide blockquote | zwijanie cytatów | +| Identity select | automatyczna tożsamość | +| ManageSieve | filtry Sieve | +| Mark as junk | Spam / Not spam | +| New mail notifier | powiadomienia + dźwięk | +| Password | zmiana hasła przez konfigurowalny backend | +| Show additional headers | rozszerzone nagłówki | +| Subscriptions option | subskrypcje folderów | +| Userinfo | informacje o koncie | +| vCard attachments | import kontaktu z załącznika | +| ZIP download | wszystkie załączniki jako ZIP | +| Additional message headers | konfigurowalne nagłówki wychodzące | +| Autologout | timeout sesji | +| Reconnect | automatyczne odzyskiwanie sesji IMAP | +| New user identity | inicjalizacja identity | +| New user dialog | onboarding | +| Virtuser | mapowanie login → adres | +| Debug logger | diagnostyka | + +Funkcje techniczne typu osobnych storage pluginów mają być implementowane jako wewnętrzne moduły RustBox, a nie jako osobne pluginy runtime. + +--- + +# 13. Etap L — Spam / Junk + +Prawoklik: + +```text +Mark as spam +Mark as not spam +``` + +Backend musi mieć warstwę adaptera umożliwiającą administratorowi wybór mechanizmu: + +- IMAP move; +- Sieve; +- rspamd HTTP; +- SpamAssassin command; +- custom command. + +UI nie powinno zależeć od konkretnego backendu antyspamowego. + +--- + +# 14. Etap M — powiadomienia + +- Browser desktop notifications; +- dźwięk; +- unread badge; +- new mail indicator; +- ustawienia per folder; +- Do Not Disturb; +- możliwość całkowitego wyłączenia; +- IMAP IDLE → backend → WebSocket → browser. + +--- + +# 15. Etap N — kalendarz i groupware + +Całość nadal należy do `0.0.1`, ale implementacja następuje po stabilizacji poczty. + +## 15.1. Kalendarz + +Widoki: + +```text +Calendar +├── Month +├── Week +├── Day +└── Agenda +``` + +Funkcje: + +- tworzenie wydarzenia; +- edycja; +- usuwanie; +- iCalendar; +- import; +- export; +- zaproszenia; +- accept; +- decline; +- tentative; +- recurrence; +- reminders; +- timezone; +- shared calendars; +- CalDAV. + +## 15.2. CardDAV + +- synchronizacja kontaktów; +- obsługa wielu książek; +- konflikty edycji; +- retry; +- usuwanie; +- status synchronizacji. + +--- + +# 16. Etap O — ustawienia użytkownika + +Docelowa struktura: + +```text +Settings + +General +Appearance +Mailbox +Message view +Composing +Identities +Signatures +Folders +Filters +Autoresponder +Contacts +Calendar +Encryption +Notifications +Privacy +Security +Sessions +Account +Diagnostics +About +``` + +## 16.1. Appearance + +- Light / Dark / System; +- density; +- font size; +- message list row height; +- preview: right / bottom / disabled; +- folder pane width; +- message list width; +- show avatars; +- show attachment icons; +- show flags; +- date format; +- time format; +- locale; +- UI scale; +- zapamiętanie preferencji użytkownika. + +## 16.2. Mailbox + +- messages per page; +- threaded / flat; +- default sort; +- mark read after N seconds; +- auto-expand threads; +- delete behavior; +- archive behavior; +- trash behavior; +- refresh / IDLE; +- domyślne foldery specjalne. + +## 16.3. Message view + +- HTML / Plain preference; +- remote images: never / contacts / ask / always; +- show full headers; +- collapse quotes; +- default charset fallback; +- print behavior; +- opening attachments. + +## 16.4. Composing + +- HTML / Text / Markdown; +- autosave interval; +- default font; +- reply above/below quote; +- quote style; +- forward inline/as attachment; +- attachment reminder; +- spellcheck; +- default identity; +- signature behavior; +- default priority; +- read receipt behavior. + +--- + +# 17. Etap P — funkcje codzienne, które łatwo przeoczyć + +RustBox `0.0.1` musi również objąć: + +- draft recovery; +- Undo po usunięciu/przeniesieniu; +- multi-select; +- select all; +- select all search results; +- keyboard shortcuts; +- pełną obsługę klawiaturą; +- print; +- `.eml` upload; +- `.eml` download; +- RFC822 attachments; +- `message/rfc822`; +- TNEF / `winmail.dat`, jeśli technicznie wykonalne przy zachowaniu bezpieczeństwa; +- DSN; +- MDN / read receipts; +- SMTPUTF8; +- IDN; +- Unicode headers; +- Unicode filenames; +- poprawne RFC2047; +- mailing-list headers; +- List-Unsubscribe; +- ZIP załączników; +- quota warnings; +- spam indicators; +- message priority; +- custom flags / labels; +- autocomplete kontaktów; +- vCard attachments; +- iCal attachments; +- automatyczny reconnect sesji IMAP; +- zachowanie filtrów i scrolla; +- poprzednia/następna wiadomość; +- obsługa dużych wiadomości; +- obsługa dużych attachmentów; +- retry po błędach sieci; +- czytelny offline/error state. + +--- + +# 18. Etap Q — bezpieczeństwo + +Bezpieczeństwo jest realizowane równolegle z każdą funkcją i nie może zostać odłożone na koniec. + +Wymagane obszary: + +- HTML sanitizer; +- CSS sanitizer; +- SVG sanitizer; +- remote images; +- tracking pixels; +- `data:` URI; +- CID; +- MIME nesting; +- malformed headers; +- CRLF/header injection; +- attachment filename traversal; +- archiwa; +- zip bombs; +- huge MIME messages; +- SSRF; +- cross-account authorization; +- CSRF; +- XSS; +- session fixation; +- session hijacking; +- secure cookies; +- SameSite; +- TLS verification; +- host name verification; +- rate limiting logowania; +- bezpieczne przechowywanie tokenów; +- brak haseł w logach; +- brak tokenów w logach; +- brak prywatnych kluczy PGP w logach; +- brak treści wiadomości w logach poza jawnie włączonym trybem diagnostycznym; +- separacja danych kont; +- ochrona endpointów administracyjnych; +- limity uploadu; +- walidacja MIME; +- bezpieczne nazwy plików; +- izolacja temporary storage; +- czyszczenie danych po wylogowaniu / wygaśnięciu sesji. + +Każdy moduł powinien posiadać testy negatywne i regresyjne. + +--- + +# 19. Etap R — sesje i konto + +## 19.1. Sesje + +- lista aktywnych sesji; +- data utworzenia; +- ostatnia aktywność; +- urządzenie/przeglądarka w bezpiecznej formie; +- wyloguj tę sesję; +- wyloguj wszystkie pozostałe; +- autologout; +- remember me zgodnie z polityką bezpieczeństwa; +- automatyczne odświeżanie po reconnect. + +## 19.2. Zmiana hasła + +RustBox nie zakłada jednego mechanizmu backendowego. + +Administrator powinien móc skonfigurować adapter: + +- API; +- LDAP; +- PAM / command; +- SQL; +- custom executable; +- funkcja wyłączona. + +Hasło nie może być logowane ani zapisywane w plaintext. + +--- + +# 20. Etap S — diagnostyka + +Panel diagnostyczny użytkownika/administratora powinien pokazywać bez ujawniania danych poufnych: + +- IMAP status; +- SMTP status; +- ManageSieve status; +- CalDAV status; +- CardDAV status; +- IDLE status; +- ostatnie połączenie; +- ostatnia synchronizacja; +- liczba wiadomości w indeksie; +- kolejka zadań; +- status indeksowania; +- quota; +- ostatnie błędy; +- informacje o capabilities serwera; +- możliwość reconnect; +- możliwość rebuild index; +- health endpoint; +- readiness endpoint. + +--- + +# 21. Etap T — UI/UX docelowego RustBox + +## 21.1. Layout + +Desktop: + +```text +┌──────────────┬──────────────────────────┬───────────────────────────────┐ +│ Folder tree │ Message list │ Message preview │ +│ │ │ │ +│ Inbox │ Sender / Subject / Date │ Header │ +│ Sent │ Flags / Attachment │ Body │ +│ Drafts │ Threads │ Attachments │ +│ Archive │ │ │ +│ Spam │ │ │ +│ Trash │ │ │ +└──────────────┴──────────────────────────┴───────────────────────────────┘ +``` + +Dostępne ustawienia: + +- preview po prawej; +- preview na dole; +- preview wyłączony; +- zmiana szerokości paneli; +- compact / comfortable; +- mobile layout z osobnymi ekranami listy i wiadomości. + +## 21.2. Styl + +- brak gradientów; +- brak dekoracyjnych kolorowych kart; +- brak emoji jako ikon; +- popularne ikony SVG; +- małe i konsekwentne promienie; +- cienkie obramowania; +- spokojne kolory; +- jedna typografia UI; +- możliwość użycia `Inter` z fallbackiem systemowym bez zewnętrznego CDN; +- czytelne focus states; +- dostępność klawiaturą; +- odpowiedni kontrast; +- informacje nie mogą być przekazywane wyłącznie kolorem. + +--- + +# 22. Etap U — synchronizacja z Oxi + +RustBox pozostaje forkiem bazującym na Oxi i zachowuje możliwość śledzenia upstreamu. + +W repozytorium należy utrzymywać: + +- `UPSTREAM_BASE` z ostatnim w pełni przejrzanym i skonsumowanym commitem Oxi; +- remote `upstream-oxi`; +- skrypt setup upstream; +- skrypt status/diff; +- skrypt synchronizacji zmian backendowych; +- skrypt portowania pojedynczego commita; +- mechanizm finalizacji synchronizacji dopiero po przeglądzie zmian UI/root. + +Mapowanie: + +```text +Oxi backend/src/... → RustBox src/... +Oxi backend/Cargo.toml → RustBox Cargo.toml +Oxi backend/migrations/... → RustBox migrations/... +``` + +Frontend Oxi nie jest automatycznie importowany. + +Zmiany frontendowe Oxi powinny być raportowane jako potencjalne nowe funkcje i ręcznie portowane do interfejsu RustBox. + +Przy konfliktach Git nie wolno automatycznie nadpisywać lokalnych zmian RustBox. + +--- + +# 23. Etap V — testy akceptacyjne 0.0.1 + +## 23.1. Core mail + +- logowanie; +- wylogowanie; +- reconnect; +- folder tree; +- create folder; +- create subfolder; +- rename; +- delete; +- subscriptions; +- special folders; +- lista wiadomości; +- threading; +- podgląd; +- full headers; +- raw source; +- `.eml` download; +- reply; +- reply all; +- forward; +- forward as attachment; +- compose; +- drafts; +- attachments; +- inline images; +- signatures; +- identities; +- move; +- copy; +- delete; +- archive; +- spam; +- flags; +- unread; +- drag & drop; +- context menus. + +## 23.2. Sieve + +- list scripts; +- get; +- put; +- rename; +- delete; +- activate; +- deactivate; +- validation; +- visual builder; +- create filter from message; +- vacation; +- capability handling; +- TLS; +- STARTTLS; +- jawnie dozwolony tryb plaintext, jeśli administrator go włączy. + +## 23.3. Search + +- szybkie; +- zaawansowane; +- Tantivy; +- filtry; +- rebuild index; +- consistency po reconnect; +- UIDVALIDITY; +- delete/move/flags. + +## 23.4. Contacts / Calendar + +- CRUD kontaktów; +- zdjęcia; +- vCard; +- CardDAV; +- iCalendar; +- CalDAV; +- invitations; +- recurrence; +- timezone. + +## 23.5. PGP + +- generate/import/export; +- sign; +- verify; +- encrypt; +- decrypt; +- missing key; +- invalid signature; +- key protection. + +## 23.6. Security + +- XSS; +- CSRF; +- SSRF; +- malicious MIME; +- malformed headers; +- unsafe SVG; +- dangerous filenames; +- account isolation; +- session isolation; +- rate limiting; +- TLS verification; +- secrets not logged. + +## 23.7. Performance + +- skrzynka z dziesiątkami tysięcy wiadomości; +- duże wiadomości; +- duże attachmenty; +- długotrwałe IDLE; +- reconnect loop; +- indeksowanie; +- stabilność pamięci; +- graceful shutdown; +- restart i wznowienie synchronizacji. + +--- + +# 24. Kryterium zakończenia RustBox 0.0.1 + +`0.0.1` można uznać za gotową bazę dopiero wtedy, gdy: +0. WIelojezykowosc w stylu rustproxy +1. RustBox realizuje pełny podstawowy workflow webmaila bez ręcznego korzystania z innego klienta. +2. Foldery IMAP są w pełni zarządzalne z UI. +3. Wiadomości można czytać, filtrować, wyszukiwać, przenosić, kopiować, archiwizować i usuwać. +4. Composer obsługuje HTML, plain text, Markdown, attachmenty, obrazy inline, drafty, tożsamości i stopki. +5. Prawoklik i drag & drop działają jako pełnoprawne elementy UX. +6. Użytkownik ma dostęp do pełnych nagłówków i źródła RFC822. +7. ManageSieve posiada zarówno edytor tekstowy, jak i kreator wizualny oraz szybkie filtry z wiadomości. +8. Autoresponder działa przez Sieve. +9. Kontakty i autocomplete są kompletne. +10. OpenPGP pozwala podpisywać, szyfrować, weryfikować i odszyfrowywać pocztę. +11. IMAP capabilities są wykrywane i wykorzystywane bez udawania niewspieranych funkcji. +12. Shared folders, ACL i quota są obsługiwane tam, gdzie serwer je udostępnia. +13. Powiadomienia działają przez IDLE/WebSocket. +14. CardDAV/CalDAV i zaproszenia kalendarzowe są wdrożone. +15. Ustawienia użytkownika obejmują wygląd, zachowanie skrzynki, composer, identities, signatures, folders, filters, autoresponder, privacy, security i sessions. +16. UI jest spójne, bez gradientów i pseudo-ikon, ze standardowymi SVG. +17. Aplikacja nadal składa się z jednego procesu i jednej binarki produkcyjnej. +18. Runtime nie wymaga Node.js, Bun ani osobnego frontend servera. +19. Oxi pozostaje śledzonym upstreamem i można bezpiecznie portować jego nowe funkcje. +20. Testy funkcjonalne, bezpieczeństwa i stabilności przechodzą na rzeczywistych serwerach IMAP/SMTP/ManageSieve. + +Dopiero po spełnieniu tych warunków należy rozważyć oznaczenie kolejnej wersji jako pierwszej publicznej. diff --git a/docs/UI.md b/docs/UI.md new file mode 100644 index 0000000..29f5348 --- /dev/null +++ b/docs/UI.md @@ -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. diff --git a/docs/UPSTREAM.md b/docs/UPSTREAM.md new file mode 100644 index 0000000..01a6002 --- /dev/null +++ b/docs/UPSTREAM.md @@ -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 +``` + +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/` becomes ``; +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 +``` + +This creates `port/oxi-` 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. diff --git a/docs/VALIDATION.md b/docs/VALIDATION.md new file mode 100644 index 0000000..139483f --- /dev/null +++ b/docs/VALIDATION.md @@ -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 +``` diff --git a/docs/imap-pool.md b/docs/imap-pool.md new file mode 100644 index 0000000..24233cb --- /dev/null +++ b/docs/imap-pool.md @@ -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. diff --git a/docs/releases/v0.0.1.md b/docs/releases/v0.0.1.md new file mode 100644 index 0000000..2338e45 --- /dev/null +++ b/docs/releases/v0.0.1.md @@ -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/`. diff --git a/examples/test_email_theme.rs b/examples/test_email_theme.rs new file mode 100644 index 0000000..342b2e6 --- /dev/null +++ b/examples/test_email_theme.rs @@ -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()) +} diff --git a/make_zip.py b/make_zip.py new file mode 100644 index 0000000..a8a050b --- /dev/null +++ b/make_zip.py @@ -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() \ No newline at end of file diff --git a/migrations/V001__initial_schema.sql b/migrations/V001__initial_schema.sql new file mode 100644 index 0000000..203c607 --- /dev/null +++ b/migrations/V001__initial_schema.sql @@ -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'); diff --git a/migrations/V002__folders_and_messages.sql b/migrations/V002__folders_and_messages.sql new file mode 100644 index 0000000..88c3015 --- /dev/null +++ b/migrations/V002__folders_and_messages.sql @@ -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); diff --git a/migrations/V003__add_date_epoch.sql b/migrations/V003__add_date_epoch.sql new file mode 100644 index 0000000..5a2fc56 --- /dev/null +++ b/migrations/V003__add_date_epoch.sql @@ -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); diff --git a/migrations/V004__folder_messages_updated_at.sql b/migrations/V004__folder_messages_updated_at.sql new file mode 100644 index 0000000..fff7c73 --- /dev/null +++ b/migrations/V004__folder_messages_updated_at.sql @@ -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; diff --git a/migrations/V005__drafts_and_attachments.sql b/migrations/V005__drafts_and_attachments.sql new file mode 100644 index 0000000..27ef7f1 --- /dev/null +++ b/migrations/V005__drafts_and_attachments.sql @@ -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 +); diff --git a/migrations/V006__cache_attachments_and_headers.sql b/migrations/V006__cache_attachments_and_headers.sql new file mode 100644 index 0000000..492f5b0 --- /dev/null +++ b/migrations/V006__cache_attachments_and_headers.sql @@ -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; diff --git a/migrations/V007__contacts.sql b/migrations/V007__contacts.sql new file mode 100644 index 0000000..c37821c --- /dev/null +++ b/migrations/V007__contacts.sql @@ -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); diff --git a/migrations/V008__identities.sql b/migrations/V008__identities.sql new file mode 100644 index 0000000..f144d2e --- /dev/null +++ b/migrations/V008__identities.sql @@ -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')) +); diff --git a/migrations/V009__notification_preferences.sql b/migrations/V009__notification_preferences.sql new file mode 100644 index 0000000..fb76df8 --- /dev/null +++ b/migrations/V009__notification_preferences.sql @@ -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); diff --git a/migrations/V010__contact_groups.sql b/migrations/V010__contact_groups.sql new file mode 100644 index 0000000..13ef6d3 --- /dev/null +++ b/migrations/V010__contact_groups.sql @@ -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); diff --git a/migrations/V011__message_reaction.sql b/migrations/V011__message_reaction.sql new file mode 100644 index 0000000..e090827 --- /dev/null +++ b/migrations/V011__message_reaction.sql @@ -0,0 +1 @@ +ALTER TABLE messages ADD COLUMN reaction TEXT; diff --git a/migrations/V012__tags.sql b/migrations/V012__tags.sql new file mode 100644 index 0000000..fcdaaa4 --- /dev/null +++ b/migrations/V012__tags.sql @@ -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); diff --git a/migrations/V013__display_preferences.sql b/migrations/V013__display_preferences.sql new file mode 100644 index 0000000..1219e63 --- /dev/null +++ b/migrations/V013__display_preferences.sql @@ -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')) +); diff --git a/migrations/V014__compose_format.sql b/migrations/V014__compose_format.sql new file mode 100644 index 0000000..96d09c9 --- /dev/null +++ b/migrations/V014__compose_format.sql @@ -0,0 +1 @@ +ALTER TABLE display_preferences ADD COLUMN compose_format TEXT NOT NULL DEFAULT 'html'; diff --git a/migrations/V015__deep_index.sql b/migrations/V015__deep_index.sql new file mode 100644 index 0000000..61e85b9 --- /dev/null +++ b/migrations/V015__deep_index.sql @@ -0,0 +1 @@ +ALTER TABLE display_preferences ADD COLUMN deep_index BOOLEAN NOT NULL DEFAULT 0; diff --git a/migrations/V016__calendar.sql b/migrations/V016__calendar.sql new file mode 100644 index 0000000..230788d --- /dev/null +++ b/migrations/V016__calendar.sql @@ -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')) +); diff --git a/migrations/V017__add_email_theme.sql b/migrations/V017__add_email_theme.sql new file mode 100644 index 0000000..d0f1a23 --- /dev/null +++ b/migrations/V017__add_email_theme.sql @@ -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; diff --git a/migrations/V018__animation_mode.sql b/migrations/V018__animation_mode.sql new file mode 100644 index 0000000..6fcee36 --- /dev/null +++ b/migrations/V018__animation_mode.sql @@ -0,0 +1,2 @@ +ALTER TABLE display_preferences +ADD COLUMN animation_mode TEXT NULL; diff --git a/migrations/V019__known_addresses.sql b/migrations/V019__known_addresses.sql new file mode 100644 index 0000000..7b30a1b --- /dev/null +++ b/migrations/V019__known_addresses.sql @@ -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 != ''; diff --git a/migrations/V020__appearance_personalization.sql b/migrations/V020__appearance_personalization.sql new file mode 100644 index 0000000..e230bf6 --- /dev/null +++ b/migrations/V020__appearance_personalization.sql @@ -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'; diff --git a/migrations/V021__special_folders.sql b/migrations/V021__special_folders.sql new file mode 100644 index 0000000..77941d2 --- /dev/null +++ b/migrations/V021__special_folders.sql @@ -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')) +); diff --git a/migrations/V022__identity_options.sql b/migrations/V022__identity_options.sql new file mode 100644 index 0000000..2b574a9 --- /dev/null +++ b/migrations/V022__identity_options.sql @@ -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'; diff --git a/migrations/V023__saved_searches.sql b/migrations/V023__saved_searches.sql new file mode 100644 index 0000000..c1d28ed --- /dev/null +++ b/migrations/V023__saved_searches.sql @@ -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); diff --git a/migrations/V024__notification_dnd.sql b/migrations/V024__notification_dnd.sql new file mode 100644 index 0000000..ee34f16 --- /dev/null +++ b/migrations/V024__notification_dnd.sql @@ -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'; diff --git a/migrations/V025__custom_theme_colors.sql b/migrations/V025__custom_theme_colors.sql new file mode 100644 index 0000000..f85c2a6 --- /dev/null +++ b/migrations/V025__custom_theme_colors.sql @@ -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'; diff --git a/migrations/V026__mail_preferences.sql b/migrations/V026__mail_preferences.sql new file mode 100644 index 0000000..048614c --- /dev/null +++ b/migrations/V026__mail_preferences.sql @@ -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); diff --git a/migrations/V027__contact_profile.sql b/migrations/V027__contact_profile.sql new file mode 100644 index 0000000..96169b7 --- /dev/null +++ b/migrations/V027__contact_profile.sql @@ -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 +); diff --git a/migrations/V028__contact_multi_values.sql b/migrations/V028__contact_multi_values.sql new file mode 100644 index 0000000..2725b0c --- /dev/null +++ b/migrations/V028__contact_multi_values.sql @@ -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 '[]'; diff --git a/migrations/V029__draft_send_options.sql b/migrations/V029__draft_send_options.sql new file mode 100644 index 0000000..0b9b5ec --- /dev/null +++ b/migrations/V029__draft_send_options.sql @@ -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'; diff --git a/migrations/V030__folder_list_preferences.sql b/migrations/V030__folder_list_preferences.sql new file mode 100644 index 0000000..2d8c3d8 --- /dev/null +++ b/migrations/V030__folder_list_preferences.sql @@ -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')) +); diff --git a/migrations/V031__compose_preferences.sql b/migrations/V031__compose_preferences.sql new file mode 100644 index 0000000..cc41b71 --- /dev/null +++ b/migrations/V031__compose_preferences.sql @@ -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; diff --git a/migrations/V032__advanced_appearance.sql b/migrations/V032__advanced_appearance.sql new file mode 100644 index 0000000..5620d25 --- /dev/null +++ b/migrations/V032__advanced_appearance.sql @@ -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 ''; diff --git a/migrations/V033__draft_compose_format.sql b/migrations/V033__draft_compose_format.sql new file mode 100644 index 0000000..828b212 --- /dev/null +++ b/migrations/V033__draft_compose_format.sql @@ -0,0 +1 @@ +ALTER TABLE drafts ADD COLUMN compose_format TEXT NOT NULL DEFAULT 'html'; diff --git a/migrations/V034__imap_keyword_sync.sql b/migrations/V034__imap_keyword_sync.sql new file mode 100644 index 0000000..be4cffd --- /dev/null +++ b/migrations/V034__imap_keyword_sync.sql @@ -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 <> ''; diff --git a/migrations/V035__compose_behavior_preferences.sql b/migrations/V035__compose_behavior_preferences.sql new file mode 100644 index 0000000..6d93444 --- /dev/null +++ b/migrations/V035__compose_behavior_preferences.sql @@ -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; diff --git a/migrations/V036__remote_draft_tracking.sql b/migrations/V036__remote_draft_tracking.sql new file mode 100644 index 0000000..158d7c1 --- /dev/null +++ b/migrations/V036__remote_draft_tracking.sql @@ -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; diff --git a/migrations/V037__openpgp_keyring.sql b/migrations/V037__openpgp_keyring.sql new file mode 100644 index 0000000..a06108a --- /dev/null +++ b/migrations/V037__openpgp_keyring.sql @@ -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); diff --git a/migrations/V038__identity_openpgp.sql b/migrations/V038__identity_openpgp.sql new file mode 100644 index 0000000..6ed1383 --- /dev/null +++ b/migrations/V038__identity_openpgp.sql @@ -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; diff --git a/migrations/V039__draft_openpgp_options.sql b/migrations/V039__draft_openpgp_options.sql new file mode 100644 index 0000000..8946f48 --- /dev/null +++ b/migrations/V039__draft_openpgp_options.sql @@ -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; diff --git a/migrations/V040__draft_openpgp_attach_key.sql b/migrations/V040__draft_openpgp_attach_key.sql new file mode 100644 index 0000000..4430ecd --- /dev/null +++ b/migrations/V040__draft_openpgp_attach_key.sql @@ -0,0 +1 @@ +ALTER TABLE drafts ADD COLUMN pgp_attach_public_key INTEGER NOT NULL DEFAULT 0; diff --git a/migrations/V041__draft_openpgp_recipient_keys.sql b/migrations/V041__draft_openpgp_recipient_keys.sql new file mode 100644 index 0000000..2966984 --- /dev/null +++ b/migrations/V041__draft_openpgp_recipient_keys.sql @@ -0,0 +1 @@ +ALTER TABLE drafts ADD COLUMN pgp_attach_recipient_keys INTEGER NOT NULL DEFAULT 0; diff --git a/packaging/systemd/rustbox.service b/packaging/systemd/rustbox.service new file mode 100644 index 0000000..388b2a1 --- /dev/null +++ b/packaging/systemd/rustbox.service @@ -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 diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..292fe49 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,2 @@ +[toolchain] +channel = "stable" diff --git a/scripts/build-release.sh b/scripts/build-release.sh new file mode 100755 index 0000000..01c2ba0 --- /dev/null +++ b/scripts/build-release.sh @@ -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" diff --git a/scripts/finish-upstream.sh b/scripts/finish-upstream.sh new file mode 100755 index 0000000..c235861 --- /dev/null +++ b/scripts/finish-upstream.sh @@ -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' diff --git a/scripts/install-systemd.sh b/scripts/install-systemd.sh new file mode 100755 index 0000000..cccb6c8 --- /dev/null +++ b/scripts/install-systemd.sh @@ -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" diff --git a/scripts/oxi-port-commit.sh b/scripts/oxi-port-commit.sh new file mode 100755 index 0000000..41f93c4 --- /dev/null +++ b/scripts/oxi-port-commit.sh @@ -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 " >&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" diff --git a/scripts/oxi-status.sh b/scripts/oxi-status.sh new file mode 100755 index 0000000..bf4fdbb --- /dev/null +++ b/scripts/oxi-status.sh @@ -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 diff --git a/scripts/setup-upstream.sh b/scripts/setup-upstream.sh new file mode 100755 index 0000000..900f07d --- /dev/null +++ b/scripts/setup-upstream.sh @@ -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)" diff --git a/scripts/sync-upstream.sh b/scripts/sync-upstream.sh new file mode 100755 index 0000000..94015f7 --- /dev/null +++ b/scripts/sync-upstream.sh @@ -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/ to . File contents are not rewritten. +git diff --binary --find-renames "$BASE..$TARGET_SHA" -- backend/ > "$tmp_raw" +sed \ + -e 's#^diff --git a/backend/#diff --git a/#' \ + -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' diff --git a/src/AGENTS.md b/src/AGENTS.md new file mode 100644 index 0000000..11f096f --- /dev/null +++ b/src/AGENTS.md @@ -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` 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. diff --git a/src/auth/csrf.rs b/src/auth/csrf.rs new file mode 100644 index 0000000..d799fe9 --- /dev/null +++ b/src/auth/csrf.rs @@ -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); + } +} diff --git a/src/auth/imap_auth.rs b/src/auth/imap_auth.rs new file mode 100644 index 0000000..94e0620 --- /dev/null +++ b/src/auth/imap_auth.rs @@ -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(client: async_imap::Client, email: &str, password: &str) -> AuthResult +where + T: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + std::fmt::Debug + Send, +{ + match client.login(email, password).await { + Ok(mut session) => { + // Successfully authenticated; clean up. + let _ = session.logout().await; + AuthResult::Success + } + 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:?}", + ); + } +} diff --git a/src/auth/middleware.rs b/src/auth/middleware.rs new file mode 100644 index 0000000..70a0d83 --- /dev/null +++ b/src/auth/middleware.rs @@ -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 { + 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 { + let cookie_name = format!("rustbox_session_{}", account_id); + extract_cookie_value(req, &cookie_name) +} + +fn extract_active_account_header(req: &Request) -> Option { + 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 { + 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::>() { + 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) -> Router { + let handler = |Extension(session): Extension| 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, + ) -> (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"); + } +} diff --git a/src/auth/mod.rs b/src/auth/mod.rs new file mode 100644 index 0000000..83a6ac9 --- /dev/null +++ b/src/auth/mod.rs @@ -0,0 +1,5 @@ +pub mod csrf; +pub mod imap_auth; +pub mod middleware; +pub mod session; +pub mod user_data; diff --git a/src/auth/session.rs b/src/auth/session.rs new file mode 100644 index 0000000..dc71637 --- /dev/null +++ b/src/auth/session.rs @@ -0,0 +1,1331 @@ +use std::sync::Arc; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine; +use dashmap::DashMap; +use rand::RngCore; + +pub type SessionId = String; +pub type BrowserId = String; +pub type AccountId = String; + +pub type SessionState = AccountSession; + +#[derive(Debug, Clone)] +pub struct AccountSession { + pub account_id: AccountId, + pub email: String, + #[allow(dead_code)] + pub password: String, + #[allow(dead_code)] + pub user_hash: String, + pub imap_host: String, + pub imap_port: u16, + pub imap_tls: bool, + pub smtp_host: String, + #[allow(dead_code)] + pub smtp_port: u16, + #[allow(dead_code)] + pub smtp_tls: bool, + pub last_accessed: Instant, + pub timeout_override: Option, + pub created_at_unix: u64, + pub last_accessed_unix: u64, + pub user_agent: String, +} + +/// Thread-safe, in-memory session store backed by `DashMap`. +/// +/// Shared across all Axum handlers via `Arc`. Sessions expire after +/// `timeout` of inactivity (sliding window). +/// +/// Browser binding allows multiple accounts to be associated with a single +/// browser session, enabling account switching without re-authentication. +#[derive(Debug, Clone)] +pub struct SessionStore { + sessions: Arc>, + browsers: Arc>>, + account_to_session: Arc>, + account_to_browser: Arc>, + timeout: Duration, +} + +impl SessionStore { + fn unix_now() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + } + + pub fn new(timeout: Duration) -> Self { + Self { + sessions: Arc::new(DashMap::new()), + browsers: Arc::new(DashMap::new()), + account_to_session: Arc::new(DashMap::new()), + account_to_browser: Arc::new(DashMap::new()), + timeout, + } + } + + pub fn generate_token() -> SessionId { + let mut bytes = [0u8; 32]; + rand::rng().fill_bytes(&mut bytes); + URL_SAFE_NO_PAD.encode(bytes) + } + + fn generate_id() -> String { + let mut bytes = [0u8; 16]; + rand::rng().fill_bytes(&mut bytes); + URL_SAFE_NO_PAD.encode(bytes) + } + + pub fn create_browser(&self) -> BrowserId { + let browser_id = Self::generate_id(); + self.browsers.insert(browser_id.clone(), Vec::new()); + browser_id + } + + #[allow(clippy::too_many_arguments)] + pub fn add_account_to_browser( + &self, + browser_id: &str, + email: String, + password: String, + user_hash: String, + imap_host: String, + imap_port: u16, + imap_tls: bool, + smtp_host: String, + smtp_port: u16, + smtp_tls: bool, + ) -> (SessionId, AccountId) { + self.add_account_to_browser_with_timeout( + browser_id, + email, + password, + user_hash, + imap_host, + imap_port, + imap_tls, + smtp_host, + smtp_port, + smtp_tls, + None, + ) + } + + #[allow(clippy::too_many_arguments)] + pub fn add_account_to_browser_with_timeout( + &self, + browser_id: &str, + email: String, + password: String, + user_hash: String, + imap_host: String, + imap_port: u16, + imap_tls: bool, + smtp_host: String, + smtp_port: u16, + smtp_tls: bool, + timeout_override: Option, + ) -> (SessionId, AccountId) { + let token = Self::generate_token(); + let account_id = Self::generate_id(); + + let now_unix = Self::unix_now(); + let session = AccountSession { + account_id: account_id.clone(), + email, + password, + user_hash, + imap_host, + imap_port, + imap_tls, + smtp_host, + smtp_port, + smtp_tls, + last_accessed: Instant::now(), + timeout_override, + created_at_unix: now_unix, + last_accessed_unix: now_unix, + user_agent: String::new(), + }; + + self.sessions.insert(token.clone(), session); + self.account_to_session + .insert(account_id.clone(), token.clone()); + self.account_to_browser + .insert(account_id.clone(), browser_id.to_string()); + self.browsers + .entry(browser_id.to_string()) + .or_default() + .push(account_id.clone()); + + (token, account_id) + } + + pub fn get_browser_accounts(&self, browser_id: &str) -> Vec { + let account_ids = self + .browsers + .get(browser_id) + .map(|entry| entry.clone()) + .unwrap_or_default(); + + account_ids + .into_iter() + .filter_map(|account_id| { + let session_id = self.account_to_session.get(&account_id)?; + self.sessions.get(session_id.value()).map(|s| s.clone()) + }) + .collect() + } + + pub fn browser_exists(&self, browser_id: &str) -> bool { + self.browsers.contains_key(browser_id) + } + + pub fn get_account_session( + &self, + browser_id: &str, + account_id: &str, + token: &str, + ) -> Option { + let accounts = self.browsers.get(browser_id)?; + if !accounts.contains(&account_id.to_string()) { + return None; + } + drop(accounts); + + let session_id = self.account_to_session.get(account_id)?; + if session_id.value() != token { + return None; + } + drop(session_id); + + self.get(token) + } + + pub fn remove_account(&self, account_id: &str) -> bool { + if let Some((removed_account_id, session_id)) = self.account_to_session.remove(account_id) { + // Remove from sessions; restore account_to_session on panic. + if let Err(payload) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + self.sessions.remove(&session_id); + })) { + self.account_to_session + .insert(removed_account_id, session_id); + std::panic::resume_unwind(payload); + } + + // Use reverse index for O(1) browser lookup instead of iterating. + if let Some((_, browser_id)) = self.account_to_browser.remove(account_id) + && let Some(mut entry) = self.browsers.get_mut(&browser_id) + && let Some(pos) = entry.value().iter().position(|id| id == account_id) + { + entry.value_mut().remove(pos); + } + true + } else { + false + } + } + + /// Store a display-only User-Agent string for active-session diagnostics. + /// It is never used for authentication or authorization decisions. + pub fn set_user_agent(&self, account_id: &str, user_agent: String) { + let Some(session_id) = self.account_to_session.get(account_id).map(|entry| entry.clone()) else { + return; + }; + if let Some(mut session) = self.sessions.get_mut(&session_id) { + session.user_agent = user_agent.chars().take(512).collect(); + } + } + + /// Return all currently live account sessions belonging to the same + /// RustBox user, including sessions created by other browsers. + pub fn get_user_sessions(&self, user_hash: &str) -> Vec { + let now = Instant::now(); + let mut result: Vec = self + .sessions + .iter() + .filter_map(|entry| { + let session = entry.value(); + let effective_timeout = session.timeout_override.unwrap_or(self.timeout); + if session.user_hash == user_hash + && now.duration_since(session.last_accessed) <= effective_timeout + { + Some(session.clone()) + } else { + None + } + }) + .collect(); + result.sort_by_key(|session| std::cmp::Reverse(session.last_accessed_unix)); + result + } + + /// Revoke one session only when it belongs to the requested user. + pub fn remove_user_session(&self, user_hash: &str, account_id: &str) -> bool { + let Some(session_id) = self.account_to_session.get(account_id).map(|entry| entry.clone()) else { + return false; + }; + let belongs_to_user = self + .sessions + .get(&session_id) + .is_some_and(|session| session.user_hash == user_hash); + if !belongs_to_user { + return false; + } + self.remove_account(account_id) + } + + /// Revoke every session for a user except the current account session. + pub fn remove_other_user_sessions(&self, user_hash: &str, current_account_id: &str) -> usize { + let account_ids: Vec = self + .sessions + .iter() + .filter(|entry| { + entry.value().user_hash == user_hash + && entry.value().account_id != current_account_id + }) + .map(|entry| entry.value().account_id.clone()) + .collect(); + let mut removed = 0; + for account_id in account_ids { + if self.remove_user_session(user_hash, &account_id) { + removed += 1; + } + } + removed + } + + pub fn remove_browser(&self, browser_id: &str) { + if let Some((_, account_ids)) = self.browsers.remove(browser_id) { + // Clean up each account. Individual failures are tolerated -- + // the browser entry is already gone, so the accounts become + // unreachable via browser lookups. They will be reaped by + // purge_expired or a subsequent remove_account call. + for account_id in account_ids { + self.account_to_browser.remove(&account_id); + if let Some((_, session_id)) = self.account_to_session.remove(&account_id) { + self.sessions.remove(&session_id); + } + } + } + } + + #[allow(dead_code)] + pub fn insert( + &self, + email: String, + password: String, + user_hash: String, + timeout_override: Option, + ) -> SessionId { + let token = Self::generate_token(); + let account_id = Self::generate_id(); + let now_unix = Self::unix_now(); + let state = AccountSession { + account_id, + email, + password, + user_hash, + imap_host: String::new(), + imap_port: 993, + imap_tls: true, + smtp_host: String::new(), + smtp_port: 587, + smtp_tls: true, + last_accessed: Instant::now(), + timeout_override, + created_at_unix: now_unix, + last_accessed_unix: now_unix, + user_agent: String::new(), + }; + self.sessions.insert(token.clone(), state); + token + } + + pub fn get(&self, token: &str) -> Option { + let mut entry = self.sessions.get_mut(token)?; + let now = Instant::now(); + let effective_timeout = entry.timeout_override.unwrap_or(self.timeout); + if now.duration_since(entry.last_accessed) > effective_timeout { + let account_id = entry.account_id.clone(); + drop(entry); + self.sessions.remove(token); + self.evict_account(&account_id); + return None; + } + entry.last_accessed = now; + entry.last_accessed_unix = Self::unix_now(); + Some(entry.clone()) + } + + #[allow(dead_code)] + pub fn remove(&self, token: &str) -> bool { + self.sessions.remove(token).is_some() + } + + /// Remove an account's entries from `account_to_session` and `browsers`. + /// If a browser's account list becomes empty, the browser entry is removed + /// too -- keeping it would serve no purpose and would itself be a leak. + fn evict_account(&self, account_id: &str) { + self.account_to_session.remove(account_id); + self.account_to_browser.remove(account_id); + + // We cannot predict which browser owns this account, so scan all. + self.browsers.retain(|_, accounts| { + accounts.retain(|id| id != account_id); + // Keep the browser entry only if it still has accounts. + !accounts.is_empty() + }); + } + + #[allow(dead_code)] + pub fn purge_expired(&self) { + let now = Instant::now(); + + // Collect expired account IDs while retaining live sessions. + let mut expired_accounts = Vec::new(); + self.sessions.retain(|_, state| { + let effective_timeout = state.timeout_override.unwrap_or(self.timeout); + let alive = now.duration_since(state.last_accessed) <= effective_timeout; + if !alive { + expired_accounts.push(state.account_id.clone()); + } + alive + }); + + for account_id in &expired_accounts { + self.evict_account(account_id); + } + } + + /// Verify internal consistency across all four maps. Returns a list of + /// inconsistency descriptions. An empty vec means the store is consistent. + /// Intended for testing and diagnostics. + #[allow(dead_code)] + pub fn check_consistency(&self) -> Vec { + let mut issues = Vec::new(); + + // Every session's account_id should have a matching account_to_session entry. + for entry in self.sessions.iter() { + let token = entry.key(); + let account_id = &entry.value().account_id; + match self.account_to_session.get(account_id) { + Some(mapped_token) if mapped_token.value() == token => {} + Some(mapped_token) => { + issues.push(format!( + "account_to_session[{account_id}] points to {} but session token is {token}", + mapped_token.value() + )); + } + None => { + issues.push(format!( + "session {token} references account {account_id} with no account_to_session entry" + )); + } + } + } + + // Every account_to_session entry should point to an existing session. + for entry in self.account_to_session.iter() { + let account_id = entry.key(); + let session_id = entry.value(); + if !self.sessions.contains_key(session_id) { + issues.push(format!( + "account_to_session[{account_id}] references missing session {session_id}" + )); + } + } + + // Every account_id in a browser list should exist in account_to_session. + for entry in self.browsers.iter() { + let browser_id = entry.key(); + for account_id in entry.value().iter() { + if !self.account_to_session.contains_key(account_id) { + issues.push(format!( + "browser {browser_id} references account {account_id} missing from account_to_session" + )); + } + } + } + + // Every account_to_browser entry should reference a valid account and browser. + for entry in self.account_to_browser.iter() { + let account_id = entry.key(); + let browser_id = entry.value(); + if !self.account_to_session.contains_key(account_id) { + issues.push(format!( + "account_to_browser[{account_id}] references account missing from account_to_session" + )); + } + if !self.browsers.contains_key(browser_id) { + issues.push(format!( + "account_to_browser[{account_id}] references missing browser {browser_id}" + )); + } + } + + issues + } + + #[allow(dead_code)] + pub fn len(&self) -> usize { + self.sessions.len() + } + + #[allow(dead_code)] + pub fn is_empty(&self) -> bool { + self.sessions.is_empty() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashSet; + use std::thread; + + fn long_lived_store() -> SessionStore { + SessionStore::new(Duration::from_secs(3600)) + } + + fn short_lived_store() -> SessionStore { + SessionStore::new(Duration::from_millis(50)) + } + + fn create_test_session(email: &str, password: &str, user_hash: &str) -> AccountSession { + AccountSession { + account_id: SessionStore::generate_id(), + email: email.to_string(), + password: password.to_string(), + user_hash: user_hash.to_string(), + imap_host: "imap.test.com".to_string(), + imap_port: 993, + imap_tls: true, + smtp_host: "smtp.test.com".to_string(), + smtp_port: 587, + smtp_tls: true, + last_accessed: Instant::now(), + timeout_override: None, + created_at_unix: SessionStore::unix_now(), + last_accessed_unix: SessionStore::unix_now(), + user_agent: String::new(), + } + } + + #[test] + fn generate_token_produces_unique_values() { + let mut tokens = HashSet::new(); + for _ in 0..1000 { + let t = SessionStore::generate_token(); + assert!(tokens.insert(t), "duplicate token generated"); + } + } + + #[test] + fn generate_token_length_is_correct() { + let token = SessionStore::generate_token(); + assert_eq!(token.len(), 43); + } + + #[test] + fn generate_token_is_valid_base64url() { + let token = SessionStore::generate_token(); + let decoded = URL_SAFE_NO_PAD.decode(&token); + assert!(decoded.is_ok(), "token is not valid base64url"); + assert_eq!(decoded.unwrap().len(), 32); + } + + #[test] + fn generate_id_produces_22_chars() { + let id = SessionStore::generate_id(); + assert_eq!(id.len(), 22); + } + + #[test] + fn create_browser_returns_valid_id() { + let store = long_lived_store(); + let browser_id = store.create_browser(); + assert_eq!(browser_id.len(), 22); + assert!(store.browsers.contains_key(&browser_id)); + } + + #[test] + fn add_account_to_browser() { + let store = long_lived_store(); + let browser_id = store.create_browser(); + + let (token, account_id) = store.add_account_to_browser( + &browser_id, + "alice@example.com".into(), + "password".into(), + "hash123".into(), + "imap.example.com".into(), + 993, + true, + "smtp.example.com".into(), + 587, + true, + ); + + assert_eq!(token.len(), 43); + assert_eq!(account_id.len(), 22); + + let accounts = store.get_browser_accounts(&browser_id); + assert_eq!(accounts.len(), 1); + assert_eq!(accounts[0].email, "alice@example.com"); + assert_eq!(accounts[0].imap_host, "imap.example.com"); + + let session = store.get(&token).expect("session should exist"); + assert_eq!(session.email, "alice@example.com"); + } + + #[test] + fn get_account_session_validates_browser_binding() { + let store = long_lived_store(); + let browser_id = store.create_browser(); + + let (token, account_id) = store.add_account_to_browser( + &browser_id, + "user@example.com".into(), + "pass".into(), + "hash".into(), + "imap.example.com".into(), + 993, + true, + "smtp.example.com".into(), + 587, + true, + ); + + let session = store + .get_account_session(&browser_id, &account_id, &token) + .expect("should return session"); + assert_eq!(session.email, "user@example.com"); + } + + #[test] + fn get_account_session_rejects_wrong_browser() { + let store = long_lived_store(); + let browser1 = store.create_browser(); + let browser2 = store.create_browser(); + + let (token, account_id) = store.add_account_to_browser( + &browser1, + "user@example.com".into(), + "pass".into(), + "hash".into(), + "imap.example.com".into(), + 993, + true, + "smtp.example.com".into(), + 587, + true, + ); + + assert!( + store + .get_account_session(&browser2, &account_id, &token) + .is_none(), + "should reject wrong browser" + ); + } + + #[test] + fn get_account_session_rejects_wrong_token() { + let store = long_lived_store(); + let browser_id = store.create_browser(); + + let (_, account_id) = store.add_account_to_browser( + &browser_id, + "user@example.com".into(), + "pass".into(), + "hash".into(), + "imap.example.com".into(), + 993, + true, + "smtp.example.com".into(), + 587, + true, + ); + + let wrong_token = SessionStore::generate_token(); + assert!( + store + .get_account_session(&browser_id, &account_id, &wrong_token) + .is_none(), + "should reject wrong token" + ); + } + + #[test] + fn remove_account() { + let store = long_lived_store(); + let browser_id = store.create_browser(); + + let (token, account_id) = store.add_account_to_browser( + &browser_id, + "user@example.com".into(), + "pass".into(), + "hash".into(), + "imap.example.com".into(), + 993, + true, + "smtp.example.com".into(), + 587, + true, + ); + + assert!(store.remove_account(&account_id)); + assert!(store.get(&token).is_none()); + assert!(store.get_browser_accounts(&browser_id).is_empty()); + assert!(!store.remove_account(&account_id)); + } + + #[test] + fn remove_browser_clears_all_accounts() { + let store = long_lived_store(); + let browser_id = store.create_browser(); + + let (token1, account1) = store.add_account_to_browser( + &browser_id, + "user1@example.com".into(), + "pass".into(), + "hash1".into(), + "imap.example.com".into(), + 993, + true, + "smtp.example.com".into(), + 587, + true, + ); + let (token2, account2) = store.add_account_to_browser( + &browser_id, + "user2@example.com".into(), + "pass".into(), + "hash2".into(), + "imap.example.com".into(), + 993, + true, + "smtp.example.com".into(), + 587, + true, + ); + + assert_eq!(store.get_browser_accounts(&browser_id).len(), 2); + + store.remove_browser(&browser_id); + + assert!(store.get(&token1).is_none()); + assert!(store.get(&token2).is_none()); + assert!(store.get_browser_accounts(&browser_id).is_empty()); + assert!(!store.account_to_session.contains_key(&account1)); + assert!(!store.account_to_session.contains_key(&account2)); + } + + #[test] + fn expired_session_returns_none() { + let store = short_lived_store(); + let browser_id = store.create_browser(); + + let (token, account_id) = store.add_account_to_browser( + &browser_id, + "user@example.com".into(), + "pass".into(), + "hash".into(), + "imap.example.com".into(), + 993, + true, + "smtp.example.com".into(), + 587, + true, + ); + + thread::sleep(Duration::from_millis(100)); + + assert!( + store.get(&token).is_none(), + "expired session should not be returned" + ); + assert!( + store + .get_account_session(&browser_id, &account_id, &token) + .is_none(), + "expired session via get_account_session should return none" + ); + } + + #[test] + fn multiple_accounts_per_browser() { + let store = long_lived_store(); + let browser_id = store.create_browser(); + + store.add_account_to_browser( + &browser_id, + "user1@example.com".into(), + "pass1".into(), + "hash1".into(), + "imap.example.com".into(), + 993, + true, + "smtp.example.com".into(), + 587, + true, + ); + store.add_account_to_browser( + &browser_id, + "user2@example.com".into(), + "pass2".into(), + "hash2".into(), + "imap.example.com".into(), + 993, + true, + "smtp.example.com".into(), + 587, + true, + ); + store.add_account_to_browser( + &browser_id, + "user3@example.com".into(), + "pass3".into(), + "hash3".into(), + "imap.example.com".into(), + 993, + true, + "smtp.example.com".into(), + 587, + true, + ); + + let accounts = store.get_browser_accounts(&browser_id); + assert_eq!(accounts.len(), 3); + + let emails: HashSet<_> = accounts.iter().map(|a| a.email.as_str()).collect(); + assert!(emails.contains("user1@example.com")); + assert!(emails.contains("user2@example.com")); + assert!(emails.contains("user3@example.com")); + } + + #[test] + fn browsers_are_isolated() { + let store = long_lived_store(); + let browser1 = store.create_browser(); + let browser2 = store.create_browser(); + + store.add_account_to_browser( + &browser1, + "user1@example.com".into(), + "pass".into(), + "hash".into(), + "imap.example.com".into(), + 993, + true, + "smtp.example.com".into(), + 587, + true, + ); + store.add_account_to_browser( + &browser2, + "user2@example.com".into(), + "pass".into(), + "hash".into(), + "imap.example.com".into(), + 993, + true, + "smtp.example.com".into(), + 587, + true, + ); + + let accounts1 = store.get_browser_accounts(&browser1); + let accounts2 = store.get_browser_accounts(&browser2); + + assert_eq!(accounts1.len(), 1); + assert_eq!(accounts2.len(), 1); + assert_eq!(accounts1[0].email, "user1@example.com"); + assert_eq!(accounts2[0].email, "user2@example.com"); + + store.remove_browser(&browser1); + assert_eq!(store.get_browser_accounts(&browser2).len(), 1); + } + + #[test] + fn sliding_window_refreshes_expiry() { + // Use generous margins so the test is reliable under CI / Nix sandbox + // CPU pressure where sleeps can overshoot significantly. + let store = SessionStore::new(Duration::from_millis(800)); + let browser_id = store.create_browser(); + + let (token, _account_id) = store.add_account_to_browser( + &browser_id, + "user@example.com".into(), + "pass".into(), + "hash".into(), + "imap.example.com".into(), + 993, + true, + "smtp.example.com".into(), + 587, + true, + ); + + thread::sleep(Duration::from_millis(400)); + assert!(store.get(&token).is_some()); + + thread::sleep(Duration::from_millis(400)); + assert!(store.get(&token).is_some()); + } + + #[test] + fn helper_creates_valid_session() { + let session = create_test_session("test@example.com", "pass", "hash"); + assert_eq!(session.email, "test@example.com"); + assert_eq!(session.imap_host, "imap.test.com"); + assert_eq!(session.imap_port, 993); + } + + // --- Consistency tests --- + + fn add_test_account(store: &SessionStore, browser_id: &str, email: &str) -> (SessionId, AccountId) { + store.add_account_to_browser( + browser_id, + email.into(), + "pass".into(), + "hash".into(), + "imap.example.com".into(), + 993, + true, + "smtp.example.com".into(), + 587, + true, + ) + } + + #[test] + fn consistency_after_add_account() { + let store = long_lived_store(); + let browser = store.create_browser(); + add_test_account(&store, &browser, "a@example.com"); + add_test_account(&store, &browser, "b@example.com"); + assert!( + store.check_consistency().is_empty(), + "store should be consistent after adding accounts" + ); + } + + #[test] + fn consistency_after_remove_account() { + let store = long_lived_store(); + let browser = store.create_browser(); + let (_, aid1) = add_test_account(&store, &browser, "a@example.com"); + let (_, _aid2) = add_test_account(&store, &browser, "b@example.com"); + + store.remove_account(&aid1); + + let issues = store.check_consistency(); + assert!( + issues.is_empty(), + "store should be consistent after remove_account, got: {issues:?}" + ); + assert_eq!(store.get_browser_accounts(&browser).len(), 1); + } + + #[test] + fn consistency_after_remove_browser() { + let store = long_lived_store(); + let browser = store.create_browser(); + add_test_account(&store, &browser, "a@example.com"); + add_test_account(&store, &browser, "b@example.com"); + + store.remove_browser(&browser); + + let issues = store.check_consistency(); + assert!( + issues.is_empty(), + "store should be consistent after remove_browser, got: {issues:?}" + ); + assert_eq!(store.sessions.len(), 0); + assert_eq!(store.account_to_session.len(), 0); + } + + #[test] + fn consistency_after_purge_expired() { + let store = short_lived_store(); + let browser = store.create_browser(); + add_test_account(&store, &browser, "a@example.com"); + add_test_account(&store, &browser, "b@example.com"); + + thread::sleep(Duration::from_millis(100)); + store.purge_expired(); + + let issues = store.check_consistency(); + assert!( + issues.is_empty(), + "store should be consistent after purge_expired, got: {issues:?}" + ); + assert_eq!(store.sessions.len(), 0); + assert_eq!(store.account_to_session.len(), 0); + // Browser entry is removed when its account list becomes empty. + assert!(!store.browser_exists(&browser)); + } + + #[test] + fn purge_expired_respects_timeout_override() { + let store = short_lived_store(); // 50ms default + let browser = store.create_browser(); + + // Add one account with a long timeout override + let (token, account_id) = add_test_account(&store, &browser, "long@example.com"); + store.sessions.get_mut(&token).unwrap().timeout_override = Some(Duration::from_secs(3600)); + + // Add one with default (short) timeout + add_test_account(&store, &browser, "short@example.com"); + + thread::sleep(Duration::from_millis(100)); + store.purge_expired(); + + // The long-timeout session should survive. + assert!(store.get(&token).is_some()); + assert!(store.account_to_session.contains_key(&account_id)); + assert_eq!(store.sessions.len(), 1); + + let issues = store.check_consistency(); + assert!( + issues.is_empty(), + "store should be consistent after selective purge, got: {issues:?}" + ); + } + + #[test] + fn consistency_empty_store() { + let store = long_lived_store(); + assert!(store.check_consistency().is_empty()); + } + + #[test] + fn consistency_mixed_operations() { + let store = long_lived_store(); + let b1 = store.create_browser(); + let b2 = store.create_browser(); + + let (_, a1) = add_test_account(&store, &b1, "a@example.com"); + add_test_account(&store, &b1, "b@example.com"); + add_test_account(&store, &b2, "c@example.com"); + + store.remove_account(&a1); + store.remove_browser(&b2); + + let issues = store.check_consistency(); + assert!( + issues.is_empty(), + "store should be consistent after mixed operations, got: {issues:?}" + ); + // Only b@example.com should remain. + assert_eq!(store.sessions.len(), 1); + assert_eq!(store.account_to_session.len(), 1); + } + + // --- Reverse index tests --- + + #[test] + fn remove_account_uses_reverse_index() { + let store = long_lived_store(); + + // Create many browsers to demonstrate the O(1) lookup + let mut other_browsers = Vec::new(); + for _ in 0..100 { + let bid = store.create_browser(); + store.add_account_to_browser( + &bid, + "other@example.com".into(), + "pass".into(), + "hash".into(), + "imap.example.com".into(), + 993, + true, + "smtp.example.com".into(), + 587, + true, + ); + other_browsers.push(bid); + } + + let target_browser = store.create_browser(); + let (token, account_id) = store.add_account_to_browser( + &target_browser, + "target@example.com".into(), + "pass".into(), + "hash".into(), + "imap.example.com".into(), + 993, + true, + "smtp.example.com".into(), + 587, + true, + ); + + // Verify reverse index was populated + assert!(store.account_to_browser.contains_key(&account_id)); + assert_eq!( + store.account_to_browser.get(&account_id).unwrap().value(), + &target_browser + ); + + // Remove the account -- uses O(1) reverse index, not O(n) iteration + assert!(store.remove_account(&account_id)); + + // Verify full cleanup + assert!(store.get(&token).is_none()); + assert!(!store.account_to_session.contains_key(&account_id)); + assert!(!store.account_to_browser.contains_key(&account_id)); + assert!(store.get_browser_accounts(&target_browser).is_empty()); + + // Other browsers are untouched + for bid in &other_browsers { + assert_eq!(store.get_browser_accounts(bid).len(), 1); + } + } + + #[test] + fn remove_browser_cleans_up_reverse_index() { + let store = long_lived_store(); + let browser_id = store.create_browser(); + + let (_, account1) = store.add_account_to_browser( + &browser_id, + "user1@example.com".into(), + "pass".into(), + "hash1".into(), + "imap.example.com".into(), + 993, + true, + "smtp.example.com".into(), + 587, + true, + ); + let (_, account2) = store.add_account_to_browser( + &browser_id, + "user2@example.com".into(), + "pass".into(), + "hash2".into(), + "imap.example.com".into(), + 993, + true, + "smtp.example.com".into(), + 587, + true, + ); + + assert!(store.account_to_browser.contains_key(&account1)); + assert!(store.account_to_browser.contains_key(&account2)); + + store.remove_browser(&browser_id); + + assert!(!store.account_to_browser.contains_key(&account1)); + assert!(!store.account_to_browser.contains_key(&account2)); + } + + #[test] + fn purge_expired_cleans_up_reverse_index() { + let store = short_lived_store(); + let browser_id = store.create_browser(); + + let (_, account_id) = store.add_account_to_browser( + &browser_id, + "user@example.com".into(), + "pass".into(), + "hash".into(), + "imap.example.com".into(), + 993, + true, + "smtp.example.com".into(), + 587, + true, + ); + + assert!(store.account_to_browser.contains_key(&account_id)); + + thread::sleep(Duration::from_millis(100)); + store.purge_expired(); + + assert!(!store.account_to_browser.contains_key(&account_id)); + assert!(!store.account_to_session.contains_key(&account_id)); + assert!(store.get_browser_accounts(&browser_id).is_empty()); + } + + // --- Cascade eviction tests --- + + #[test] + fn purge_expired_cascades_to_browsers_and_account_map() { + let store = short_lived_store(); + let browser_id = store.create_browser(); + + let (_token, account_id) = store.add_account_to_browser( + &browser_id, + "user@example.com".into(), + "pass".into(), + "hash".into(), + "imap.example.com".into(), + 993, + true, + "smtp.example.com".into(), + 587, + true, + ); + + thread::sleep(Duration::from_millis(100)); + + store.purge_expired(); + + assert!(store.sessions.is_empty(), "session should be purged"); + assert!( + !store.account_to_session.contains_key(&account_id), + "account_to_session entry should be removed" + ); + assert!( + !store.browsers.contains_key(&browser_id), + "empty browser entry should be removed" + ); + } + + #[test] + fn purge_expired_keeps_live_accounts_in_browser() { + let store = SessionStore::new(Duration::from_millis(150)); + let browser_id = store.create_browser(); + + // First account -- will expire. + let (_token1, account1) = store.add_account_to_browser( + &browser_id, + "old@example.com".into(), + "pass".into(), + "hash".into(), + "imap.example.com".into(), + 993, + true, + "smtp.example.com".into(), + 587, + true, + ); + + thread::sleep(Duration::from_millis(100)); + + // Second account -- added later, still alive after sleep. + let (_token2, account2) = store.add_account_to_browser( + &browser_id, + "new@example.com".into(), + "pass".into(), + "hash".into(), + "imap.example.com".into(), + 993, + true, + "smtp.example.com".into(), + 587, + true, + ); + + thread::sleep(Duration::from_millis(60)); + + store.purge_expired(); + + assert_eq!(store.sessions.len(), 1, "only the live session should remain"); + assert!( + !store.account_to_session.contains_key(&account1), + "expired account mapping should be gone" + ); + assert!( + store.account_to_session.contains_key(&account2), + "live account mapping should remain" + ); + + let browser_accounts = store.browsers.get(&browser_id).unwrap(); + assert_eq!(browser_accounts.len(), 1); + assert_eq!(browser_accounts[0], account2); + } + + #[test] + fn lazy_eviction_on_get_cascades() { + let store = short_lived_store(); + let browser_id = store.create_browser(); + + let (token, account_id) = store.add_account_to_browser( + &browser_id, + "user@example.com".into(), + "pass".into(), + "hash".into(), + "imap.example.com".into(), + 993, + true, + "smtp.example.com".into(), + 587, + true, + ); + + thread::sleep(Duration::from_millis(100)); + + // Accessing the expired session should trigger cascade cleanup. + assert!(store.get(&token).is_none()); + + assert!( + !store.account_to_session.contains_key(&account_id), + "account_to_session should be cleaned up by lazy eviction" + ); + assert!( + !store.browsers.contains_key(&browser_id), + "empty browser should be cleaned up by lazy eviction" + ); + } + + #[test] + fn lazy_eviction_on_get_account_session_cascades() { + let store = short_lived_store(); + let browser_id = store.create_browser(); + + let (token, account_id) = store.add_account_to_browser( + &browser_id, + "user@example.com".into(), + "pass".into(), + "hash".into(), + "imap.example.com".into(), + 993, + true, + "smtp.example.com".into(), + 587, + true, + ); + + thread::sleep(Duration::from_millis(100)); + + assert!(store.get_account_session(&browser_id, &account_id, &token).is_none()); + + assert!( + !store.account_to_session.contains_key(&account_id), + "account_to_session should be cleaned up via get_account_session" + ); + } + #[test] + fn remembered_account_uses_timeout_override() { + let store = SessionStore::new(Duration::from_millis(50)); + let browser_id = store.create_browser(); + let (token, _) = store.add_account_to_browser_with_timeout( + &browser_id, + "remember@example.com".into(), + "pass".into(), + "hash".into(), + "imap.example.com".into(), + 993, + true, + "smtp.example.com".into(), + 587, + true, + Some(Duration::from_secs(1)), + ); + + thread::sleep(Duration::from_millis(100)); + assert!(store.get(&token).is_some(), "remembered session should outlive default timeout"); + } + +} diff --git a/src/auth/user_data.rs b/src/auth/user_data.rs new file mode 100644 index 0000000..d4ef46c --- /dev/null +++ b/src/auth/user_data.rs @@ -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 { + 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"); + } +} diff --git a/src/calendar/ics.rs b/src/calendar/ics.rs new file mode 100644 index 0000000..b8e80c1 --- /dev/null +++ b/src/calendar/ics.rs @@ -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, 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 = 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)>>) -> 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 = 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" + ); + } +} diff --git a/src/calendar/mod.rs b/src/calendar/mod.rs new file mode 100644 index 0000000..e33d978 --- /dev/null +++ b/src/calendar/mod.rs @@ -0,0 +1 @@ +pub mod ics; diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..4af598a --- /dev/null +++ b/src/config.rs @@ -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, + + /// IMAP server port. + #[serde(default = "default_imap_port")] + pub imap_port: u16, + + /// SMTP server hostname. When unset or blank, IMAP host is used. + #[serde(default)] + pub smtp_host: Option, + + /// SMTP server port. + #[serde(default = "default_smtp_port")] + pub smtp_port: u16, + + /// Whether TLS is enabled for mail connections. + #[serde(default = "default_tls_enabled")] + pub tls_enabled: bool, + + /// 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, + + /// ManageSieve host. When unset, the authenticated account's IMAP host is used. + #[serde(default)] + pub sieve_host: Option, + + /// ManageSieve TCP port. + #[serde(default = "default_sieve_port")] + pub sieve_port: u16, + + /// ManageSieve transport: `starttls`, `implicit` or `none`. + #[serde(default = "default_sieve_tls_mode")] + pub sieve_tls_mode: String, + + /// Explicit opt-in for sending reusable credentials over a cleartext Sieve connection. + #[serde(default)] + pub sieve_allow_insecure_auth: bool, + + /// Network timeout for ManageSieve connect/read/write operations. + #[serde(default = "default_sieve_timeout_secs")] + pub sieve_timeout_secs: u64, + + /// Comma-separated list of trusted proxy IPs or CIDR ranges that are allowed to set X-Forwarded-For. + #[serde(default)] + pub trusted_proxies: Option, + + /// Administrator-defined extra headers added to every outgoing message. + /// Each entry uses `Header-Name: value`. Structural/MIME headers are rejected. + #[serde(default)] + pub outgoing_headers: Vec, + + /// Enable Web Key Directory lookups for OpenPGP recipient keys. + #[serde(default = "default_pgp_wkd_enabled")] + pub pgp_wkd_enabled: bool, + + /// Allow optional HKP-over-HTTPS fallback for OpenPGP discovery. + #[serde(default)] + pub pgp_hkp_enabled: bool, + + /// HKP-compatible HTTPS endpoint used when the fallback is enabled. + #[serde(default = "default_pgp_hkp_url")] + pub pgp_hkp_url: String, + + /// Timeout for WKD/HKP network operations. + #[serde(default = "default_pgp_network_timeout_secs")] + pub pgp_network_timeout_secs: u64, + + /// Maximum accepted OpenPGP key response size. + #[serde(default = "default_pgp_max_key_bytes")] + pub pgp_max_key_bytes: usize, + + /// Allow authenticated users to export private OpenPGP key material. + /// Disabled by default; public-key export is always available. + #[serde(default)] + pub pgp_allow_private_export: bool, +} + +#[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 { + 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::() { + return Some(TrustedProxy::Ip(ip)); + } + match trimmed.parse::() { + 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, 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 { + 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())] + ); + } +} diff --git a/src/db/calendar.rs b/src/db/calendar.rs new file mode 100644 index 0000000..d8068d6 --- /dev/null +++ b/src/db/calendar.rs @@ -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, + pub attendees: String, + pub organizer: String, + pub status: String, + pub source: String, + pub source_uid: Option, + pub source_message_uid: Option, + pub source_message_folder: Option, + pub meeting_url: Option, + pub color: Option, + pub reminder_minutes: Option, + 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, + #[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, + pub source_message_uid: Option, + pub source_message_folder: Option, + pub meeting_url: Option, + pub color: Option, + pub reminder_minutes: Option, +} + +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, + pub description: Option, + pub location: Option, + pub start_time: Option, + pub end_time: Option, + pub all_day: Option, + pub recurrence_rule: Option, + pub attendees: Option, + pub organizer: Option, + pub status: Option, + pub meeting_url: Option, + pub color: Option, + pub reminder_minutes: Option, +} + +/// 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, + pub week_starts_on: Option, + pub working_hours_start: Option, + pub working_hours_end: Option, + pub time_format: Option, + pub zoom_link: Option, + pub teams_link: Option, + pub meet_link: Option, + pub caldav_url: Option, + pub caldav_username: Option, + pub caldav_password: Option, + pub caldav_enabled: Option, +} + +/// 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 { + 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 { + 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 { + 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, 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, 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, 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 { + 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, String> { + // Build SET clauses dynamically for non-None fields. + let mut sets = Vec::new(); + let mut values: Vec> = 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 { + 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, 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 { + // 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 { + // 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> = 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, 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 { + 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 { + 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, + } + } + } +} diff --git a/src/db/contact_groups.rs b/src/db/contact_groups.rs new file mode 100644 index 0000000..d8786eb --- /dev/null +++ b/src/db/contact_groups.rs @@ -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, 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 { + 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 { + 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 { + 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, 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::>(&row.get::<_, String>(16)?) + .unwrap_or_default(), + phones: serde_json::from_str::>(&row.get::<_, String>(17)?) + .unwrap_or_default(), + addresses: serde_json::from_str::>(&row.get::<_, String>(18)?) + .unwrap_or_default(), + 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, 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"); + } +} diff --git a/src/db/contacts.rs b/src/db/contacts.rs new file mode 100644 index 0000000..247f5c4 --- /dev/null +++ b/src/db/contacts.rs @@ -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, + pub phones: Vec, + pub addresses: Vec, + pub is_favorite: bool, + pub last_contacted: Option, + 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 { + 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::>(&row.get::<_, String>(16)?).unwrap_or_default(), + phones: serde_json::from_str::>(&row.get::<_, String>(17)?).unwrap_or_default(), + addresses: serde_json::from_str::>(&row.get::<_, String>(18)?).unwrap_or_default(), + 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, 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, 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, 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 { + 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, 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, 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 = 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)>, 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 { + 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"); + } +} diff --git a/src/db/display_preferences.rs b/src/db/display_preferences.rs new file mode 100644 index 0000000..df273c0 --- /dev/null +++ b/src/db/display_preferences.rs @@ -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, + 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, + pub theme: Option, + pub language: Option, + pub compose_format: Option, + pub compose_autosave_ms: Option, + pub default_priority: Option, + pub request_mdn_default: Option, + pub attachment_reminder: Option, + pub deep_index: Option, + #[serde(default, deserialize_with = "deserialize_animation_mode_field")] + pub animation_mode: Option>, + pub preview_pane: Option, + pub ui_scale: Option, + pub color_preset: Option, + pub accent_color: Option, + pub background_color: Option, + pub panel_color: Option, + pub text_color: Option, + pub font_family: Option, + pub font_size: Option, + pub row_height: Option, + pub folder_pane_width: Option, + pub message_pane_width: Option, + pub border_radius: Option, + pub contrast: Option, + pub show_avatars: Option, + pub show_attachment_icons: Option, + pub show_flags: Option, + pub reduce_motion: Option, + pub date_format: Option, + pub time_format: Option, + pub icon_size: Option, + pub toolbar_density: Option, + pub sidebar_item_height: Option, + pub reader_max_width: Option, + pub composer_width: Option, + pub selection_color: Option, + pub unread_font_weight: Option, + pub unread_badge_style: Option, + pub compact_headers: Option, + pub show_borders: Option, + pub scrollbar_density: Option, + pub monochrome: Option, + pub folder_background: Option, + pub message_background: Option, + pub reader_background: Option, + pub compose_font_family: Option, + pub reply_position: Option, + pub quote_style: Option, + pub forward_mode: Option, + pub spellcheck_enabled: Option, +} + +fn deserialize_animation_mode_field<'de, D>(deserializer: D) -> Result>, D::Error> +where + D: Deserializer<'de>, +{ + let value = Option::::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 { + 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 { + 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> = 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()); + } +} diff --git a/src/db/drafts.rs b/src/db/drafts.rs new file mode 100644 index 0000000..deafecd --- /dev/null +++ b/src/db/drafts.rs @@ -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, + pub in_reply_to: Option, + pub references_header: Option, + 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, + pub remote_uid: Option, + pub remote_uid_validity: Option, + 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, 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, 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 { + 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, 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 { + 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()); + } +} diff --git a/src/db/folder_preferences.rs b/src/db/folder_preferences.rs new file mode 100644 index 0000000..2a9eba8 --- /dev/null +++ b/src/db/folder_preferences.rs @@ -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, + pub effective_flagged_on_top: bool, +} + +#[derive(Debug, Deserialize)] +pub struct UpdateFolderPreferences { + pub flagged_on_top: Option, +} + +fn get_override(conn: &Connection, folder: &str) -> Result, String> { + conn.query_row( + "SELECT flagged_on_top FROM folder_preferences WHERE folder_name = ?1", + [folder], + |row| row.get::<_, Option>(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 { + 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 { + 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 { + 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); + } +} diff --git a/src/db/folders.rs b/src/db/folders.rs new file mode 100644 index 0000000..91af031 --- /dev/null +++ b/src/db/folders.rs @@ -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, + pub parent: Option, + 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, 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 { + if current_names.is_empty() { + // Delete all folders when the current list is empty. + let deleted = conn + .execute("DELETE FROM folders", []) + .map_err(|e| format!("Failed to delete all folders: {e}"))?; + return Ok(deleted); + } + + // Build a parameterized WHERE NOT IN clause. + let placeholders: Vec = (1..=current_names.len()) + .map(|i| format!("?{i}")) + .collect(); + let 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 { + 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 { + 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 { + 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, 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, 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")); + } +} diff --git a/src/db/header_sync.rs b/src/db/header_sync.rs new file mode 100644 index 0000000..4d7cfc2 --- /dev/null +++ b/src/db/header_sync.rs @@ -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 { + 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::().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, 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(()) +} diff --git a/src/db/identities.rs b/src/db/identities.rs new file mode 100644 index 0000000..e4bcc6e --- /dev/null +++ b/src/db/identities.rs @@ -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, + 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, + #[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, + pub email: Option, + pub signature_html: Option, + pub is_default: Option, + pub reply_to: Option, + pub bcc: Option, + pub organization: Option, + pub signature_text: Option, + pub signature_new: Option, + pub signature_reply: Option, + pub signature_forward: Option, + pub signature_position: Option, + pub pgp_fingerprint: Option>, + pub pgp_sign_default: Option, + pub pgp_encrypt_default: Option, + pub pgp_inline_default: Option, +} + +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 { + 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, 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, 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, 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 { + 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, 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> = 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 { + 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 { + 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:"

Best

".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()); + } +} diff --git a/src/db/mail_preferences.rs b/src/db/mail_preferences.rs new file mode 100644 index 0000000..61ef55b --- /dev/null +++ b/src/db/mail_preferences.rs @@ -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, + pub mark_read_delay_ms: Option, + pub remote_images: Option, + pub preferred_body: Option, + pub thread_mode: Option, + pub collapse_quotes: Option, + pub show_snippets: Option, + pub default_sort: Option, + pub flagged_on_top: Option, + pub sync_imap_keywords: Option, +} + +pub fn get_preferences(conn: &Connection) -> Result { + 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 { + 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); + } +} diff --git a/src/db/messages.rs b/src/db/messages.rs new file mode 100644 index 0000000..bb92c83 --- /dev/null +++ b/src/db/messages.rs @@ -0,0 +1,1180 @@ +use rusqlite::{Connection, params}; +use serde::Serialize; + +// mail_parser is used for RFC2822 date parsing in parse_date_to_epoch. + +/// Message data needed to rebuild the full-text search index from SQLite. +#[derive(Debug, Clone)] +pub struct IndexSourceMessage { + pub uid: u32, + pub folder: String, + pub subject: String, + pub from_address: String, + pub from_name: String, + pub to_addresses: String, + pub body_text: String, + pub date_epoch: i64, + pub has_attachments: bool, +} + +/// A cached email message header, mirroring the query-visible columns of the +/// `messages` table. +#[derive(Debug, Clone, Serialize)] +pub struct CachedMessage { + pub uid: u32, + pub folder: String, + pub message_id: Option, + pub in_reply_to: Option, + pub references_header: Option, + pub subject: String, + pub from_address: String, + pub from_name: String, + pub to_addresses: String, + pub cc_addresses: String, + pub date: String, + pub flags: String, + pub size: u32, + pub has_attachments: bool, + pub snippet: String, + pub reaction: Option, +} + +// --------------------------------------------------------------------------- +// Helper to map a row to CachedMessage (used in multiple queries) +// --------------------------------------------------------------------------- + +fn row_to_cached_message(row: &rusqlite::Row<'_>) -> rusqlite::Result { + let has_attachments_int: i32 = row.get(12)?; + Ok(CachedMessage { + uid: row.get(0)?, + folder: row.get(1)?, + message_id: row.get(2)?, + in_reply_to: row.get(3)?, + references_header: row.get(4)?, + subject: row.get(5)?, + from_address: row.get(6)?, + from_name: row.get(7)?, + to_addresses: row.get(8)?, + cc_addresses: row.get(9)?, + date: row.get(10)?, + flags: row.get(11)?, + size: row.get(13)?, + has_attachments: has_attachments_int != 0, + snippet: row.get(14)?, + reaction: row.get(15)?, + }) +} + +/// The SELECT column list used by all queries that return `CachedMessage`. +const MSG_SELECT_COLS: &str = + "uid, folder, message_id, in_reply_to, references_header, + subject, from_address, from_name, to_addresses, cc_addresses, + date, flags, has_attachments, size, snippet, reaction"; + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/// Insert or replace a message header row. +#[allow(clippy::too_many_arguments)] +pub fn upsert_message( + conn: &Connection, + folder: &str, + uid: u32, + message_id: Option<&str>, + in_reply_to: Option<&str>, + references_header: Option<&str>, + subject: &str, + from_address: &str, + from_name: &str, + to_json: &str, + cc_json: &str, + date: &str, + flags_csv: &str, + size: u32, + has_attachments: bool, + snippet: &str, + reaction: Option<&str>, +) -> Result<(), String> { + let date_epoch = parse_date_to_epoch(date); + conn.execute( + "INSERT OR REPLACE INTO messages + (uid, folder, message_id, in_reply_to, references_header, + subject, from_address, from_name, to_addresses, cc_addresses, + date, flags, size, has_attachments, snippet, date_epoch, reaction) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17)", + params![ + uid, + folder, + message_id, + in_reply_to, + references_header, + subject, + from_address, + from_name, + to_json, + cc_json, + date, + flags_csv, + size, + has_attachments as i32, + snippet, + date_epoch, + reaction, + ], + ) + .map_err(|e| format!("Failed to upsert message: {e}"))?; + Ok(()) +} + +/// Remove a successfully moved/deleted source UID and reject older header reads. +/// Invalidate freshness so a rejected batch is retried by the next list request. +pub fn remove_after_mutation(conn: &Connection, folder: &str, uid: u32) -> Result<(), String> { + let transaction = + rusqlite::Transaction::new_unchecked(conn, rusqlite::TransactionBehavior::Immediate) + .map_err(|e| e.to_string())?; + delete_message(conn, folder, uid)?; + super::header_sync::invalidate(&transaction)?; + super::folders::invalidate_folder_freshness(conn, folder)?; + transaction.commit().map_err(|e| e.to_string()) +} + +/// Delete all messages in a folder (used when UIDVALIDITY changes). +pub fn delete_folder_messages(conn: &Connection, folder: &str) -> Result { + let deleted = conn + .execute("DELETE FROM messages WHERE folder = ?1", params![folder]) + .map_err(|e| format!("Failed to delete folder messages: {e}"))?; + Ok(deleted) +} + +/// Return the highest cached UID for a folder, or 0 if no messages are cached. +pub fn max_uid(conn: &Connection, folder: &str) -> Result { + conn.query_row( + "SELECT COALESCE(MAX(uid), 0) FROM messages WHERE folder = ?1", + params![folder], + |row| row.get(0), + ) + .map_err(|e| format!("Failed to get max uid: {e}")) +} + +/// Return all cached message data needed for a full Tantivy rebuild. +/// Body text falls back to the message snippet when the full body has not yet +/// been cached, so a rebuild remains useful before every message is opened. +pub fn list_index_source_messages(conn: &Connection) -> Result, String> { + let mut stmt = conn + .prepare( + "SELECT uid, folder, subject, from_address, from_name, to_addresses, \ + COALESCE(NULLIF(body_text, ''), snippet, ''), date_epoch, has_attachments \ + FROM messages ORDER BY folder, uid", + ) + .map_err(|e| format!("Failed to prepare index rebuild query: {e}"))?; + + let rows = stmt + .query_map([], |row| { + let has_attachments: i32 = row.get(8)?; + Ok(IndexSourceMessage { + uid: row.get(0)?, + folder: row.get(1)?, + subject: row.get(2)?, + from_address: row.get(3)?, + from_name: row.get(4)?, + to_addresses: row.get(5)?, + body_text: row.get(6)?, + date_epoch: row.get(7)?, + has_attachments: has_attachments != 0, + }) + }) + .map_err(|e| format!("Failed to query messages for index rebuild: {e}"))?; + + let mut messages = Vec::new(); + for row in rows { + messages.push(row.map_err(|e| format!("Failed to read index rebuild row: {e}"))?); + } + Ok(messages) +} + +/// Public wrapper to parse a date string to a Unix epoch timestamp. +/// Useful when other modules need to compute `date_epoch` from a raw date string. +pub fn parse_date_to_epoch_public(date: &str) -> i64 { + parse_date_to_epoch(date) +} + +/// Parse a date string to a Unix epoch timestamp (seconds). +/// Tries RFC2822 first (IMAP dates), then ISO 8601. +/// Returns 0 on parse failure. +fn parse_date_to_epoch(date: &str) -> i64 { + if date.is_empty() { + return 0; + } + // Try ISO 8601 format first (used in test fixtures and some IMAP servers). + if let Ok(epoch) = parse_iso8601_to_epoch(date) { + return epoch; + } + // Try mail-parser's RFC2822 date parsing (standard IMAP date format). + if let Some(dt) = mail_parser::DateTime::parse_rfc822(date) { + let epoch = datetime_to_epoch(&dt); + // Sanity check: mail_parser can misparse non-RFC2822 dates. + if dt.year >= 1970 && dt.month >= 1 && dt.month <= 12 && epoch > 0 { + return epoch; + } + } + 0 +} + +/// Convert a mail_parser::DateTime to a Unix epoch timestamp. +fn datetime_to_epoch(dt: &mail_parser::DateTime) -> i64 { + // Days from year 1970 to the start of the given year + let year = dt.year as i64; + let mut days: i64 = 0; + for y in 1970..year { + days += if is_leap_year(y) { 366 } else { 365 }; + } + for y in (year..1970).rev() { + days -= if is_leap_year(y) { 366 } else { 365 }; + } + // Days from start of year to start of month + let month_days: [i64; 12] = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + let month = (dt.month as usize).saturating_sub(1).min(11); + for (m, &d) in month_days.iter().enumerate().take(month) { + days += d; + if m == 1 && is_leap_year(year) { + days += 1; + } + } + days += (dt.day as i64) - 1; + + let secs = days * 86400 + (dt.hour as i64) * 3600 + (dt.minute as i64) * 60 + (dt.second as i64); + // tz_before_gmt=true means the offset is negative (behind UTC) + let tz_offset_secs = (dt.tz_hour as i64) * 3600 + (dt.tz_minute as i64) * 60; + if dt.tz_before_gmt { + secs + tz_offset_secs + } else { + secs - tz_offset_secs + } +} + +fn is_leap_year(y: i64) -> bool { + (y % 4 == 0 && y % 100 != 0) || y % 400 == 0 +} + +/// Parse ISO 8601 dates like "2024-01-01T10:00:00Z" +fn parse_iso8601_to_epoch(s: &str) -> Result { + // Simple parser for YYYY-MM-DDTHH:MM:SSZ + let s = s.trim(); + if s.len() < 19 { + return Err(()); + } + let year: i64 = s[0..4].parse().map_err(|_| ())?; + let month: u32 = s[5..7].parse().map_err(|_| ())?; + let day: u32 = s[8..10].parse().map_err(|_| ())?; + let hour: u32 = s[11..13].parse().map_err(|_| ())?; + let minute: u32 = s[14..16].parse().map_err(|_| ())?; + let second: u32 = s[17..19].parse().map_err(|_| ())?; + + let dt = mail_parser::DateTime { + year: year as u16, + month: month as u8, + day: day as u8, + hour: hour as u8, + minute: minute as u8, + second: second as u8, + tz_before_gmt: false, + tz_hour: 0, + tz_minute: 0, + }; + Ok(datetime_to_epoch(&dt)) +} + +/// Return a page of messages for a folder, ordered by date descending. +/// `page` is 0-indexed. +pub fn get_messages( + conn: &Connection, + folder: &str, + page: u32, + per_page: u32, +) -> Result, String> { + let offset = page * per_page; + let sql = format!( + "SELECT {MSG_SELECT_COLS} + FROM messages + WHERE folder = ?1 + ORDER BY date_epoch DESC + LIMIT ?2 OFFSET ?3" + ); + + let mut stmt = conn + .prepare(&sql) + .map_err(|e| format!("Failed to prepare get_messages: {e}"))?; + + let rows = stmt + .query_map(params![folder, per_page, offset], |row| { + row_to_cached_message(row) + }) + .map_err(|e| format!("Failed to query messages: {e}"))?; + + let mut messages = Vec::new(); + for row in rows { + messages.push(row.map_err(|e| format!("Failed to read message row: {e}"))?); + } + Ok(messages) +} + +/// Return a page of messages with a validated sort order. +/// +/// `sort` is mapped to a static SQL fragment; user input is never interpolated +/// into the ORDER BY clause. +pub fn get_messages_sorted( + conn: &Connection, + folder: &str, + page: u32, + per_page: u32, + sort: &str, + flagged_on_top: bool, +) -> Result, String> { + if sort == "date_desc" && !flagged_on_top { + return get_messages(conn, folder, page, per_page); + } + + let base_order = match sort { + "date_desc" => "date_epoch DESC, uid DESC", + "date_asc" => "date_epoch ASC, uid ASC", + "sender_asc" => "LOWER(COALESCE(NULLIF(from_name, ''), from_address)) ASC, date_epoch DESC", + "sender_desc" => "LOWER(COALESCE(NULLIF(from_name, ''), from_address)) DESC, date_epoch DESC", + "subject_asc" => "LOWER(subject) ASC, date_epoch DESC", + "subject_desc" => "LOWER(subject) DESC, date_epoch DESC", + "size_asc" => "size ASC, date_epoch DESC", + "size_desc" => "size DESC, date_epoch DESC", + _ => return Err(format!("Unsupported message sort: {sort}")), + }; + let order_by = if flagged_on_top { + format!("CASE WHEN instr(flags, '\\Flagged') > 0 THEN 0 ELSE 1 END ASC, {base_order}") + } else { + base_order.to_string() + }; + + let offset = page * per_page; + let sql = format!( + "SELECT {MSG_SELECT_COLS} + FROM messages + WHERE folder = ?1 + ORDER BY {order_by} + LIMIT ?2 OFFSET ?3" + ); + let mut stmt = conn + .prepare(&sql) + .map_err(|e| format!("Failed to prepare sorted get_messages: {e}"))?; + let rows = stmt + .query_map(params![folder, per_page, offset], row_to_cached_message) + .map_err(|e| format!("Failed to query sorted messages: {e}"))?; + rows.collect::, _>>() + .map_err(|e| format!("Failed to read sorted message row: {e}")) +} + +/// Return the total number of cached messages for a folder. +pub fn count_messages(conn: &Connection, folder: &str) -> Result { + conn.query_row( + "SELECT COUNT(*) FROM messages WHERE folder = ?1", + params![folder], + |row| row.get(0), + ) + .map_err(|e| format!("Failed to count messages: {e}")) +} + +/// Return the total number of cached messages across all folders. +/// Used as a safe upper bound when the UI explicitly asks to materialize +/// every result of a search for a bulk operation. +pub fn count_all_messages(conn: &Connection) -> Result { + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM messages", [], |row| row.get(0)) + .map_err(|e| format!("Failed to count cached messages: {e}"))?; + usize::try_from(count).map_err(|_| "Cached message count is out of range".to_string()) +} + +/// Update only the flags column for a specific message. +pub fn update_message_flags( + conn: &Connection, + folder: &str, + uid: u32, + flags_csv: &str, +) -> Result<(), String> { + conn.execute( + "UPDATE messages SET flags = ?1 WHERE folder = ?2 AND uid = ?3", + params![flags_csv, folder, uid], + ) + .map_err(|e| format!("Failed to update message flags: {e}"))?; + Ok(()) +} + +/// Cache a message body (HTML and/or plain text) along with attachment +/// metadata (as a JSON string) and the raw RFC-822 headers. +#[allow(clippy::too_many_arguments)] +pub fn cache_message_body( + conn: &Connection, + folder: &str, + uid: u32, + html: Option<&str>, + text: Option<&str>, + attachments_json: Option<&str>, + raw_headers: Option<&str>, + email_theme: Option, +) -> Result<(), String> { + conn.execute( + "UPDATE messages + SET body_html = ?1, body_text = ?2, body_cached = 1, + attachments_json = ?3, raw_headers = ?4, email_theme = ?5 + WHERE folder = ?6 AND uid = ?7", + params![html, text, attachments_json, raw_headers, email_theme, folder, uid], + ) + .map_err(|e| format!("Failed to cache message body: {e}"))?; + Ok(()) +} + +pub fn update_email_theme( + conn: &Connection, + folder: &str, + uid: u32, + email_theme: i32, +) -> Result<(), String> { + conn.execute( + "UPDATE messages SET email_theme = ?1 WHERE folder = ?2 AND uid = ?3", + params![email_theme, folder, uid], + ) + .map_err(|e| format!("Failed to update email theme: {e}"))?; + Ok(()) +} + +/// Cached body data including attachment metadata and raw headers. +pub struct CachedBody { + pub html: Option, + pub text: Option, + pub attachments_json: Option, + pub raw_headers: Option, + pub email_theme: Option, +} + +/// Return the cached body if `body_cached = 1`, otherwise `None`. +pub fn get_cached_body( + conn: &Connection, + folder: &str, + uid: u32, +) -> Result, String> { + let result = conn.query_row( + "SELECT body_cached, body_html, body_text, attachments_json, raw_headers, email_theme + FROM messages + WHERE folder = ?1 AND uid = ?2", + params![folder, uid], + |row| { + let cached: i32 = row.get(0)?; + if cached == 1 { + Ok(Some(CachedBody { + html: row.get(1)?, + text: row.get(2)?, + attachments_json: row.get(3)?, + raw_headers: row.get(4)?, + email_theme: row.get(5)?, + })) + } else { + Ok(None) + } + }, + ); + + match result { + Ok(body) => Ok(body), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(format!("Failed to get cached body: {e}")), + } +} + +/// Return a single message by folder and UID, or None if not found. +pub fn get_single_message(conn: &Connection, folder: &str, uid: u32) -> Result, String> { + let sql = format!( + "SELECT {MSG_SELECT_COLS} + FROM messages + WHERE folder = ?1 AND uid = ?2" + ); + + let result = conn.query_row(&sql, params![folder, uid], |row| { + row_to_cached_message(row) + }); + + match result { + Ok(msg) => Ok(Some(msg)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(format!("Failed to get single message: {e}")), + } +} + +/// Delete a single message by folder and UID. +pub fn delete_message(conn: &Connection, folder: &str, uid: u32) -> Result<(), String> { + conn.execute( + "DELETE FROM messages WHERE folder = ?1 AND uid = ?2", + params![folder, uid], + ) + .map_err(|e| format!("Failed to delete message: {e}"))?; + Ok(()) +} + +/// Return `(folder, uid)` pairs for messages that don't have a cached body, +/// ordered by date_epoch DESC (most recent first), limited to `limit`. +/// Used by the deep-index background task to know which bodies to fetch. +pub fn get_unindexed_messages(conn: &Connection, limit: u32) -> Result, String> { + let mut stmt = conn + .prepare( + "SELECT folder, uid FROM messages + WHERE body_cached = 0 + ORDER BY date_epoch DESC + LIMIT ?1", + ) + .map_err(|e| format!("Failed to prepare get_unindexed_messages: {e}"))?; + + let rows = stmt + .query_map(params![limit], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, u32>(1)?)) + }) + .map_err(|e| format!("Failed to query unindexed messages: {e}"))?; + + let mut result = Vec::new(); + for row in rows { + result.push(row.map_err(|e| format!("Failed to read unindexed row: {e}"))?); + } + Ok(result) +} + +/// Return all (uid, flags_csv) pairs for a folder, for reconciliation. +pub fn get_all_uids_and_flags(conn: &Connection, folder: &str) -> Result, String> { + let mut stmt = conn + .prepare("SELECT uid, flags FROM messages WHERE folder = ?1") + .map_err(|e| format!("Failed to prepare get_all_uids_and_flags: {e}"))?; + let rows = stmt + .query_map(params![folder], |row| { + Ok((row.get::<_, u32>(0)?, row.get::<_, String>(1)?)) + }) + .map_err(|e| format!("Failed to query uids and flags: {e}"))?; + + let mut result = Vec::new(); + for row in rows { + result.push(row.map_err(|e| format!("Failed to read uid/flags row: {e}"))?); + } + Ok(result) +} + +/// Find messages related to the given `target_message_id` for threading. +/// +/// Returns messages where: +/// - `message_id` equals `target_message_id`, OR +/// - `in_reply_to` equals `target_message_id`, OR +/// - `references_header` contains `target_message_id` +pub fn get_thread_messages( + conn: &Connection, + target_message_id: &str, +) -> Result, String> { + // Escape % and _ characters in the message_id to prevent LIKE injection. + let escaped_target = target_message_id.replace("%", "\\%").replace("_", "\\_"); + let like_pattern = format!("%{}%", escaped_target); + let sql = format!( + "SELECT {MSG_SELECT_COLS} + FROM messages + WHERE message_id = ?1 + OR in_reply_to = ?1 + OR references_header LIKE ?2 ESCAPE '\\' + ORDER BY date_epoch ASC" + ); + + let mut stmt = conn + .prepare(&sql) + .map_err(|e| format!("Failed to prepare get_thread_messages: {e}"))?; + + let rows = stmt + .query_map(params![target_message_id, like_pattern], |row| { + row_to_cached_message(row) + }) + .map_err(|e| format!("Failed to query thread messages: {e}"))?; + + let mut messages = Vec::new(); + for row in rows { + let message = row.map_err(|e| format!("Failed to read thread message row: {e}"))?; + // The SQL LIKE narrows candidates but a Message-ID must match a complete + // token in References, never just a substring of another ID. + let exact_reference = message + .references_header + .as_deref() + .is_some_and(|refs| refs.split_whitespace().any(|id| id == target_message_id)); + if message.message_id.as_deref() == Some(target_message_id) + || message.in_reply_to.as_deref() == Some(target_message_id) + || exact_reference + { + messages.push(message); + } + } + Ok(messages) +} + +/// Build a complete email thread by transitively walking the References chain. +/// +/// Starting from `message_id` and the optional space-separated `references` string, +/// this function repeatedly queries related messages (via `get_thread_messages`) and +/// collects every message_id, in_reply_to, and references_header ID it encounters. +/// The process repeats until no new IDs are discovered (transitive closure). +/// +/// The result is deduplicated by `(folder, uid)` and sorted by `date_epoch` ascending. +pub fn get_full_thread( + conn: &Connection, + message_id: &str, + references: Option<&str>, +) -> Result, String> { + use std::collections::HashSet; + + // Step 1: Seed the set of known message IDs. + let mut known_ids: HashSet = HashSet::new(); + known_ids.insert(message_id.to_string()); + if let Some(refs) = references { + for id in refs.split_whitespace() { + if !id.is_empty() { + known_ids.insert(id.to_string()); + } + } + } + + // Step 2-4: Iteratively query until no new IDs are found. + let mut processed_ids: HashSet = HashSet::new(); + let mut all_messages: Vec = Vec::new(); + + loop { + // Find IDs we haven't queried yet. + let to_query: Vec = known_ids + .difference(&processed_ids) + .cloned() + .collect(); + + if to_query.is_empty() { + break; + } + + for id in &to_query { + processed_ids.insert(id.clone()); + + let found = get_thread_messages(conn, id)?; + for msg in found { + // Extract IDs from each found message and add to known set. + if let Some(ref mid) = msg.message_id + && !mid.is_empty() + { + known_ids.insert(mid.clone()); + } + if let Some(ref irt) = msg.in_reply_to + && !irt.is_empty() + { + known_ids.insert(irt.clone()); + } + if let Some(ref refs) = msg.references_header { + for r in refs.split_whitespace() { + if !r.is_empty() { + known_ids.insert(r.to_string()); + } + } + } + all_messages.push(msg); + } + } + } + + // Step 5: Deduplicate by (folder, uid) pair. + let mut seen: HashSet<(String, u32)> = HashSet::new(); + all_messages.retain(|msg| seen.insert((msg.folder.clone(), msg.uid))); + + // Step 6: Sort by date_epoch ascending. + all_messages.sort_by_key(|msg| parse_date_to_epoch(&msg.date)); + + Ok(all_messages) +} + +/// Search the SQLite message cache using LIKE for text matches across +/// subject, from_name, from_address, and to_addresses. +/// This provides comprehensive results independent of the tantivy index state. +#[allow(clippy::too_many_arguments)] +pub fn search_messages_sqlite( + conn: &Connection, + text: &str, + subject: Option<&str>, + folder: Option<&str>, + from: Option<&str>, + to: Option<&str>, + date_from: Option, + date_to: Option, + has_attachment: Option, + unread: Option, + flagged: Option, + limit: usize, +) -> Result, String> { + let mut conditions = Vec::new(); + let mut param_values: Vec> = Vec::new(); + let mut idx = 1u32; + + // Exclude Trash/Spam/Junk from search results (same as tantivy). + conditions.push("LOWER(folder) NOT IN ('trash', 'spam', 'junk')".to_string()); + + if !text.is_empty() { + let pattern = format!("%{text}%"); + conditions.push(format!( + "(subject LIKE ?{idx} OR from_name LIKE ?{idx} OR from_address LIKE ?{idx} OR to_addresses LIKE ?{idx})" + )); + param_values.push(Box::new(pattern)); + idx += 1; + } + + if let Some(subject) = subject.filter(|value| !value.trim().is_empty()) { + conditions.push(format!("subject LIKE ?{idx}")); + param_values.push(Box::new(format!("%{subject}%"))); + idx += 1; + } + + if let Some(f) = folder { + conditions.push(format!("folder = ?{idx}")); + param_values.push(Box::new(f.to_string())); + idx += 1; + } + + if let Some(f) = from { + if f.contains('@') { + conditions.push(format!("from_address = ?{idx}")); + } else { + conditions.push(format!("from_name LIKE ?{idx}")); + } + let val = if f.contains('@') { f.to_string() } else { format!("%{f}%") }; + param_values.push(Box::new(val)); + idx += 1; + } + + if let Some(t) = to { + conditions.push(format!("to_addresses LIKE ?{idx}")); + param_values.push(Box::new(format!("%{t}%"))); + idx += 1; + } + + if let Some(df) = date_from { + conditions.push(format!("date_epoch >= ?{idx}")); + param_values.push(Box::new(df)); + idx += 1; + } + + if let Some(dt) = date_to { + conditions.push(format!("date_epoch <= ?{idx}")); + param_values.push(Box::new(dt)); + idx += 1; + } + + if let Some(ha) = has_attachment { + conditions.push(format!("has_attachments = ?{idx}")); + param_values.push(Box::new(ha as i32)); + idx += 1; + } + + if let Some(want_unread) = unread { + conditions.push(if want_unread { + "flags NOT LIKE '%\\Seen%'".to_string() + } else { + "flags LIKE '%\\Seen%'".to_string() + }); + } + + if let Some(want_flagged) = flagged { + conditions.push(if want_flagged { + "flags LIKE '%\\Flagged%'".to_string() + } else { + "flags NOT LIKE '%\\Flagged%'".to_string() + }); + } + + let _ = idx; // suppress unused warning + + let where_clause = if conditions.is_empty() { + String::new() + } else { + format!("WHERE {}", conditions.join(" AND ")) + }; + + let sql = format!( + "SELECT {MSG_SELECT_COLS} + FROM messages + {where_clause} + ORDER BY date_epoch DESC + LIMIT ?{}", param_values.len() + 1 + ); + + param_values.push(Box::new(limit as i64)); + + let params_refs: Vec<&dyn rusqlite::types::ToSql> = param_values.iter().map(|b| b.as_ref()).collect(); + + let mut stmt = conn + .prepare(&sql) + .map_err(|e| format!("Failed to prepare search query: {e}"))?; + + let rows = stmt + .query_map(rusqlite::params_from_iter(params_refs), |row| { + row_to_cached_message(row) + }) + .map_err(|e| format!("Failed to execute search query: {e}"))?; + + let mut results = Vec::new(); + for row in rows { + results.push(row.map_err(|e| format!("Failed to read search row: {e}"))?); + } + Ok(results) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::folders::upsert_folder; + use crate::db::pool::open_test_db; + + /// Helper: insert a folder so that the foreign key constraint is satisfied. + fn ensure_folder(conn: &Connection, name: &str) { + upsert_folder(conn, name, Some("/"), None, "", true, 0, 0, 1, 0).unwrap(); + } + + /// Helper: insert a sample message. + fn insert_sample(conn: &Connection, folder: &str, uid: u32, date: &str) { + upsert_message( + conn, + folder, + uid, + Some(&format!("")), + None, + None, + &format!("Subject {uid}"), + "alice@example.com", + "Alice", + "[]", + "[]", + date, + "\\Seen", + 1024, + false, + "snippet", + None, + ) + .unwrap(); + } + + #[test] + fn cached_storage_tracks_all_folders_and_deletions() { + let conn = open_test_db(); + assert_eq!(cached_storage_bytes(&conn).unwrap(), None); + ensure_folder(&conn, "INBOX"); + ensure_folder(&conn, "Archive"); + insert_sample(&conn, "INBOX", 1, "2024-01-01"); + insert_sample(&conn, "Archive", 1, "2024-01-01"); + assert_eq!(cached_storage_bytes(&conn).unwrap(), Some(2048)); + delete_message(&conn, "INBOX", 1).unwrap(); + assert_eq!(cached_storage_bytes(&conn).unwrap(), Some(1024)); + } + + #[test] + fn test_parse_date_to_epoch() { + let e1 = parse_date_to_epoch("2024-01-01T10:00:00Z"); + let e2 = parse_date_to_epoch("2024-01-02T10:00:00Z"); + let e3 = parse_date_to_epoch("2024-01-03T10:00:00Z"); + assert!(e1 > 0, "epoch for date 1 should be > 0, got {e1}"); + assert!(e2 > e1, "date 2 ({e2}) should be after date 1 ({e1})"); + assert!(e3 > e2, "date 3 ({e3}) should be after date 2 ({e2})"); + + // RFC2822 format. + let e4 = parse_date_to_epoch("Mon, 1 Jan 2024 10:00:00 +0000"); + assert!(e4 > 0, "epoch for rfc2822 date should be > 0, got {e4}"); + assert_eq!(e1, e4, "ISO and RFC2822 should produce same epoch"); + + // Empty returns 0. + assert_eq!(parse_date_to_epoch(""), 0); + } + + #[test] + fn test_upsert_and_get_messages() { + let conn = open_test_db(); + ensure_folder(&conn, "INBOX"); + + insert_sample(&conn, "INBOX", 1, "2024-01-01T10:00:00Z"); + insert_sample(&conn, "INBOX", 2, "2024-01-02T10:00:00Z"); + insert_sample(&conn, "INBOX", 3, "2024-01-03T10:00:00Z"); + + let msgs = get_messages(&conn, "INBOX", 0, 10).unwrap(); + assert_eq!(msgs.len(), 3); + + // Should be date DESC: uid 3, 2, 1. + assert_eq!(msgs[0].uid, 3); + assert_eq!(msgs[1].uid, 2); + assert_eq!(msgs[2].uid, 1); + } + + #[test] + fn test_pagination_no_overlap() { + let conn = open_test_db(); + ensure_folder(&conn, "INBOX"); + + for uid in 1..=5 { + insert_sample(&conn, "INBOX", uid, &format!("2024-01-{:02}T10:00:00Z", uid)); + } + + let page0 = get_messages(&conn, "INBOX", 0, 2).unwrap(); + let page1 = get_messages(&conn, "INBOX", 1, 2).unwrap(); + let page2 = get_messages(&conn, "INBOX", 2, 2).unwrap(); + + assert_eq!(page0.len(), 2); + assert_eq!(page1.len(), 2); + assert_eq!(page2.len(), 1); + + // Verify no UIDs overlap between pages. + let uids0: Vec = page0.iter().map(|m| m.uid).collect(); + let uids1: Vec = page1.iter().map(|m| m.uid).collect(); + let uids2: Vec = page2.iter().map(|m| m.uid).collect(); + + for uid in &uids0 { + assert!(!uids1.contains(uid)); + assert!(!uids2.contains(uid)); + } + for uid in &uids1 { + assert!(!uids2.contains(uid)); + } + } + + #[test] + fn test_count_messages() { + let conn = open_test_db(); + ensure_folder(&conn, "INBOX"); + + assert_eq!(count_messages(&conn, "INBOX").unwrap(), 0); + + insert_sample(&conn, "INBOX", 1, "2024-01-01T10:00:00Z"); + insert_sample(&conn, "INBOX", 2, "2024-01-02T10:00:00Z"); + + assert_eq!(count_messages(&conn, "INBOX").unwrap(), 2); + } + + #[test] + fn test_update_message_flags() { + let conn = open_test_db(); + ensure_folder(&conn, "INBOX"); + insert_sample(&conn, "INBOX", 1, "2024-01-01T10:00:00Z"); + + update_message_flags(&conn, "INBOX", 1, "\\Seen,\\Flagged").unwrap(); + + let msgs = get_messages(&conn, "INBOX", 0, 10).unwrap(); + assert_eq!(msgs[0].flags, "\\Seen,\\Flagged"); + } + + #[test] + fn test_cache_and_get_body_initially_none() { + let conn = open_test_db(); + ensure_folder(&conn, "INBOX"); + insert_sample(&conn, "INBOX", 1, "2024-01-01T10:00:00Z"); + + // Body should not be cached yet. + let body = get_cached_body(&conn, "INBOX", 1).unwrap(); + assert!(body.is_none()); + } + + #[test] + fn test_cache_and_get_body_after_caching() { + let conn = open_test_db(); + ensure_folder(&conn, "INBOX"); + insert_sample(&conn, "INBOX", 1, "2024-01-01T10:00:00Z"); + + cache_message_body( + &conn, "INBOX", 1, + Some("

Hello

"), Some("Hello"), + Some(r#"[{"id":"0","filename":"test.pdf","content_type":"application/pdf","size":1024,"content_id":null}]"#), + Some("From: alice@example.com"), + Some(0), + ) + .unwrap(); + + let body = get_cached_body(&conn, "INBOX", 1).unwrap(); + assert!(body.is_some()); + let cached = body.unwrap(); + assert_eq!(cached.html.unwrap(), "

Hello

"); + assert_eq!(cached.text.unwrap(), "Hello"); + assert!(cached.attachments_json.unwrap().contains("test.pdf")); + assert_eq!(cached.raw_headers.unwrap(), "From: alice@example.com"); + assert_eq!(cached.email_theme, Some(0)); + } + + #[test] + fn test_update_email_theme() { + let conn = open_test_db(); + ensure_folder(&conn, "INBOX"); + insert_sample(&conn, "INBOX", 1, "2024-01-01T10:00:00Z"); + + update_email_theme(&conn, "INBOX", 1, 2).unwrap(); + + let body = get_cached_body(&conn, "INBOX", 1).unwrap(); + assert!(body.is_none()); + + cache_message_body( + &conn, "INBOX", 1, + Some("

Test

"), Some("Test"), + None, None, Some(0), + ).unwrap(); + + update_email_theme(&conn, "INBOX", 1, 1).unwrap(); + + let cached = get_cached_body(&conn, "INBOX", 1).unwrap().unwrap(); + assert_eq!(cached.email_theme, Some(1)); + } + + #[test] + fn test_delete_message() { + let conn = open_test_db(); + ensure_folder(&conn, "INBOX"); + insert_sample(&conn, "INBOX", 1, "2024-01-01T10:00:00Z"); + insert_sample(&conn, "INBOX", 2, "2024-01-02T10:00:00Z"); + + delete_message(&conn, "INBOX", 1).unwrap(); + + let msgs = get_messages(&conn, "INBOX", 0, 10).unwrap(); + assert_eq!(msgs.len(), 1); + assert_eq!(msgs[0].uid, 2); + } + + #[test] + fn test_get_thread_messages_by_message_id() { + let conn = open_test_db(); + ensure_folder(&conn, "INBOX"); + + // Original message. + upsert_message( + &conn, "INBOX", 1, + Some(""), None, None, + "Hello", "alice@example.com", "Alice", "[]", "[]", + "2024-01-01T10:00:00Z", "", 100, false, "", None, + ).unwrap(); + + // Reply referencing original via in_reply_to. + upsert_message( + &conn, "INBOX", 2, + Some(""), + Some(""), + None, + "Re: Hello", "bob@example.com", "Bob", "[]", "[]", + "2024-01-02T10:00:00Z", "", 200, false, "", None, + ).unwrap(); + + let thread = get_thread_messages(&conn, "").unwrap(); + assert_eq!(thread.len(), 2); + // ASC order: uid 1 first, uid 2 second. + assert_eq!(thread[0].uid, 1); + assert_eq!(thread[1].uid, 2); + } + + #[test] + fn test_get_thread_messages_by_references_header() { + let conn = open_test_db(); + ensure_folder(&conn, "INBOX"); + + // Original message. + upsert_message( + &conn, "INBOX", 1, + Some(""), None, None, + "Hello", "alice@example.com", "Alice", "[]", "[]", + "2024-01-01T10:00:00Z", "", 100, false, "", None, + ).unwrap(); + + // A message that references the original only via references_header. + upsert_message( + &conn, "INBOX", 3, + Some(""), + Some(""), + Some(" "), + "Re: Re: Hello", "carol@example.com", "Carol", "[]", "[]", + "2024-01-03T10:00:00Z", "", 300, false, "", None, + ).unwrap(); + + let thread = get_thread_messages(&conn, "").unwrap(); + assert_eq!(thread.len(), 2); + assert_eq!(thread[0].uid, 1); // matched by message_id + assert_eq!(thread[1].uid, 3); // matched by references_header LIKE + } + + #[test] + fn test_get_thread_messages_does_not_match_partial_reference_id() { + let conn = open_test_db(); + ensure_folder(&conn, "INBOX"); + + upsert_message( + &conn, "INBOX", 1, + Some(""), None, None, + "Original", "alice@example.com", "Alice", "[]", "[]", + "2024-01-01T10:00:00Z", "", 100, false, "", None, + ).unwrap(); + upsert_message( + &conn, "INBOX", 2, + Some(""), None, Some(""), + "Unrelated", "bob@example.com", "Bob", "[]", "[]", + "2024-01-02T10:00:00Z", "", 100, false, "", None, + ).unwrap(); + + let thread = get_thread_messages(&conn, "").unwrap(); + assert_eq!(thread.len(), 1); + assert_eq!(thread[0].uid, 1); + } + + #[test] + fn test_get_full_thread_walks_references_chain() { + let conn = open_test_db(); + ensure_folder(&conn, "INBOX"); + + // Message 1: original + upsert_message(&conn, "INBOX", 1, Some(""), None, None, + "Hello", "alice@ex", "Alice", "[]", "[]", "2024-01-01T10:00:00Z", "", 100, false, "", None).unwrap(); + // Message 2: reply to 1 + upsert_message(&conn, "INBOX", 2, Some(""), Some(""), Some(""), + "Re: Hello", "bob@ex", "Bob", "[]", "[]", "2024-01-02T10:00:00Z", "", 100, false, "", None).unwrap(); + // Message 3: reply to 2, references both + upsert_message(&conn, "INBOX", 3, Some(""), Some(""), Some(" "), + "Re: Re: Hello", "carol@ex", "Carol", "[]", "[]", "2024-01-03T10:00:00Z", "", 100, false, "", None).unwrap(); + + // Starting from message 2, should find the entire thread (1, 2, 3). + let thread = get_full_thread(&conn, "", Some("")).unwrap(); + assert_eq!(thread.len(), 3); + assert_eq!(thread[0].uid, 1); + assert_eq!(thread[1].uid, 2); + assert_eq!(thread[2].uid, 3); + } + + #[test] + fn test_get_full_thread_from_leaf_message() { + let conn = open_test_db(); + ensure_folder(&conn, "INBOX"); + + upsert_message(&conn, "INBOX", 1, Some(""), None, None, + "Start", "alice@ex", "Alice", "[]", "[]", "2024-01-01T10:00:00Z", "", 100, false, "", None).unwrap(); + upsert_message(&conn, "INBOX", 2, Some(""), Some(""), Some(""), + "Re: Start", "bob@ex", "Bob", "[]", "[]", "2024-01-02T10:00:00Z", "", 100, false, "", None).unwrap(); + upsert_message(&conn, "INBOX", 3, Some(""), Some(""), Some(" "), + "Re: Re: Start", "carol@ex", "Carol", "[]", "[]", "2024-01-03T10:00:00Z", "", 100, false, "", None).unwrap(); + + // Starting from the leaf message, should still find entire thread. + let thread = get_full_thread(&conn, "", Some(" ")).unwrap(); + assert_eq!(thread.len(), 3); + } + + #[test] + fn test_get_full_thread_single_message() { + let conn = open_test_db(); + ensure_folder(&conn, "INBOX"); + + upsert_message(&conn, "INBOX", 1, Some(""), None, None, + "Solo", "alice@ex", "Alice", "[]", "[]", "2024-01-01T10:00:00Z", "", 100, false, "", None).unwrap(); + + let thread = get_full_thread(&conn, "", None).unwrap(); + assert_eq!(thread.len(), 1); + assert_eq!(thread[0].uid, 1); + } +} + +/// Cached storage is an estimate while folders are still being synchronized. +/// None distinguishes an empty cache from cached zero-byte messages. +pub fn cached_storage_bytes(conn: &Connection) -> Result, String> { + conn.query_row("SELECT SUM(size) FROM messages", [], |row| row.get(0)) + .map_err(|e| e.to_string()) +} diff --git a/src/db/mod.rs b/src/db/mod.rs new file mode 100644 index 0000000..3af2ba4 --- /dev/null +++ b/src/db/mod.rs @@ -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; diff --git a/src/db/notification_preferences.rs b/src/db/notification_preferences.rs new file mode 100644 index 0000000..603e36d --- /dev/null +++ b/src/db/notification_preferences.rs @@ -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, + 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, + pub sound: Option, + pub folders: Option>, + pub dnd_enabled: Option, + pub dnd_start: Option, + pub dnd_end: Option, +} + +/// Retrieve the singleton notification preferences row. +/// Returns sensible defaults if the row does not yet exist. +pub fn get_preferences(conn: &Connection) -> Result { + let result = conn.query_row( + "SELECT enabled, sound, folders, dnd_enabled, dnd_start, dnd_end, updated_at FROM notification_preferences WHERE id = 1", + [], + |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 = 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 { + // 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> = 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::(), minute.parse::()), (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()]); + } +} diff --git a/src/db/pgp.rs b/src/db/pgp.rs new file mode 100644 index 0000000..ce5a12e --- /dev/null +++ b/src/db/pgp.rs @@ -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, +} + +#[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 { + 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, 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, 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, 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 { + 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 { + 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 { + 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 { + 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, 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, 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() +} diff --git a/src/db/pool.rs b/src/db/pool.rs new file mode 100644 index 0000000..a398f16 --- /dev/null +++ b/src/db/pool.rs @@ -0,0 +1,231 @@ +use std::fs; +use std::path::Path; + +use rusqlite::Connection; + +/// Owned database context for short blocking jobs. Never hold SQLite on a Tokio worker. +#[derive(Clone)] +pub(crate) struct UserDb { + data_dir: String, + user_hash: String, +} + +impl UserDb { + pub(crate) fn new(data_dir: &str, user_hash: &str) -> Self { + Self { + data_dir: data_dir.into(), + user_hash: user_hash.into(), + } + } + + pub(crate) async fn run( + &self, + work: impl FnOnce(&Connection) -> Result + Send + 'static, + ) -> Result { + let context = self.clone(); + tokio::task::spawn_blocking(move || { + let conn = open_user_db(&context.data_dir, &context.user_hash)?; + work(&conn) + }) + .await + .map_err(|e| format!("Database task failed: {e}"))? + } +} + +/// All migration scripts in order. Each entry is (version, sql). +const MIGRATIONS: &[(u32, &str)] = &[ + (1, include_str!("../../migrations/V001__initial_schema.sql")), + (2, include_str!("../../migrations/V002__folders_and_messages.sql")), + (3, include_str!("../../migrations/V003__add_date_epoch.sql")), + (4, include_str!("../../migrations/V004__folder_messages_updated_at.sql")), + (5, include_str!("../../migrations/V005__drafts_and_attachments.sql")), + (6, include_str!("../../migrations/V006__cache_attachments_and_headers.sql")), + (7, include_str!("../../migrations/V007__contacts.sql")), + (8, include_str!("../../migrations/V008__identities.sql")), + (9, include_str!("../../migrations/V009__notification_preferences.sql")), + (10, include_str!("../../migrations/V010__contact_groups.sql")), + (11, include_str!("../../migrations/V011__message_reaction.sql")), + (12, include_str!("../../migrations/V012__tags.sql")), + (13, include_str!("../../migrations/V013__display_preferences.sql")), + (14, include_str!("../../migrations/V014__compose_format.sql")), + (15, include_str!("../../migrations/V015__deep_index.sql")), + (16, include_str!("../../migrations/V016__calendar.sql")), + (17, include_str!("../../migrations/V017__add_email_theme.sql")), + (18, include_str!("../../migrations/V018__animation_mode.sql")), + (19, include_str!("../../migrations/V019__known_addresses.sql")), + (20, include_str!("../../migrations/V020__appearance_personalization.sql")), + (21, include_str!("../../migrations/V021__special_folders.sql")), + (22, include_str!("../../migrations/V022__identity_options.sql")), + (23, include_str!("../../migrations/V023__saved_searches.sql")), + (24, include_str!("../../migrations/V024__notification_dnd.sql")), + (25, include_str!("../../migrations/V025__custom_theme_colors.sql")), + (26, include_str!("../../migrations/V026__mail_preferences.sql")), + (27, include_str!("../../migrations/V027__contact_profile.sql")), + (28, include_str!("../../migrations/V028__contact_multi_values.sql")), + (29, include_str!("../../migrations/V029__draft_send_options.sql")), + (30, include_str!("../../migrations/V030__folder_list_preferences.sql")), + (31, include_str!("../../migrations/V031__compose_preferences.sql")), + (32, include_str!("../../migrations/V032__advanced_appearance.sql")), + (33, include_str!("../../migrations/V033__draft_compose_format.sql")), + (34, include_str!("../../migrations/V034__imap_keyword_sync.sql")), + (35, include_str!("../../migrations/V035__compose_behavior_preferences.sql")), + (36, include_str!("../../migrations/V036__remote_draft_tracking.sql")), + (37, include_str!("../../migrations/V037__openpgp_keyring.sql")), + (38, include_str!("../../migrations/V038__identity_openpgp.sql")), + (39, include_str!("../../migrations/V039__draft_openpgp_options.sql")), + (40, include_str!("../../migrations/V040__draft_openpgp_attach_key.sql")), + (41, include_str!("../../migrations/V041__draft_openpgp_recipient_keys.sql")), +]; + +/// Run any pending migrations based on SQLite's `user_version` PRAGMA. +/// +/// If refinery has previously run migrations (indicated by the presence of +/// `refinery_schema_history`), we sync `user_version` to match the highest +/// refinery version before checking for new migrations. This avoids re-running +/// migrations that refinery already applied. +fn run_migrations(conn: &Connection) -> Result<(), String> { + let mut current: u32 = conn + .query_row("PRAGMA user_version", [], |row| row.get(0)) + .map_err(|e| format!("Failed to read user_version: {e}"))?; + + // Sync with refinery's migration state so we never re-apply migrations + // that refinery already applied (refinery updates its own history table + // but doesn't touch the user_version pragma). + let refinery_max: Option = conn + .query_row( + "SELECT MAX(version) FROM refinery_schema_history", + [], + |row| row.get(0), + ) + .unwrap_or(None); + + if let Some(v) = refinery_max + && v > current + { + current = v; + conn.execute_batch(&format!("PRAGMA user_version = {current};")) + .map_err(|e| format!("Failed to sync user_version from refinery: {e}"))?; + } + + for &(version, sql) in MIGRATIONS { + if version > current { + conn.execute_batch(sql) + .map_err(|e| format!("Migration V{version:03} failed: {e}"))?; + conn.execute_batch(&format!("PRAGMA user_version = {version};")) + .map_err(|e| format!("Failed to set user_version to {version}: {e}"))?; + } + } + + Ok(()) +} + +/// Opens the per-user SQLite database at `{data_dir}/{user_hash}/db.sqlite`. +/// +/// Creates the directory tree if it doesn't exist, enables WAL journal mode +/// and foreign key enforcement, then runs any pending migrations. +pub fn open_user_db(data_dir: &str, user_hash: &str) -> Result { + let dir = Path::new(data_dir).join(user_hash); + fs::create_dir_all(&dir).map_err(|e| format!("Failed to create db dir: {e}"))?; + + let db_path = dir.join("db.sqlite"); + let conn = + Connection::open(&db_path).map_err(|e| format!("Failed to open SQLite: {e}"))?; + + // Enable WAL mode for better concurrent read performance. + conn.execute_batch("PRAGMA journal_mode=WAL;") + .map_err(|e| format!("Failed to set WAL mode: {e}"))?; + + // Enable foreign key constraint enforcement. + conn.execute_batch("PRAGMA foreign_keys=ON;") + .map_err(|e| format!("Failed to enable foreign keys: {e}"))?; + + // Run any pending schema migrations. + run_migrations(&conn)?; + + Ok(conn) +} + +/// Opens an in-memory SQLite database with all migration scripts applied. +/// Used exclusively by tests so every test starts with a clean, fully-migrated +/// schema without touching the filesystem. +#[cfg(test)] +pub fn open_test_db() -> Connection { + let conn = Connection::open_in_memory().expect("Failed to open in-memory SQLite"); + + conn.execute_batch("PRAGMA foreign_keys=ON;") + .expect("Failed to enable foreign keys"); + + run_migrations(&conn).expect("Test migrations failed"); + + conn +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_open_test_db_has_tables() { + let conn = open_test_db(); + + // Verify the three expected tables exist. + let tables: Vec = conn + .prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name") + .unwrap() + .query_map([], |row| row.get(0)) + .unwrap() + .filter_map(|r| r.ok()) + .collect(); + + assert!(tables.contains(&"user_meta".to_string())); + assert!(tables.contains(&"folders".to_string())); + assert!(tables.contains(&"messages".to_string())); + assert!(tables.contains(&"drafts".to_string())); + assert!(tables.contains(&"draft_attachments".to_string())); + } + + #[test] + fn test_open_user_db_creates_file() { + let tmp = tempfile::tempdir().unwrap(); + let data_dir = tmp.path().to_str().unwrap(); + + let conn = open_user_db(data_dir, "abc123").unwrap(); + + // The file should exist on disk. + let db_file = tmp.path().join("abc123").join("db.sqlite"); + assert!(db_file.exists()); + + // Foreign keys should be enabled. + let fk: i32 = conn + .query_row("PRAGMA foreign_keys", [], |row| row.get(0)) + .unwrap(); + assert_eq!(fk, 1); + + drop(conn); + } +} + +#[cfg(test)] +mod async_tests { + use super::*; + + #[tokio::test(flavor = "current_thread")] + async fn database_jobs_leave_runtime_free() { + let directory = tempfile::tempdir().unwrap(); + let database = UserDb::new(directory.path().to_str().unwrap(), "test"); + let (started, ready) = tokio::sync::oneshot::channel(); + let (release, wait) = std::sync::mpsc::channel(); + let job = tokio::spawn(async move { + database.run(move |_| { + started.send(()).unwrap(); + wait.recv_timeout(std::time::Duration::from_secs(2)) + .map_err(|e| format!("runtime could not release database job: {e}"))?; + Ok(()) + }).await + }); + ready.await.unwrap(); + // On a current-thread runtime, an inline DB job prevents this send. + release.send(()).unwrap(); + job.await.unwrap().unwrap(); + } +} diff --git a/src/db/saved_searches.rs b/src/db/saved_searches.rs new file mode 100644 index 0000000..49821f5 --- /dev/null +++ b/src/db/saved_searches.rs @@ -0,0 +1,78 @@ +use rusqlite::{params, Connection}; +use serde::Serialize; + +#[derive(Debug, Clone, Serialize)] +pub struct SavedSearch { + pub id: i64, + pub name: String, + pub query_string: String, + pub created_at: String, + pub updated_at: String, +} + +fn row_to_saved(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(SavedSearch { + id: row.get(0)?, + name: row.get(1)?, + query_string: row.get(2)?, + created_at: row.get(3)?, + updated_at: row.get(4)?, + }) +} + +pub fn list(conn: &Connection) -> Result, String> { + let mut stmt = conn + .prepare("SELECT id,name,query_string,created_at,updated_at FROM saved_searches ORDER BY name COLLATE NOCASE") + .map_err(|e| format!("Failed to prepare saved searches: {e}"))?; + let rows = stmt + .query_map([], row_to_saved) + .map_err(|e| format!("Failed to query saved searches: {e}"))?; + rows.map(|row| row.map_err(|e| format!("Failed to read saved search: {e}"))) + .collect() +} + +pub fn create(conn: &Connection, name: &str, query_string: &str) -> Result { + let name = name.trim(); + let query_string = query_string.trim(); + if name.is_empty() || query_string.is_empty() { + return Err("Saved search name and query are required".to_string()); + } + conn.execute( + "INSERT INTO saved_searches(name,query_string) VALUES (?1,?2)", + params![name, query_string], + ) + .map_err(|e| format!("Failed to save search: {e}"))?; + let id = conn.last_insert_rowid(); + get(conn, id)?.ok_or_else(|| "Failed to read saved search".to_string()) +} + +pub fn update(conn: &Connection, id: i64, name: &str, query_string: &str) -> Result, String> { + let name = name.trim(); + let query_string = query_string.trim(); + if name.is_empty() || query_string.is_empty() { + return Err("Saved search name and query are required".to_string()); + } + let changed = conn.execute( + "UPDATE saved_searches SET name=?1,query_string=?2,updated_at=datetime('now') WHERE id=?3", + params![name, query_string, id], + ).map_err(|e| format!("Failed to update saved search: {e}"))?; + if changed == 0 { return Ok(None); } + get(conn, id) +} + +pub fn get(conn: &Connection, id: i64) -> Result, String> { + match conn.query_row( + "SELECT id,name,query_string,created_at,updated_at FROM saved_searches WHERE id=?1", + params![id], + row_to_saved, + ) { + Ok(value) => Ok(Some(value)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(format!("Failed to get saved search: {e}")), + } +} + +pub fn delete(conn: &Connection, id: i64) -> Result { + Ok(conn.execute("DELETE FROM saved_searches WHERE id=?1", params![id]) + .map_err(|e| format!("Failed to delete saved search: {e}"))? > 0) +} diff --git a/src/db/special_folders.rs b/src/db/special_folders.rs new file mode 100644 index 0000000..1ddb697 --- /dev/null +++ b/src/db/special_folders.rs @@ -0,0 +1,125 @@ +use rusqlite::{params, Connection, OptionalExtension}; +use serde::{Deserialize, Serialize}; + +const ROLES: &[&str] = &["sent", "drafts", "trash", "junk", "archive"]; + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct SpecialFolders { + pub sent: Option, + pub drafts: Option, + pub trash: Option, + pub junk: Option, + pub archive: Option, +} + +fn valid_role(role: &str) -> bool { + ROLES.contains(&role) +} + +pub fn get_role(conn: &Connection, role: &str) -> Result, String> { + if !valid_role(role) { + return Err(format!("Invalid special folder role: {role}")); + } + conn.query_row( + "SELECT folder_name FROM special_folders WHERE role = ?1", + [role], + |row| row.get(0), + ) + .optional() + .map_err(|e| format!("Failed to read special folder {role}: {e}")) +} + +pub fn get_all(conn: &Connection) -> Result { + Ok(SpecialFolders { + sent: get_role(conn, "sent")?, + drafts: get_role(conn, "drafts")?, + trash: get_role(conn, "trash")?, + junk: get_role(conn, "junk")?, + archive: get_role(conn, "archive")?, + }) +} + +pub fn set_role(conn: &Connection, role: &str, folder_name: Option<&str>) -> Result<(), String> { + if !valid_role(role) { + return Err(format!("Invalid special folder role: {role}")); + } + match folder_name.map(str::trim).filter(|value| !value.is_empty()) { + Some(folder) => { + let exists: bool = conn + .query_row( + "SELECT EXISTS(SELECT 1 FROM folders WHERE name = ?1)", + [folder], + |row| row.get(0), + ) + .map_err(|e| format!("Failed to validate folder {folder}: {e}"))?; + if !exists { + return Err(format!("Folder does not exist: {folder}")); + } + conn.execute( + "INSERT INTO special_folders(role, folder_name, updated_at) VALUES(?1, ?2, datetime('now'))\n ON CONFLICT(role) DO UPDATE SET folder_name = excluded.folder_name, updated_at = datetime('now')", + params![role, folder], + ) + .map_err(|e| format!("Failed to set special folder {role}: {e}"))?; + } + None => { + conn.execute("DELETE FROM special_folders WHERE role = ?1", [role]) + .map_err(|e| format!("Failed to clear special folder {role}: {e}"))?; + } + } + Ok(()) +} + +pub fn update_all(conn: &Connection, folders: &SpecialFolders) -> Result { + set_role(conn, "sent", folders.sent.as_deref())?; + set_role(conn, "drafts", folders.drafts.as_deref())?; + set_role(conn, "trash", folders.trash.as_deref())?; + set_role(conn, "junk", folders.junk.as_deref())?; + set_role(conn, "archive", folders.archive.as_deref())?; + get_all(conn) +} + +pub fn rename_folder_reference(conn: &Connection, old_name: &str, new_name: &str) -> Result<(), String> { + conn.execute( + "UPDATE special_folders SET folder_name = ?1, updated_at = datetime('now') WHERE folder_name = ?2", + params![new_name, old_name], + ) + .map_err(|e| format!("Failed to update special folder reference: {e}"))?; + Ok(()) +} + +pub fn clear_folder_reference(conn: &Connection, folder_name: &str) -> Result<(), String> { + conn.execute("DELETE FROM special_folders WHERE folder_name = ?1", [folder_name]) + .map_err(|e| format!("Failed to clear special folder reference: {e}"))?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::folders; + use crate::db::pool::open_test_db; + + #[test] + fn special_folders_roundtrip() { + let conn = open_test_db(); + for name in ["Sent Items", "Drafts", "Deleted", "Junk", "Archive"] { + folders::insert_folder_if_new(&conn, name, Some("/"), "").unwrap(); + } + let value = SpecialFolders { + sent: Some("Sent Items".into()), + drafts: Some("Drafts".into()), + trash: Some("Deleted".into()), + junk: Some("Junk".into()), + archive: Some("Archive".into()), + }; + let saved = update_all(&conn, &value).unwrap(); + assert_eq!(saved.sent.as_deref(), Some("Sent Items")); + assert_eq!(saved.trash.as_deref(), Some("Deleted")); + } + + #[test] + fn rejects_unknown_folder() { + let conn = open_test_db(); + assert!(set_role(&conn, "sent", Some("Missing")).is_err()); + } +} diff --git a/src/db/tags.rs b/src/db/tags.rs new file mode 100644 index 0000000..59091a0 --- /dev/null +++ b/src/db/tags.rs @@ -0,0 +1,501 @@ +use std::collections::HashMap; + +use rusqlite::{params, Connection}; +use serde::Serialize; + +use crate::db::messages::CachedMessage; + +/// A tag record, mirroring the `tags` table. +#[derive(Debug, Clone, Serialize)] +pub struct Tag { + pub id: String, + pub name: String, + pub color: String, + pub imap_keyword: String, + pub message_count: i64, + pub created_at: String, + pub updated_at: String, +} + +/// Lightweight tag info returned alongside messages. +#[derive(Debug, Clone, Serialize)] +pub struct MessageTag { + pub id: String, + pub name: String, + pub color: String, +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/// Create a new tag. +pub fn imap_keyword_for_tag_id(id: &str) -> String { + format!("RustBox-{}", id.chars().filter(|ch| *ch != '-').collect::()) +} + +pub fn create_tag(conn: &Connection, id: &str, name: &str, color: &str) -> Result<(), String> { + let imap_keyword = imap_keyword_for_tag_id(id); + conn.execute( + "INSERT INTO tags (id, name, color, imap_keyword) VALUES (?1, ?2, ?3, ?4)", + params![id, name, color, imap_keyword], + ) + .map_err(|e| format!("Failed to create tag: {e}"))?; + Ok(()) +} + +/// List all tags with their message counts. +pub fn list_tags(conn: &Connection) -> Result, String> { + let mut stmt = conn + .prepare( + "SELECT t.id, t.name, t.color, t.imap_keyword, t.created_at, t.updated_at, + (SELECT COUNT(*) FROM message_tags mt WHERE mt.tag_id = t.id) AS message_count + FROM tags t + ORDER BY t.name ASC", + ) + .map_err(|e| format!("Failed to prepare list_tags: {e}"))?; + + let rows = stmt + .query_map([], |row| { + Ok(Tag { + id: row.get(0)?, + name: row.get(1)?, + color: row.get(2)?, + imap_keyword: row.get(3)?, + created_at: row.get(4)?, + updated_at: row.get(5)?, + message_count: row.get(6)?, + }) + }) + .map_err(|e| format!("Failed to query tags: {e}"))?; + + let mut tags = Vec::new(); + for row in rows { + tags.push(row.map_err(|e| format!("Failed to read tag row: {e}"))?); + } + Ok(tags) +} + +pub fn get_tag(conn: &Connection, id: &str) -> Result, String> { + let mut tags = list_tags(conn)?; + Ok(tags.drain(..).find(|tag| tag.id == id)) +} + +pub fn get_message_refs_by_tag(conn: &Connection, tag_id: &str) -> Result, String> { + let mut stmt = conn.prepare("SELECT message_uid, message_folder FROM message_tags WHERE tag_id = ?1") + .map_err(|e| format!("Failed to prepare tag message refs: {e}"))?; + let rows = stmt.query_map(params![tag_id], |row| Ok((row.get(0)?, row.get(1)?))) + .map_err(|e| format!("Failed to query tag message refs: {e}"))?; + let mut refs = Vec::new(); + for row in rows { refs.push(row.map_err(|e| format!("Failed to read tag message ref: {e}"))?); } + Ok(refs) +} + +/// Import RustBox-owned IMAP keyword flags into local message/tag relations. +/// This intentionally only adds remotely-present labels; absence is not treated as +/// deletion because some servers/folders do not expose persistent custom flags. +pub fn import_imap_keywords(conn: &Connection, uid: u32, folder: &str, flags: &[String]) -> Result { + let keyword_set: std::collections::HashSet<&str> = flags.iter().map(String::as_str).collect(); + if !keyword_set.iter().any(|flag| flag.starts_with("RustBox-")) { return Ok(0); } + let tags = list_tags(conn)?; + let mut imported = 0usize; + for tag in tags { + if !tag.imap_keyword.is_empty() && keyword_set.contains(tag.imap_keyword.as_str()) { + add_tag_to_message(conn, &tag.id, uid, folder)?; + imported += 1; + } + } + Ok(imported) +} + + +/// Reconcile RustBox-owned IMAP keyword flags for one message. +/// +/// This is intentionally stronger than `import_imap_keywords`: callers must +/// use it only after confirming that the selected mailbox supports persistent +/// custom keywords and that `flags` is the complete authoritative FLAGS set. +/// Remotely present RustBox keywords are added locally and remotely absent +/// RustBox keywords are removed locally. +pub fn reconcile_imap_keywords( + conn: &Connection, + uid: u32, + folder: &str, + flags: &[String], +) -> Result<(usize, usize), String> { + let keyword_set: std::collections::HashSet<&str> = flags.iter().map(String::as_str).collect(); + let tags = list_tags(conn)?; + let existing: std::collections::HashSet = get_message_tags(conn, uid, folder)? + .into_iter() + .map(|tag| tag.id) + .collect(); + + let mut added = 0usize; + let mut removed = 0usize; + for tag in tags { + if tag.imap_keyword.is_empty() || !tag.imap_keyword.starts_with("RustBox-") { + continue; + } + let remote_has = keyword_set.contains(tag.imap_keyword.as_str()); + let local_has = existing.contains(&tag.id); + match (remote_has, local_has) { + (true, false) => { + add_tag_to_message(conn, &tag.id, uid, folder)?; + added += 1; + } + (false, true) => { + if remove_tag_from_message(conn, &tag.id, uid, folder)? { + removed += 1; + } + } + _ => {} + } + } + Ok((added, removed)) +} + +/// Update a tag's name and/or color. +pub fn update_tag(conn: &Connection, id: &str, name: &str, color: &str) -> Result { + let updated = conn + .execute( + "UPDATE tags SET name = ?1, color = ?2, updated_at = datetime('now') WHERE id = ?3", + params![name, color, id], + ) + .map_err(|e| format!("Failed to update tag: {e}"))?; + Ok(updated > 0) +} + +/// Delete a tag. CASCADE handles junction rows. +pub fn delete_tag(conn: &Connection, id: &str) -> Result { + let deleted = conn + .execute("DELETE FROM tags WHERE id = ?1", params![id]) + .map_err(|e| format!("Failed to delete tag: {e}"))?; + Ok(deleted > 0) +} + +/// Add a tag to a message (INSERT OR IGNORE to avoid duplicates). +pub fn add_tag_to_message( + conn: &Connection, + tag_id: &str, + uid: u32, + folder: &str, +) -> Result<(), String> { + conn.execute( + "INSERT OR IGNORE INTO message_tags (tag_id, message_uid, message_folder) VALUES (?1, ?2, ?3)", + params![tag_id, uid, folder], + ) + .map_err(|e| format!("Failed to add tag to message: {e}"))?; + Ok(()) +} + +/// Remove a tag from a message. +pub fn remove_tag_from_message( + conn: &Connection, + tag_id: &str, + uid: u32, + folder: &str, +) -> Result { + let deleted = conn + .execute( + "DELETE FROM message_tags WHERE tag_id = ?1 AND message_uid = ?2 AND message_folder = ?3", + params![tag_id, uid, folder], + ) + .map_err(|e| format!("Failed to remove tag from message: {e}"))?; + Ok(deleted > 0) +} + +/// Get all tags for a specific message. +pub fn get_message_tags(conn: &Connection, uid: u32, folder: &str) -> Result, String> { + let mut stmt = conn + .prepare( + "SELECT t.id, t.name, t.color + FROM tags t + JOIN message_tags mt ON mt.tag_id = t.id + WHERE mt.message_uid = ?1 AND mt.message_folder = ?2 + ORDER BY t.name ASC", + ) + .map_err(|e| format!("Failed to prepare get_message_tags: {e}"))?; + + let rows = stmt + .query_map(params![uid, folder], |row| { + Ok(MessageTag { + id: row.get(0)?, + name: row.get(1)?, + color: row.get(2)?, + }) + }) + .map_err(|e| format!("Failed to query message tags: {e}"))?; + + let mut tags = Vec::new(); + for row in rows { + tags.push(row.map_err(|e| format!("Failed to read message tag row: {e}"))?); + } + Ok(tags) +} + +/// Batch-fetch tags for multiple messages (efficient for list view). +pub fn get_tags_for_messages( + conn: &Connection, + messages: &[(u32, &str)], +) -> Result>, String> { + if messages.is_empty() { + return Ok(HashMap::new()); + } + + // Build a query with placeholders for all message refs. + // Using a single query with OR conditions for reasonable batch sizes. + let conditions: Vec = messages + .iter() + .enumerate() + .map(|(i, _)| format!("(mt.message_uid = ?{} AND mt.message_folder = ?{})", i * 2 + 1, i * 2 + 2)) + .collect(); + + let sql = format!( + "SELECT mt.message_uid, mt.message_folder, t.id, t.name, t.color + FROM message_tags mt + JOIN tags t ON t.id = mt.tag_id + WHERE {} + ORDER BY t.name ASC", + conditions.join(" OR ") + ); + + let mut stmt = conn + .prepare(&sql) + .map_err(|e| format!("Failed to prepare get_tags_for_messages: {e}"))?; + + let mut param_values: Vec> = Vec::new(); + for &(uid, folder) in messages { + param_values.push(Box::new(uid)); + param_values.push(Box::new(folder.to_string())); + } + let params_ref: Vec<&dyn rusqlite::types::ToSql> = param_values.iter().map(|p| p.as_ref()).collect(); + + let rows = stmt + .query_map(params_ref.as_slice(), |row| { + let uid: u32 = row.get(0)?; + let folder: String = row.get(1)?; + let tag = MessageTag { + id: row.get(2)?, + name: row.get(3)?, + color: row.get(4)?, + }; + Ok((uid, folder, tag)) + }) + .map_err(|e| format!("Failed to query tags for messages: {e}"))?; + + let mut map: HashMap<(u32, String), Vec> = HashMap::new(); + for row in rows { + let (uid, folder, tag) = row.map_err(|e| format!("Failed to read tags row: {e}"))?; + map.entry((uid, folder)).or_default().push(tag); + } + Ok(map) +} + +/// Get messages by tag (paginated), ordered by date descending. +pub fn get_messages_by_tag( + conn: &Connection, + tag_id: &str, + page: u32, + per_page: u32, +) -> Result, String> { + let offset = page * per_page; + let mut stmt = conn + .prepare( + "SELECT m.uid, m.folder, m.message_id, m.in_reply_to, m.references_header, + m.subject, m.from_address, m.from_name, m.to_addresses, m.cc_addresses, + m.date, m.flags, m.size, m.has_attachments, m.snippet, m.reaction + FROM messages m + JOIN message_tags mt ON mt.message_uid = m.uid AND mt.message_folder = m.folder + WHERE mt.tag_id = ?1 + ORDER BY m.date_epoch DESC + LIMIT ?2 OFFSET ?3", + ) + .map_err(|e| format!("Failed to prepare get_messages_by_tag: {e}"))?; + + let rows = stmt + .query_map(params![tag_id, per_page, offset], |row| { + let has_attachments_int: i32 = row.get(13)?; + Ok(CachedMessage { + uid: row.get(0)?, + folder: row.get(1)?, + message_id: row.get(2)?, + in_reply_to: row.get(3)?, + references_header: row.get(4)?, + subject: row.get(5)?, + from_address: row.get(6)?, + from_name: row.get(7)?, + to_addresses: row.get(8)?, + cc_addresses: row.get(9)?, + date: row.get(10)?, + flags: row.get(11)?, + size: row.get(12)?, + has_attachments: has_attachments_int != 0, + snippet: row.get(14)?, + reaction: row.get(15)?, + }) + }) + .map_err(|e| format!("Failed to query messages by tag: {e}"))?; + + let mut messages = Vec::new(); + for row in rows { + messages.push(row.map_err(|e| format!("Failed to read message row: {e}"))?); + } + Ok(messages) +} + +/// Count messages with a given tag. +pub fn count_messages_by_tag(conn: &Connection, tag_id: &str) -> Result { + conn.query_row( + "SELECT COUNT(*) FROM message_tags WHERE tag_id = ?1", + params![tag_id], + |row| row.get(0), + ) + .map_err(|e| format!("Failed to count messages by tag: {e}")) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::pool::open_test_db; + + fn insert_test_message(conn: &Connection, folder: &str, uid: u32) { + conn.execute( + "INSERT OR IGNORE INTO folders (name) VALUES (?1)", + params![folder], + ) + .unwrap(); + conn.execute( + "INSERT INTO messages (uid, folder, subject, from_address, from_name, to_addresses, date, flags, size, has_attachments, snippet) + VALUES (?1, ?2, 'Test', 'test@ex.com', 'Test', '[]', '2024-01-01', '', 100, 0, '')", + params![uid, folder], + ) + .unwrap(); + } + + #[test] + fn test_create_and_list_tags() { + let conn = open_test_db(); + create_tag(&conn, "t1", "Urgent", "#ef4444").unwrap(); + create_tag(&conn, "t2", "Project X", "#3b82f6").unwrap(); + + let tags = list_tags(&conn).unwrap(); + assert_eq!(tags.len(), 2); + assert_eq!(tags[0].name, "Project X"); + assert_eq!(tags[1].name, "Urgent"); + assert_eq!(tags[0].message_count, 0); + } + + #[test] + fn test_update_tag() { + let conn = open_test_db(); + create_tag(&conn, "t1", "Urgent", "#ef4444").unwrap(); + + let updated = update_tag(&conn, "t1", "Very Urgent", "#dc2626").unwrap(); + assert!(updated); + + let tags = list_tags(&conn).unwrap(); + assert_eq!(tags[0].name, "Very Urgent"); + assert_eq!(tags[0].color, "#dc2626"); + } + + #[test] + fn test_delete_tag_cascades() { + let conn = open_test_db(); + create_tag(&conn, "t1", "Urgent", "#ef4444").unwrap(); + insert_test_message(&conn, "INBOX", 1); + add_tag_to_message(&conn, "t1", 1, "INBOX").unwrap(); + + let deleted = delete_tag(&conn, "t1").unwrap(); + assert!(deleted); + + let tags = list_tags(&conn).unwrap(); + assert_eq!(tags.len(), 0); + } + + #[test] + fn test_add_and_get_message_tags() { + let conn = open_test_db(); + create_tag(&conn, "t1", "Urgent", "#ef4444").unwrap(); + create_tag(&conn, "t2", "Work", "#3b82f6").unwrap(); + insert_test_message(&conn, "INBOX", 1); + + add_tag_to_message(&conn, "t1", 1, "INBOX").unwrap(); + add_tag_to_message(&conn, "t2", 1, "INBOX").unwrap(); + // Duplicate should be ignored + add_tag_to_message(&conn, "t1", 1, "INBOX").unwrap(); + + let tags = get_message_tags(&conn, 1, "INBOX").unwrap(); + assert_eq!(tags.len(), 2); + + let tag_list = list_tags(&conn).unwrap(); + assert_eq!(tag_list.iter().find(|t| t.name == "Urgent").unwrap().message_count, 1); + } + + #[test] + fn test_remove_tag_from_message() { + let conn = open_test_db(); + create_tag(&conn, "t1", "Urgent", "#ef4444").unwrap(); + insert_test_message(&conn, "INBOX", 1); + add_tag_to_message(&conn, "t1", 1, "INBOX").unwrap(); + + let removed = remove_tag_from_message(&conn, "t1", 1, "INBOX").unwrap(); + assert!(removed); + + let tags = get_message_tags(&conn, 1, "INBOX").unwrap(); + assert_eq!(tags.len(), 0); + } + + #[test] + fn test_get_tags_for_messages_batch() { + let conn = open_test_db(); + create_tag(&conn, "t1", "Urgent", "#ef4444").unwrap(); + insert_test_message(&conn, "INBOX", 1); + insert_test_message(&conn, "INBOX", 2); + add_tag_to_message(&conn, "t1", 1, "INBOX").unwrap(); + + let refs: Vec<(u32, &str)> = vec![(1, "INBOX"), (2, "INBOX")]; + let map = get_tags_for_messages(&conn, &refs).unwrap(); + + assert_eq!(map.get(&(1, "INBOX".to_string())).unwrap().len(), 1); + assert!(map.get(&(2, "INBOX".to_string())).is_none()); + } + + #[test] + fn test_get_messages_by_tag() { + let conn = open_test_db(); + create_tag(&conn, "t1", "Urgent", "#ef4444").unwrap(); + insert_test_message(&conn, "INBOX", 1); + insert_test_message(&conn, "Sent", 2); + add_tag_to_message(&conn, "t1", 1, "INBOX").unwrap(); + add_tag_to_message(&conn, "t1", 2, "Sent").unwrap(); + + let messages = get_messages_by_tag(&conn, "t1", 0, 50).unwrap(); + assert_eq!(messages.len(), 2); + + let count = count_messages_by_tag(&conn, "t1").unwrap(); + assert_eq!(count, 2); + } + #[test] + fn test_reconcile_imap_keywords_adds_and_removes() { + let conn = open_test_db(); + create_tag(&conn, "t1", "One", "#ef4444").unwrap(); + create_tag(&conn, "t2", "Two", "#3b82f6").unwrap(); + insert_test_message(&conn, "INBOX", 7); + add_tag_to_message(&conn, "t1", 7, "INBOX").unwrap(); + + let keyword_two = imap_keyword_for_tag_id("t2"); + let (added, removed) = reconcile_imap_keywords( + &conn, + 7, + "INBOX", + &["\\Seen".to_string(), keyword_two], + ) + .unwrap(); + + assert_eq!(added, 1); + assert_eq!(removed, 1); + let tags = get_message_tags(&conn, 7, "INBOX").unwrap(); + assert_eq!(tags.len(), 1); + assert_eq!(tags[0].id, "t2"); + } + +} diff --git a/src/email_theme.rs b/src/email_theme.rs new file mode 100644 index 0000000..ab1aa7a --- /dev/null +++ b/src/email_theme.rs @@ -0,0 +1,898 @@ +use regex::Regex; +use rusqlite::types::{FromSql, FromSqlResult, ToSql, ToSqlOutput, ValueRef}; +use serde::{Deserialize, Serialize}; +use std::collections::HashSet; +use std::sync::LazyLock; + +static HEX_COLOR_RE: LazyLock = + LazyLock::new(|| Regex::new(r#"#[0-9a-fA-F]{3,8}\b"#).unwrap()); + +static RGB_COLOR_RE: LazyLock = + LazyLock::new(|| Regex::new(r#"rgb\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)"#).unwrap()); + +static RGBA_COLOR_RE: LazyLock = LazyLock::new(|| { + Regex::new(r#"rgba\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*([0-9]*\.?[0-9]+%?)\s*\)"#) + .unwrap() +}); + +static RGBA_MODERN_RE: LazyLock = LazyLock::new(|| { + Regex::new(r#"rgb\s*\(\s*(\d+)\s+(\d+)\s+(\d+)\s*/\s*([0-9]*\.?[0-9]+%?)\s*\)"#).unwrap() +}); + +static STYLE_TAG_RE: LazyLock = + LazyLock::new(|| Regex::new(r#"(?is)]*>(.*?)"#).unwrap()); + +static STYLE_ATTR_RE: LazyLock = + LazyLock::new(|| Regex::new(r#"(?i)style\s*=\s*(?:"([^"]+)"|'([^']+)')"#).unwrap()); + +static BGCOLOR_ATTR_RE: LazyLock = + LazyLock::new(|| Regex::new(r#"(?i)bgcolor\s*=\s*["']([^"']+)["']"#).unwrap()); + +static DARK_MEDIA_QUERY_RE: LazyLock = LazyLock::new(|| { + Regex::new(r#"(?is)@media[^{]*prefers-color-scheme\s*:\s*dark[^{]*\{(.+?\})\s*\}"#).unwrap() +}); + +static BODY_CSS_RE: LazyLock = + LazyLock::new(|| Regex::new(r#"(?i)body\s*\{([^}]+)\}"#).unwrap()); + +static CLASS_CSS_RE: LazyLock = + LazyLock::new(|| Regex::new(r#"(?i)\.[a-zA-Z][a-zA-Z0-9_-]*\s*\{([^}]+)\}"#).unwrap()); + +static ELEM_CSS_RE: LazyLock = LazyLock::new(|| { + Regex::new(r#"(?i)(body|table|td|div)\s*(?:,\s*#\w+)?\s*\{([^}]+)\}"#).unwrap() +}); + +static BG_PROP_RE: LazyLock = + LazyLock::new(|| Regex::new(r#"(?i)background(?:-color)?\s*:\s*([^;}"']+)"#).unwrap()); + +static BODY_TAG_RE: LazyLock = LazyLock::new(|| Regex::new(r#"]*>"#).unwrap()); + +static TABLE_TAG_RE: LazyLock = LazyLock::new(|| Regex::new(r#"]*>"#).unwrap()); + +static TD_TAG_RE: LazyLock = LazyLock::new(|| Regex::new(r#"]*>"#).unwrap()); + +static DIV_TAG_RE: LazyLock = LazyLock::new(|| Regex::new(r#"]*>"#).unwrap()); + +/// Compiled word-boundary regexes for each named CSS color, ensuring exact +/// matching so that e.g. "white" does not match inside "whitesmoke". +static NAMED_COLOR_REGEXES: LazyLock> = LazyLock::new(|| { + NAMED_COLORS + .iter() + .map(|(name, _)| { + let pattern = format!(r#"(?i)\b{name}\b"#); + (*name, Regex::new(&pattern).unwrap()) + }) + .collect() +}); + +/// Named CSS colors mapped to RGB values. +type NamedColorList = Vec<(&'static str, (u8, u8, u8))>; +static NAMED_COLORS: LazyLock = LazyLock::new(|| { + vec![ + ("black", (0, 0, 0)), + ("white", (255, 255, 255)), + ("red", (255, 0, 0)), + ("green", (0, 128, 0)), + ("blue", (0, 0, 255)), + ("yellow", (255, 255, 0)), + ("navy", (0, 0, 128)), + ("gray", (128, 128, 128)), + ("grey", (128, 128, 128)), + ("silver", (192, 192, 192)), + ("maroon", (128, 0, 0)), + ("purple", (128, 0, 128)), + ("teal", (0, 128, 128)), + ("olive", (128, 128, 0)), + ("aqua", (0, 255, 255)), + ("fuchsia", (255, 0, 255)), + ("lime", (0, 255, 0)), + ("darkgray", (169, 169, 169)), + ("darkgrey", (169, 169, 169)), + ("lightgray", (211, 211, 211)), + ("lightgrey", (211, 211, 211)), + ("whitesmoke", (245, 245, 245)), + ("ghostwhite", (248, 248, 255)), + ("floralwhite", (255, 250, 240)), + ("linen", (250, 240, 230)), + ("cornsilk", (255, 248, 220)), + ("gainsboro", (220, 220, 220)), + ] +}); + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum EmailTheme { + Light = 0, + Dark = 1, + Transparent = 2, + Adaptive = 3, +} + +impl EmailTheme { + pub fn as_i32(self) -> i32 { + self as i32 + } +} + +impl FromSql for EmailTheme { + fn column_result(value: ValueRef<'_>) -> FromSqlResult { + match value.as_i64()? { + 0 => Ok(EmailTheme::Light), + 1 => Ok(EmailTheme::Dark), + 2 => Ok(EmailTheme::Transparent), + 3 => Ok(EmailTheme::Adaptive), + _ => Err(rusqlite::types::FromSqlError::InvalidType), + } + } +} + +impl ToSql for EmailTheme { + fn to_sql(&self) -> rusqlite::Result> { + Ok(ToSqlOutput::from(*self as i32)) + } +} + +/// Detect whether an email's HTML is light, dark, transparent, or adaptive. +/// +/// Detection priority: +/// 1. If the email has `@media(prefers-color-scheme:dark)` rules with background +/// overrides, classify as Adaptive (it handles dark mode natively). +/// 2. Extract background colors from body, table, td, div elements (inline styles, +/// style tags, bgcolor attrs). Group by luminance (light vs dark). If one group +/// dominates (>= 50% of total weight), return that group's classification. +/// 3. If no backgrounds found, return Transparent. +pub fn detect_email_theme(html: &str) -> Option { + if html.trim().is_empty() { + return None; + } + + let decoded = decode_quoted_printable(html); + + // Check for @media(prefers-color-scheme:dark) with background overrides. + // These emails handle their own dark adaptation — classify as Adaptive + // so the frontend sets color-scheme and lets the email CSS handle it. + if has_dark_mode_media_query(&decoded) { + return Some(EmailTheme::Adaptive); + } + + let backgrounds = extract_background_colors(&decoded); + + if backgrounds.is_empty() { + return Some(EmailTheme::Transparent); + } + + classify_by_luminance_groups(&backgrounds) +} + +/// Check if the HTML has a @media(prefers-color-scheme:dark) block that sets +/// background colors, indicating the email handles dark mode natively. +fn has_dark_mode_media_query(html: &str) -> bool { + let lower = html.to_lowercase(); + for cap in DARK_MEDIA_QUERY_RE.captures_iter(&lower) { + let block = &cap[1]; + if block.contains("background") { + return true; + } + } + false +} + +/// Group all extracted colors into "light" and "dark" buckets by their +/// luminance, then check which bucket dominates. +fn classify_by_luminance_groups(colors: &[BackgroundColor]) -> Option { + let mut light_weight: f32 = 0.0; + let mut dark_weight: f32 = 0.0; + let mut total_weight: f32 = 0.0; + let mut has_body_background = false; + + for bg in colors { + let luminance = calculate_luminance(&bg.color); + total_weight += bg.element_weight; + if bg.element_weight >= 1.0 { + has_body_background = true; + } + if luminance < 0.5 { + dark_weight += bg.element_weight; + } else { + light_weight += bg.element_weight; + } + } + + if total_weight == 0.0 { + return Some(EmailTheme::Transparent); + } + + let light_coverage = light_weight / total_weight; + let dark_coverage = dark_weight / total_weight; + + // Require at least 50% of weighted colors to agree + if light_coverage >= 0.5 { + // If the only signal is from a wrapper table/td with white background + // and there's no body-level background, treat as transparent. + // A lone "white" on a wrapper element is usually just a reset, not + // intentional theming. + if !has_body_background && is_only_white_wrapper(colors) { + return Some(EmailTheme::Transparent); + } + Some(EmailTheme::Light) + } else if dark_coverage >= 0.5 { + // Guard against tiny accent elements being the only signal: + // if total weight is very low (only 1-2 low-weight elements like td), + // that's likely an accent, not a real background. + if total_weight <= 1.0 && colors.len() <= 2 && !has_body_background { + Some(EmailTheme::Transparent) + } else { + Some(EmailTheme::Dark) + } + } else { + Some(EmailTheme::Transparent) + } +} + +/// Check if all color signals are just white (#ffffff or named "white") on +/// wrapper elements (table or td) without any body-level background. This +/// usually indicates a boilerplate reset rather than intentional light theming. +fn is_only_white_wrapper(colors: &[BackgroundColor]) -> bool { + if colors.is_empty() { + return false; + } + for bg in colors { + // If there's a body-level background, it's intentional + if bg.element_weight >= 1.0 { + return false; + } + // Only consider pure white as a wrapper reset + let c = bg.color.trim().to_lowercase(); + if c != "#ffffff" && c != "white" { + return false; + } + } + true +} + +fn decode_quoted_printable(html: &str) -> String { + let mut result = html.to_string(); + result = result.replace("=3D", "="); + result = result.replace("=3d", "="); + result = result.replace("=20", " "); + result = result.replace("=\r\n", ""); + result = result.replace("=\n", ""); + result +} + +#[derive(Debug, Clone)] +struct BackgroundColor { + color: String, + element_weight: f32, +} + +fn extract_background_colors(html: &str) -> Vec { + let mut colors = Vec::new(); + + extract_inline_style_colors(html, &mut colors); + extract_style_tag_colors(html, &mut colors); + extract_bgcolor_attrs(html, &mut colors); + + // Deduplicate: if the same color appears on the same element from both + // bgcolor and style attributes, keep only the one with higher weight. + deduplicate_colors(&mut colors); + + colors +} + +/// Remove duplicate color entries that likely come from the same element +/// having both bgcolor="X" and style="background-color:X". +fn deduplicate_colors(colors: &mut Vec) { + if colors.len() <= 1 { + return; + } + + // Track seen (normalized_color, weight) pairs and remove exact duplicates + let mut seen: HashSet = HashSet::new(); + colors.retain(|bg| { + let key = format!("{}:{:.1}", bg.color, bg.element_weight); + seen.insert(key) + }); +} + +fn extract_inline_style_colors(html: &str, colors: &mut Vec) { + let element_weights: &[(&LazyLock, f32)] = &[ + (&BODY_TAG_RE, 1.0), + (&TABLE_TAG_RE, 0.8), + (&TD_TAG_RE, 0.6), + (&DIV_TAG_RE, 0.4), + ]; + + for (tag_re, weight) in element_weights { + for m in tag_re.find_iter(html) { + let tag_content = m.as_str(); + let extracted = extract_colors_from_style(tag_content); + for ec in extracted { + if ec.alpha < 0.1 { + continue; + } + if !is_transparent(&ec.color) { + let normalized = normalize_color(&ec.color); + if !normalized.is_empty() { + colors.push(BackgroundColor { + color: normalized, + element_weight: *weight * ec.alpha, + }); + } + } + } + } + } +} + +fn extract_style_tag_colors(html: &str, colors: &mut Vec) { + for cap in STYLE_TAG_RE.captures_iter(html) { + let style_content = &cap[1]; + + let lower = style_content.to_lowercase(); + if !lower.contains("background") { + continue; + } + + // Extract from body{...} rules + if let Some(body_cap) = BODY_CSS_RE.captures(style_content) { + let body_styles = &body_cap[1]; + if body_styles.to_lowercase().contains("background") { + extract_all_colors_from_css(body_styles, 1.0, colors); + } + } + + // Extract from class rules that mention background + for class_cap in CLASS_CSS_RE.captures_iter(style_content) { + let class_styles = &class_cap[1]; + if class_styles.to_lowercase().contains("background") { + extract_all_colors_from_css(class_styles, 0.3, colors); + } + } + + // Extract from element-level rules (td{...}, table{...}, etc.) + for elem_cap in ELEM_CSS_RE.captures_iter(style_content) { + let tag = elem_cap[1].to_lowercase(); + let elem_styles = &elem_cap[2]; + if elem_styles.to_lowercase().contains("background") { + let weight = match tag.as_str() { + "body" => 1.0, + "table" => 0.8, + "td" => 0.6, + "div" => 0.4, + _ => 0.3, + }; + extract_all_colors_from_css(elem_styles, weight, colors); + } + } + } +} + +/// Extract color values only from background/background-color properties in a CSS rule body. +fn extract_all_colors_from_css(css: &str, weight: f32, colors: &mut Vec) { + let lower = css.to_lowercase(); + + // Only extract colors from background/background-color property values, + // not from the entire CSS rule (which may contain color, border-color, etc.) + for bg_cap in BG_PROP_RE.captures_iter(&lower) { + let bg_value = bg_cap[1].trim(); + + for hex_cap in HEX_COLOR_RE.captures_iter(bg_value) { + let color = normalize_color(&hex_cap[0]); + if !is_transparent(&color) && !color.is_empty() { + colors.push(BackgroundColor { + color, + element_weight: weight, + }); + } + } + + for rgb_cap in RGB_COLOR_RE.captures_iter(bg_value) { + let color = format!("rgb({}, {}, {})", &rgb_cap[1], &rgb_cap[2], &rgb_cap[3]); + if !is_transparent(&color) { + colors.push(BackgroundColor { + color, + element_weight: weight, + }); + } + } + + for rgba_cap in RGBA_COLOR_RE.captures_iter(bg_value) { + let alpha = parse_alpha_str(&rgba_cap[4]); + if alpha < 0.1 { + continue; + } + let color = format!("rgb({}, {}, {})", &rgba_cap[1], &rgba_cap[2], &rgba_cap[3]); + if !is_transparent(&color) { + colors.push(BackgroundColor { + color, + element_weight: weight * alpha, + }); + } + } + + for rgba_cap in RGBA_MODERN_RE.captures_iter(bg_value) { + let alpha = parse_alpha_str(&rgba_cap[4]); + if alpha < 0.1 { + continue; + } + let color = format!("rgb({}, {}, {})", &rgba_cap[1], &rgba_cap[2], &rgba_cap[3]); + if !is_transparent(&color) { + colors.push(BackgroundColor { + color, + element_weight: weight * alpha, + }); + } + } + + // Check for named colors using word-boundary matching to avoid + // false positives (e.g. "white" matching inside "whitesmoke"). + for (name, re) in NAMED_COLOR_REGEXES.iter() { + if re.is_match(bg_value) { + colors.push(BackgroundColor { + color: name.to_string(), + element_weight: weight, + }); + break; + } + } + } +} + +fn extract_bgcolor_attrs(html: &str, colors: &mut Vec) { + let element_weights: &[(&LazyLock, f32)] = + &[(&BODY_TAG_RE, 1.0), (&TABLE_TAG_RE, 0.8), (&TD_TAG_RE, 0.6)]; + + for (tag_re, weight) in element_weights { + for m in tag_re.find_iter(html) { + let tag_content = m.as_str(); + if let Some(cap) = BGCOLOR_ATTR_RE.captures(tag_content) { + let color = cap[1].to_string(); + if !is_transparent(&color) { + let normalized = normalize_color(&color); + if !normalized.is_empty() { + colors.push(BackgroundColor { + color: normalized, + element_weight: *weight, + }); + } + } + } + } + } +} + +/// A color extracted from an inline style, with its alpha value. +struct ExtractedColor { + color: String, + alpha: f32, +} + +fn extract_colors_from_style(tag_content: &str) -> Vec { + let mut colors = Vec::new(); + + if let Some(cap) = STYLE_ATTR_RE.captures(tag_content) { + let style_value = cap + .get(1) + .or_else(|| cap.get(2)) + .map(|m| m.as_str().to_lowercase()) + .unwrap_or_default(); + + // Only extract colors from background/background-color property values, + // not from the entire style string (which may contain border-color, color, etc.) + for bg_cap in BG_PROP_RE.captures_iter(&style_value) { + let bg_value = bg_cap[1].trim(); + + for hex_cap in HEX_COLOR_RE.captures_iter(bg_value) { + let raw = hex_cap[0].to_string(); + let alpha = parse_hex_alpha(&raw); + colors.push(ExtractedColor { color: raw, alpha }); + } + + for rgb_cap in RGB_COLOR_RE.captures_iter(bg_value) { + colors.push(ExtractedColor { + color: format!("rgb({}, {}, {})", &rgb_cap[1], &rgb_cap[2], &rgb_cap[3]), + alpha: 1.0, + }); + } + + for rgba_cap in RGBA_COLOR_RE.captures_iter(bg_value) { + let alpha = parse_alpha_str(&rgba_cap[4]); + colors.push(ExtractedColor { + color: format!("rgb({}, {}, {})", &rgba_cap[1], &rgba_cap[2], &rgba_cap[3]), + alpha, + }); + } + + for rgba_cap in RGBA_MODERN_RE.captures_iter(bg_value) { + let alpha = parse_alpha_str(&rgba_cap[4]); + colors.push(ExtractedColor { + color: format!("rgb({}, {}, {})", &rgba_cap[1], &rgba_cap[2], &rgba_cap[3]), + alpha, + }); + } + + // Check for named colors using word-boundary matching to avoid + // false positives (e.g. "white" matching inside "whitesmoke"). + for (name, re) in NAMED_COLOR_REGEXES.iter() { + if re.is_match(bg_value) { + colors.push(ExtractedColor { + color: name.to_string(), + alpha: 1.0, + }); + break; + } + } + } + } + + colors +} + +/// Parse the alpha channel from an 8-digit hex color (e.g. `#rrggbbaa`). +/// Returns 1.0 for 3/6-digit hex or if parsing fails. +fn parse_hex_alpha(color: &str) -> f32 { + let c = color.trim().to_lowercase(); + if let Some(hex) = c.strip_prefix('#') { + if hex.len() == 8 { + return u8::from_str_radix(&hex[6..8], 16).unwrap_or(255) as f32 / 255.0; + } + if hex.len() == 4 { + let digit = u8::from_str_radix(&hex[3..4], 16).unwrap_or(15); + return (digit * 17) as f32 / 255.0; + } + } + 1.0 +} + +/// Parse an alpha string that may be a decimal (`0.5`) or percentage (`50%`). +fn parse_alpha_str(s: &str) -> f32 { + if let Some(pct) = s.strip_suffix('%') { + pct.parse::().unwrap_or(100.0) / 100.0 + } else { + s.parse::().unwrap_or(1.0) + } +} + +fn is_transparent(color: &str) -> bool { + let c = color.to_lowercase(); + c.contains("transparent") + || c.contains("rgba(0, 0, 0, 0)") + || c.contains("rgba(0,0,0,0)") + || c == "initial" + || c == "inherit" + || c == "none" +} + +fn normalize_color(color: &str) -> String { + let c = color.trim().to_lowercase(); + if let Some(hex) = c.strip_prefix('#') { + match hex.len() { + 3 => { + let mut chars = hex.chars(); + let r = chars.next().unwrap_or('0'); + let g = chars.next().unwrap_or('0'); + let b = chars.next().unwrap_or('0'); + format!("#{r}{r}{g}{g}{b}{b}") + } + 6 => c, + 8 => { + // 8-digit hex: strip alpha channel + format!("#{}", &hex[0..6]) + } + _ => c, + } + } else { + c + } +} + +fn calculate_luminance(color: &str) -> f64 { + let (r, g, b) = parse_color(color); + (0.299 * r as f64 + 0.587 * g as f64 + 0.114 * b as f64) / 255.0 +} + +fn parse_color(color: &str) -> (u8, u8, u8) { + let c = color.trim().to_lowercase(); + + // Check named colors first + for (name, rgb) in NAMED_COLORS.iter() { + if c == *name { + return *rgb; + } + } + + if let Some(hex) = c.strip_prefix('#') { + let hex = hex.trim(); + if hex.len() >= 6 { + return ( + u8::from_str_radix(&hex[0..2], 16).unwrap_or(255), + u8::from_str_radix(&hex[2..4], 16).unwrap_or(255), + u8::from_str_radix(&hex[4..6], 16).unwrap_or(255), + ); + } + } + + if let Some(cap) = RGB_COLOR_RE.captures(&c) { + return ( + cap[1].parse().unwrap_or(255), + cap[2].parse().unwrap_or(255), + cap[3].parse().unwrap_or(255), + ); + } + + if let Some(cap) = RGBA_COLOR_RE.captures(&c) { + return ( + cap[1].parse().unwrap_or(255), + cap[2].parse().unwrap_or(255), + cap[3].parse().unwrap_or(255), + ); + } + + // Unknown color - default to white (high luminance = light, safe fallback) + (255, 255, 255) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_detect_dark_email_by_background() { + let html = r#"Dark content"#; + assert_eq!(detect_email_theme(html), Some(EmailTheme::Dark)); + } + + #[test] + fn test_detect_light_email_by_background() { + let html = r#"Light content"#; + assert_eq!(detect_email_theme(html), Some(EmailTheme::Light)); + } + + #[test] + fn test_detect_light_email_with_table() { + let html = r#"
Light
"#; + assert_eq!(detect_email_theme(html), Some(EmailTheme::Light)); + } + + #[test] + fn test_detect_transparent_email() { + let html = r#"Just text, no background"#; + assert_eq!(detect_email_theme(html), Some(EmailTheme::Transparent)); + } + + #[test] + fn test_empty_html_returns_none() { + assert_eq!(detect_email_theme(""), None); + } + + #[test] + fn test_luminance_calculation() { + assert!(calculate_luminance("#000000") < 0.1); + assert!(calculate_luminance("#ffffff") > 0.9); + assert!(calculate_luminance("#101519") < 0.5); + } + + #[test] + fn test_adaptive_media_query_detection() { + let html = r#"Content"#; + assert_eq!(detect_email_theme(html), Some(EmailTheme::Adaptive)); + } + + #[test] + fn test_luminance_grouping_mixed_light_colors() { + // Multiple light colors should still classify as Light + let html = r#" + + + +
Content
+
Footer
+ + "#; + assert_eq!(detect_email_theme(html), Some(EmailTheme::Light)); + } + + #[test] + fn test_accent_td_not_treated_as_background() { + // A single td with a dark accent color shouldn't classify the email as dark + let html = r##"
Click here
"##; + assert_eq!(detect_email_theme(html), Some(EmailTheme::Transparent)); + } + + #[test] + fn test_deduplicate_bgcolor_and_style() { + // Same color from both bgcolor and style on same td should be deduplicated + let html = r##"
Click
"##; + assert_eq!(detect_email_theme(html), Some(EmailTheme::Transparent)); + } + + #[test] + fn test_lone_white_wrapper_table_is_transparent() { + // A wrapper table with background-color:white but no body background + // should be treated as transparent (boilerplate reset) + let html = r#"
Content
"#; + assert_eq!(detect_email_theme(html), Some(EmailTheme::Transparent)); + } + + #[test] + fn test_named_color_white() { + // Body with white background is intentional + let html = r#"Content"#; + assert_eq!(detect_email_theme(html), Some(EmailTheme::Light)); + } + + #[test] + fn test_named_color_black() { + let html = r#"Content"#; + assert_eq!(detect_email_theme(html), Some(EmailTheme::Dark)); + } + + #[test] + fn test_rgb_color() { + let html = r#"
Content
"#; + assert_eq!(detect_email_theme(html), Some(EmailTheme::Light)); + } + + #[test] + fn test_3_char_hex() { + let html = r#"Content"#; + assert_eq!(detect_email_theme(html), Some(EmailTheme::Light)); + } + + #[test] + fn test_quoted_printable_decoding() { + let encoded = r#""#; + let decoded = decode_quoted_printable(encoded); + assert!(decoded.contains("style=\"background-color:")); + } + + #[test] + fn test_detect_dark_with_style_tag() { + let html = r#"Dark"#; + assert_eq!(detect_email_theme(html), Some(EmailTheme::Dark)); + } + + #[test] + fn test_white_does_not_match_whitesmoke() { + // "whitesmoke" is a distinct named color (very light gray, RGB 245,245,245). + // It must NOT be misidentified as "white". + let html = r#"Content"#; + let colors = extract_background_colors(html); + assert_eq!(colors.len(), 1); + assert_eq!(colors[0].color, "whitesmoke"); + } + + #[test] + fn test_white_does_not_match_ghostwhite() { + let html = r#"Content"#; + let colors = extract_background_colors(html); + assert_eq!(colors.len(), 1); + assert_eq!(colors[0].color, "ghostwhite"); + } + + #[test] + fn test_white_does_not_match_floralwhite() { + let html = r#"Content"#; + let colors = extract_background_colors(html); + assert_eq!(colors.len(), 1); + assert_eq!(colors[0].color, "floralwhite"); + } + + #[test] + fn test_gray_does_not_match_darkgray() { + let html = r#"Content"#; + let colors = extract_background_colors(html); + assert_eq!(colors.len(), 1); + assert_eq!(colors[0].color, "darkgray"); + } + + #[test] + fn test_gray_does_not_match_lightgray() { + let html = r#"Content"#; + let colors = extract_background_colors(html); + assert_eq!(colors.len(), 1); + assert_eq!(colors[0].color, "lightgray"); + } + + #[test] + fn test_exact_white_still_matches() { + let html = r#"Content"#; + let colors = extract_background_colors(html); + assert_eq!(colors.len(), 1); + assert_eq!(colors[0].color, "white"); + } + + #[test] + fn test_named_color_in_style_tag_exact_match() { + // Ensure style-tag extraction also uses exact matching + let html = r#"Content"#; + let colors = extract_background_colors(html); + assert!( + colors.iter().any(|c| c.color == "whitesmoke"), + "expected whitesmoke, got: {:?}", + colors + ); + assert!( + !colors.iter().any(|c| c.color == "white"), + "white should not match whitesmoke" + ); + } + + #[test] + fn test_rgba_half_alpha_reduced_weight() { + // rgba(0,0,0,0.5) on body should have weight 1.0 * 0.5 = 0.5 + // compared to rgb(0,0,0) which would have weight 1.0 + let html_rgba = + r#"Content"#; + let html_rgb = r#"Content"#; + + let colors_rgba = extract_background_colors(html_rgba); + let colors_rgb = extract_background_colors(html_rgb); + + assert_eq!(colors_rgba.len(), 1); + assert_eq!(colors_rgb.len(), 1); + assert!( + (colors_rgba[0].element_weight - 0.5).abs() < 0.01, + "rgba(0,0,0,0.5) on body should have weight ~0.5, got {}", + colors_rgba[0].element_weight + ); + assert!( + (colors_rgb[0].element_weight - 1.0).abs() < 0.01, + "rgb(0,0,0) on body should have weight 1.0, got {}", + colors_rgb[0].element_weight + ); + } + + #[test] + fn test_rgba_fully_transparent_ignored() { + // rgba(0,0,0,0) should be skipped entirely (alpha < 0.1) + let html = r#"Content"#; + let colors = extract_background_colors(html); + assert!( + colors.is_empty(), + "Fully transparent rgba should produce no colors, got {:?}", + colors + ); + } + + #[test] + fn test_rgba_full_alpha_same_as_rgb() { + // rgba(255,255,255,1.0) should behave identically to rgb(255,255,255) + let html_rgba = + r#"Content"#; + let html_rgb = + r#"Content"#; + + assert_eq!(detect_email_theme(html_rgba), Some(EmailTheme::Light)); + assert_eq!(detect_email_theme(html_rgb), Some(EmailTheme::Light)); + + let colors_rgba = extract_background_colors(html_rgba); + let colors_rgb = extract_background_colors(html_rgb); + + assert_eq!(colors_rgba.len(), 1); + assert_eq!(colors_rgb.len(), 1); + assert!( + (colors_rgba[0].element_weight - colors_rgb[0].element_weight).abs() < 0.01, + "rgba with alpha=1.0 should have same weight as rgb" + ); + } + + #[test] + fn test_rgba_in_style_tag() { + // RGBA in a Content"#; + let colors = extract_background_colors(html); + assert_eq!(colors.len(), 1); + assert!( + (colors[0].element_weight - 0.5).abs() < 0.01, + "rgba in style tag on body should have weight ~0.5, got {}", + colors[0].element_weight + ); + } +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..ff702cf --- /dev/null +++ b/src/error.rs @@ -0,0 +1,173 @@ +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use serde::Serialize; + +/// Structured JSON error envelope returned to clients. +#[derive(Debug, Serialize)] +struct ErrorEnvelope { + error: ErrorBody, +} + +/// Body of the error envelope. +#[derive(Debug, Serialize)] +struct ErrorBody { + code: String, + message: String, + status: u16, +} + +/// Application-level error type that converts into an Axum response +/// with a structured JSON error envelope. +#[derive(Debug)] +#[allow(dead_code)] +pub enum AppError { + /// Internal server error (500). + InternalError(String), + /// Resource not found (404). + NotFound(String), + /// Unauthorized access (401). + Unauthorized(String), + /// Bad request (400). + BadRequest(String), + /// Authenticated but forbidden by policy (403). + Forbidden(String), + /// Service unavailable (503). + ServiceUnavailable(String), +} + +impl AppError { + /// Returns the HTTP status code for this error variant. + fn status_code(&self) -> StatusCode { + match self { + AppError::InternalError(_) => StatusCode::INTERNAL_SERVER_ERROR, + AppError::NotFound(_) => StatusCode::NOT_FOUND, + AppError::Unauthorized(_) => StatusCode::UNAUTHORIZED, + AppError::BadRequest(_) => StatusCode::BAD_REQUEST, + AppError::Forbidden(_) => StatusCode::FORBIDDEN, + AppError::ServiceUnavailable(_) => StatusCode::SERVICE_UNAVAILABLE, + } + } + + /// Returns the error code string for this error variant. + fn error_code(&self) -> &'static str { + match self { + AppError::InternalError(_) => "INTERNAL_ERROR", + AppError::NotFound(_) => "NOT_FOUND", + AppError::Unauthorized(_) => "UNAUTHORIZED", + AppError::BadRequest(_) => "BAD_REQUEST", + AppError::Forbidden(_) => "FORBIDDEN", + AppError::ServiceUnavailable(_) => "SERVICE_UNAVAILABLE", + } + } + + /// Returns the human-readable message for this error variant. + fn message(&self) -> &str { + match self { + AppError::InternalError(msg) + | AppError::NotFound(msg) + | AppError::Unauthorized(msg) + | AppError::BadRequest(msg) + | AppError::Forbidden(msg) + | AppError::ServiceUnavailable(msg) => msg, + } + } +} + +impl IntoResponse for AppError { + fn into_response(self) -> Response { + let status = self.status_code(); + let envelope = ErrorEnvelope { + error: ErrorBody { + code: self.error_code().to_string(), + message: self.message().to_string(), + status: status.as_u16(), + }, + }; + + let body = serde_json::to_string(&envelope).unwrap_or_else(|_| { + r#"{"error":{"code":"INTERNAL_ERROR","message":"Failed to serialize error","status":500}}"# + .to_string() + }); + + (status, [("content-type", "application/json")], body).into_response() + } +} + +impl std::fmt::Display for AppError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "[{}] {}", self.error_code(), self.message()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use http_body_util::BodyExt; + + async fn error_to_json(error: AppError) -> (StatusCode, serde_json::Value) { + let response = error.into_response(); + let status = response.status(); + let body = response + .into_body() + .collect() + .await + .expect("body should collect") + .to_bytes(); + let json: serde_json::Value = + serde_json::from_slice(&body).expect("body should be valid JSON"); + (status, json) + } + + #[tokio::test] + async fn internal_error_returns_500() { + let (status, json) = error_to_json(AppError::InternalError("something broke".into())).await; + assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR); + assert_eq!(json["error"]["code"], "INTERNAL_ERROR"); + assert_eq!(json["error"]["message"], "something broke"); + assert_eq!(json["error"]["status"], 500); + } + + #[tokio::test] + async fn not_found_returns_404() { + let (status, json) = error_to_json(AppError::NotFound("mailbox missing".into())).await; + assert_eq!(status, StatusCode::NOT_FOUND); + assert_eq!(json["error"]["code"], "NOT_FOUND"); + assert_eq!(json["error"]["message"], "mailbox missing"); + assert_eq!(json["error"]["status"], 404); + } + + #[tokio::test] + async fn unauthorized_returns_401() { + let (status, json) = error_to_json(AppError::Unauthorized("bad token".into())).await; + assert_eq!(status, StatusCode::UNAUTHORIZED); + assert_eq!(json["error"]["code"], "UNAUTHORIZED"); + assert_eq!(json["error"]["message"], "bad token"); + assert_eq!(json["error"]["status"], 401); + } + + #[tokio::test] + async fn bad_request_returns_400() { + let (status, json) = error_to_json(AppError::BadRequest("invalid input".into())).await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(json["error"]["code"], "BAD_REQUEST"); + assert_eq!(json["error"]["message"], "invalid input"); + assert_eq!(json["error"]["status"], 400); + } + + #[tokio::test] + async fn forbidden_returns_403() { + let (status, json) = error_to_json(AppError::Forbidden("blocked".into())).await; + assert_eq!(status, StatusCode::FORBIDDEN); + assert_eq!(json["error"]["code"], "FORBIDDEN"); + assert_eq!(json["error"]["status"], 403); + } + + #[tokio::test] + async fn service_unavailable_returns_503() { + let (status, json) = error_to_json(AppError::ServiceUnavailable("IMAP down".into())).await; + assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(json["error"]["code"], "SERVICE_UNAVAILABLE"); + assert_eq!(json["error"]["message"], "IMAP down"); + assert_eq!(json["error"]["status"], 503); + } +} diff --git a/src/imap/client.rs b/src/imap/client.rs new file mode 100644 index 0000000..961e37f --- /dev/null +++ b/src/imap/client.rs @@ -0,0 +1,1347 @@ +use async_trait::async_trait; +use futures::StreamExt; + +use super::connection::map_imap_error; +use super::parse::{ + decode_rfc2047, flag_to_string, has_attachments, imap_address_to_email, + name_attribute_to_string, +}; + +pub use super::error::ImapError; +pub use super::types::{ + EmailAddress, FolderStatus, FolderStatusExtended, ImapAttachment, ImapCredentials, ImapFolder, + ImapMessageBody, ImapMessageHeader, MailboxQuota, +}; + +pub(crate) use super::connection::connect; + +#[cfg(test)] +#[path = "mock.rs"] +pub mod mock; + +/// Abstraction over IMAP operations. +/// +/// Every method receives explicit connection parameters so that the trait +/// keeps its API independent of connection management. +/// +/// The `Send + Sync` bounds allow implementations to be shared across +/// Tokio tasks and stored in `Arc`. +#[allow(dead_code)] +#[allow(clippy::double_must_use)] +#[async_trait] +pub trait ImapClient: Send + Sync { + /// Return the capabilities advertised by the authenticated IMAP server. + async fn capabilities(&self, creds: &ImapCredentials) -> Result, ImapError>; + + /// Drop reusable request-path connections for this account. The default + /// implementation is a no-op for test/alternate clients. + async fn reset_connections(&self, _creds: &ImapCredentials) -> Result<(), ImapError> { + Ok(()) + } + + /// List all folders (mailboxes) on the server. + async fn list_folders(&self, creds: &ImapCredentials) -> Result, ImapError>; + + /// Perform a lightweight `SELECT` on a folder to get its status + /// (UIDVALIDITY, EXISTS count, UIDNEXT) without fetching any messages. + async fn folder_status( + &self, + creds: &ImapCredentials, + folder: &str, + ) -> Result; + + /// Fetch message headers (envelopes) for a range of UIDs in a folder. + async fn fetch_headers( + &self, + creds: &ImapCredentials, + folder: &str, + uid_range: &str, + ) -> Result, ImapError>; + + /// Fetch the full body of a single message by UID. + async fn fetch_body( + &self, + creds: &ImapCredentials, + folder: &str, + uid: u32, + ) -> Result; + + /// Fetch the complete RFC822 message bytes for source/download operations. + async fn fetch_raw_message( + &self, + _creds: &ImapCredentials, + folder: &str, + uid: u32, + ) -> Result, ImapError> { + Err(ImapError::MessageNotFound { + uid, + folder: folder.to_string(), + }) + } + + /// Add flags to a message (IMAP +FLAGS). + async fn add_flags( + &self, + creds: &ImapCredentials, + folder: &str, + uid: u32, + flags: &[&str], + ) -> Result<(), ImapError>; + + /// Remove flags from a message (IMAP -FLAGS). + async fn remove_flags( + &self, + creds: &ImapCredentials, + folder: &str, + uid: u32, + flags: &[&str], + ) -> Result<(), ImapError>; + + /// Set (replace) the flags on a message. + async fn set_flags( + &self, + creds: &ImapCredentials, + folder: &str, + uid: u32, + flags: &[&str], + ) -> Result<(), ImapError>; + + /// Move a message from one folder to another. + async fn move_message( + &self, + creds: &ImapCredentials, + from_folder: &str, + uid: u32, + to_folder: &str, + ) -> Result<(), ImapError>; + + /// Copy a message to another folder without removing the source. + async fn copy_message( + &self, + creds: &ImapCredentials, + from_folder: &str, + uid: u32, + to_folder: &str, + ) -> Result<(), ImapError>; + + /// Mark all messages in a folder as read. + async fn mark_all_read(&self, creds: &ImapCredentials, folder: &str) -> Result<(), ImapError>; + + /// Permanently remove every message from a folder. + async fn empty_folder(&self, creds: &ImapCredentials, folder: &str) -> Result<(), ImapError>; + + /// Permanently remove a message that has the `\Deleted` flag. + async fn expunge_message( + &self, + creds: &ImapCredentials, + folder: &str, + uid: u32, + ) -> Result<(), ImapError>; + + /// Append a raw RFC822 message to a folder (e.g. saving sent mail to "Sent"). + async fn append_message( + &self, + creds: &ImapCredentials, + folder: &str, + message_bytes: &[u8], + flags: &[&str], + ) -> Result<(), ImapError>; + + /// Append a message and resolve the server-assigned UID without guessing. + /// + /// `identity_header` and `identity_value` must uniquely identify this + /// application-owned snapshot. Implementations only accept a UID at or + /// above the mailbox UIDNEXT observed before APPEND. + async fn append_message_resolved_uid( + &self, + creds: &ImapCredentials, + folder: &str, + message_bytes: &[u8], + flags: &[&str], + identity_header: &str, + identity_value: &str, + ) -> Result<(u32, u32), ImapError>; + + /// Create a new folder (mailbox) and subscribe to it. + async fn create_folder( + &self, + creds: &ImapCredentials, + folder_name: &str, + ) -> Result<(), ImapError>; + + /// Rename an existing folder. + async fn rename_folder( + &self, + creds: &ImapCredentials, + from: &str, + to: &str, + ) -> Result<(), ImapError>; + + /// Permanently delete a folder (mailbox). + async fn delete_folder( + &self, + creds: &ImapCredentials, + folder_name: &str, + ) -> Result<(), ImapError>; + + /// Subscribe to or unsubscribe from a folder. + async fn subscribe_folder( + &self, + creds: &ImapCredentials, + folder_name: &str, + subscribe: bool, + ) -> Result<(), ImapError>; + + /// Fetch only UIDs and FLAGS for all messages in a folder. + /// Used for periodic reconciliation to detect flag changes and deletions. + async fn fetch_uids_and_flags( + &self, + creds: &ImapCredentials, + folder: &str, + ) -> Result)>, ImapError>; + + /// Lightweight STATUS command to get folder metadata without SELECT. + /// Returns UIDVALIDITY, EXISTS, UIDNEXT, UNSEEN, and HIGHESTMODSEQ. + async fn folder_status_extended( + &self, + creds: &ImapCredentials, + folder: &str, + ) -> Result; + + /// Fetch only messages whose flags changed since `since_modseq` using CONDSTORE. + /// Returns (vec of (uid, flags), new_highest_modseq). + async fn fetch_changed_flags( + &self, + creds: &ImapCredentials, + folder: &str, + since_modseq: u64, + ) -> Result<(Vec<(u32, Vec)>, u64), ImapError>; + + /// Fetch mailbox quota via IMAP GETQUOTAROOT. + /// Returns `None` if the server doesn't support quotas. + async fn get_quota(&self, creds: &ImapCredentials) -> Result, ImapError>; + + /// Fetch the total size of all messages in a folder via UID FETCH 1:* (RFC822.SIZE). + async fn fetch_folder_size( + &self, + creds: &ImapCredentials, + folder: &str, + ) -> Result; + + /// Return whether the selected mailbox allows creating persistent custom keywords. + /// An absent PERMANENTFLAGS response is treated according to RFC 3501 as permissive. + async fn folder_keyword_support( + &self, + _creds: &ImapCredentials, + _folder: &str, + ) -> Result { + Ok(false) + } +} + +// --------------------------------------------------------------------------- +// Real implementation backed by async-imap +// --------------------------------------------------------------------------- + +/// Production IMAP client that uses `async-imap` and `mail-parser`. +/// +/// Read operations reuse bounded, exclusively leased authenticated sessions. +/// Writes retain dedicated connections for now. +#[derive(Default)] +pub struct RealImapClient { + pool: super::pool::ConnectionPool, +} + +// ---- Trait implementation ------------------------------------------------- + +#[async_trait] +impl ImapClient for RealImapClient { + async fn capabilities(&self, creds: &ImapCredentials) -> Result, ImapError> { + let mut session = self.pool.checkout(creds).await?; + let caps = session.capabilities().await.map_err(map_imap_error)?; + let mut values: Vec = caps + .iter() + .map(|cap| match cap { + async_imap::types::Capability::Imap4rev1 => "IMAP4REV1".to_string(), + async_imap::types::Capability::Auth(name) => format!("AUTH={name}"), + async_imap::types::Capability::Atom(name) => name.to_string(), + }) + .collect(); + values.sort_by_key(|value| value.to_ascii_uppercase()); + values.dedup_by(|a, b| a.eq_ignore_ascii_case(b)); + session.recycle(); + Ok(values) + } + + async fn reset_connections(&self, creds: &ImapCredentials) -> Result<(), ImapError> { + self.pool.clear_user(creds); + Ok(()) + } + + async fn folder_status( + &self, + creds: &ImapCredentials, + folder: &str, + ) -> Result { + let mut session = self.pool.checkout(creds).await?; + + let mailbox = session.select(folder).await.map_err(|e| match &e { + async_imap::error::Error::No(msg) + if msg.to_lowercase().contains("not found") + || msg.to_lowercase().contains("doesn't exist") + || msg.to_lowercase().contains("does not exist") + || msg.to_lowercase().contains("no such") => + { + ImapError::FolderNotFound(folder.to_string()) + } + _ => map_imap_error(e), + })?; + + let uid_validity = mailbox.uid_validity.unwrap_or(0); + let exists = mailbox.exists; + let uid_next = mailbox.uid_next.unwrap_or(0); + + session.recycle(); + Ok(FolderStatus { + uid_validity, + exists, + uid_next, + }) + } + + async fn list_folders(&self, creds: &ImapCredentials) -> Result, ImapError> { + let mut session = self.pool.checkout(creds).await?; + + let folders = { + let names_stream = session + .list(Some(""), Some("*")) + .await + .map_err(map_imap_error)?; + + let mut names_stream = std::pin::pin!(names_stream); + let mut names = Vec::new(); + while let Some(result) = names_stream.next().await { + names.push(result.map_err(map_imap_error)?); + } + + names + .iter() + .map(|n| ImapFolder { + name: n.name().to_string(), + delimiter: n.delimiter().map(|d| d.to_string()), + attributes: n + .attributes() + .iter() + .map(name_attribute_to_string) + .collect(), + }) + .collect() + }; + + session.recycle(); + Ok(folders) + } + + async fn fetch_headers( + &self, + creds: &ImapCredentials, + folder: &str, + uid_range: &str, + ) -> Result, ImapError> { + let mut session = self.pool.checkout(creds).await?; + + session.select(folder).await.map_err(|e| match &e { + async_imap::error::Error::No(msg) + if msg.to_lowercase().contains("not found") + || msg.to_lowercase().contains("doesn't exist") + || msg.to_lowercase().contains("does not exist") + || msg.to_lowercase().contains("no such") => + { + ImapError::FolderNotFound(folder.to_string()) + } + _ => map_imap_error(e), + })?; + + let fetches = { + // Fetch ENVELOPE, FLAGS, BODYSTRUCTURE, RFC822.SIZE, and threading headers. + // We only fetch Message-ID, In-Reply-To, and References (a few bytes per message) + // rather than full raw headers, to keep bulk syncs lightweight. + let mut fetch_stream = session + .uid_fetch( + uid_range, + "(UID ENVELOPE FLAGS BODYSTRUCTURE RFC822.SIZE BODY.PEEK[HEADER.FIELDS (Message-ID In-Reply-To References Content-Class x-ms-exchange-generated-message-class)])", + ) + .await + .map_err(map_imap_error)?; + + let mut fetches = Vec::new(); + while let Some(result) = fetch_stream.next().await { + fetches.push(result.map_err(map_imap_error)?); + } + + fetches + }; + session.recycle(); + let folder = folder.to_owned(); + tokio::task::spawn_blocking(move || { + let mut headers = Vec::with_capacity(fetches.len()); + for fetch in &fetches { + let uid = match fetch.uid { + Some(u) => u, + None => continue, + }; + + // Parse threading headers from the small HEADER.FIELDS response. + let raw_header_bytes = fetch.header(); + let parsed_threading = raw_header_bytes + .and_then(|raw| mail_parser::MessageParser::default().parse(raw)); + + let (subject, from, to, cc, date) = if let Some(env) = fetch.envelope() { + let subject = env + .subject + .as_ref() + .and_then(|b| std::str::from_utf8(b).ok()) + .map(decode_rfc2047); + + let from: Vec = env + .from + .as_ref() + .map(|addrs| addrs.iter().map(imap_address_to_email).collect()) + .unwrap_or_default(); + + let to: Vec = env + .to + .as_ref() + .map(|addrs| addrs.iter().map(imap_address_to_email).collect()) + .unwrap_or_default(); + + let cc: Vec = env + .cc + .as_ref() + .map(|addrs| addrs.iter().map(imap_address_to_email).collect()) + .unwrap_or_default(); + + let date = env + .date + .as_ref() + .and_then(|b| std::str::from_utf8(b).ok()) + .map(|s| s.to_string()); + + (subject, from, to, cc, date) + } else { + // No envelope — we can't fill subject/from/to/date from the + // small threading-only header fetch, so leave them empty. + // They'll be populated when the user opens the message body. + tracing::warn!( + uid = uid, + folder = %folder, + "ENVELOPE missing for message, headers will be empty until body is fetched" + ); + (None, vec![], vec![], vec![], None) + }; + + // Extract threading headers from the small HEADER.FIELDS response. + let message_id = parsed_threading + .as_ref() + .and_then(|p| p.message_id().map(|s| format!("<{s}>"))); + let in_reply_to = parsed_threading.as_ref().and_then(|p| { + let val = p.in_reply_to(); + val.as_text().map(|s| format!("<{s}>")) + }); + let references = parsed_threading.as_ref().and_then(|p| { + let val = p.references(); + val.as_text_list() + .map(|list| { + list.iter() + .map(|s| format!("<{s}>")) + .collect::>() + .join(" ") + }) + .or_else(|| val.as_text().map(|s| format!("<{s}>"))) + }); + + // Detect Outlook/Exchange reaction emails from headers. + let reaction = raw_header_bytes.and_then(|raw| { + let header_str = std::str::from_utf8(raw).ok()?; + let lower = header_str.to_lowercase(); + let is_reaction = lower.contains("content-class: activitynotification") + || lower.contains("urn:content-class:reaction"); + if !is_reaction { + return None; + } + let subj = subject.as_deref().unwrap_or("").to_lowercase(); + let emoji = match subj.trim() { + s if s.contains("like") => "\u{1f44d}", + s if s.contains("heart") || s.contains("love") => "\u{2764}\u{fe0f}", + s if s.contains("laugh") => "\u{1f604}", + s if s.contains("surprised") || s.contains("wow") => "\u{1f62e}", + s if s.contains("sad") => "\u{1f622}", + s if s.contains("angry") => "\u{1f620}", + _ => "\u{1f44d}", + }; + Some(emoji.to_string()) + }); + + let flags: Vec = fetch.flags().map(|f| flag_to_string(&f)).collect(); + + let has_attach = fetch + .bodystructure() + .map(|bs| has_attachments(bs)) + .unwrap_or(false); + + let size = fetch.size.unwrap_or(0); + + headers.push(ImapMessageHeader { + uid, + subject, + from, + to, + date, + flags, + has_attachments: has_attach, + size, + message_id, + in_reply_to, + references, + cc, + reaction, + }); + } + headers + }) + .await + .map_err(|e| ImapError::ProtocolError(format!("Header parsing task failed: {e}"))) + } + + async fn fetch_body( + &self, + creds: &ImapCredentials, + folder: &str, + uid: u32, + ) -> Result { + let mut session = self.pool.checkout(creds).await?; + + session.select(folder).await.map_err(|e| match &e { + async_imap::error::Error::No(msg) + if msg.to_lowercase().contains("not found") + || msg.to_lowercase().contains("doesn't exist") + || msg.to_lowercase().contains("does not exist") + || msg.to_lowercase().contains("no such") => + { + ImapError::FolderNotFound(folder.to_string()) + } + _ => map_imap_error(e), + })?; + + let uid_str = uid.to_string(); + let body = { + let mut fetch_stream = session + .uid_fetch(&uid_str, "(UID BODY[])") + .await + .map_err(map_imap_error)?; + + let mut fetches = Vec::new(); + while let Some(result) = fetch_stream.next().await { + fetches.push(result.map_err(map_imap_error)?); + } + + let fetch = fetches.first().ok_or(ImapError::MessageNotFound { + uid, + folder: folder.to_string(), + })?; + + let raw = fetch.body().ok_or_else(|| { + ImapError::ProtocolError("BODY[] not returned by server".to_string()) + })?; + + use mail_parser::MimeHeaders; + + let parsed = mail_parser::MessageParser::default() + .parse(raw) + .ok_or_else(|| { + ImapError::ProtocolError("failed to parse RFC822 message".to_string()) + })?; + + let text_plain: Option = parsed.body_text(0).map(|s| s.to_string()); + + let has_html_part = parsed.parts.iter().any(|part| { + part.content_type() + .is_some_and(|ct| ct.ctype() == "text" && ct.subtype() == Some("html")) + }); + + let text_html: Option = if has_html_part { + parsed.body_html(0).map(|s| s.to_string()) + } else { + None + }; + + tracing::debug!( + uid = uid, + folder = %folder, + total_parts = parsed.parts.len(), + attachment_count = parsed.attachments().count(), + has_text = text_plain.is_some(), + has_html = text_html.is_some(), + "fetch_body: parsed message structure" + ); + + let mut attachments = Vec::new(); + + // Collect explicit attachments. + for attachment in parsed.attachments() { + let filename: Option = attachment.attachment_name().map(|s| s.to_string()); + let content_type: String = attachment.content_type().map_or_else( + || "application/octet-stream".to_string(), + |ct: &mail_parser::ContentType<'_>| { + if let Some(subtype) = ct.subtype() { + format!("{}/{}", ct.ctype(), subtype) + } else { + ct.ctype().to_string() + } + }, + ); + let content_id = attachment + .content_id() + .map(|s| s.trim_matches(|c| c == '<' || c == '>').to_string()); + let data = attachment.contents().to_vec(); + let size = data.len(); + attachments.push(ImapAttachment { + filename, + content_type, + size, + data, + content_id, + }); + } + + // Also collect inline parts with Content-ID (e.g. embedded images + // referenced via cid: URLs in the HTML body). + for part in parsed.parts.iter() { + if part.content_id().is_none() { + continue; + } + // Skip if this is a text/html or text/plain body part. + let is_text = part.content_type().is_some_and(|ct| ct.ctype() == "text"); + if is_text { + continue; + } + let cid = part + .content_id() + .unwrap() + .trim_matches(|c| c == '<' || c == '>') + .to_string(); + // Skip if we already captured this part via attachments(). + if attachments + .iter() + .any(|a| a.content_id.as_deref() == Some(&cid)) + { + continue; + } + let content_type: String = part.content_type().map_or_else( + || "application/octet-stream".to_string(), + |ct: &mail_parser::ContentType<'_>| { + if let Some(subtype) = ct.subtype() { + format!("{}/{}", ct.ctype(), subtype) + } else { + ct.ctype().to_string() + } + }, + ); + let data = part.contents().to_vec(); + let size = data.len(); + attachments.push(ImapAttachment { + filename: part.attachment_name().map(|s| s.to_string()), + content_type, + size, + data, + content_id: Some(cid), + }); + } + + // Extract raw headers from the RFC 822 message. + let raw_str = String::from_utf8_lossy(raw); + let raw_headers = raw_str + .split_once("\r\n\r\n") + .or_else(|| raw_str.split_once("\n\n")) + .map_or_else(|| raw_str.to_string(), |(h, _)| h.to_string()); + + ImapMessageBody { + uid, + text_plain, + text_html, + attachments, + raw_headers, + } + }; + + session.recycle(); + Ok(body) + } + + async fn fetch_raw_message( + &self, + creds: &ImapCredentials, + folder: &str, + uid: u32, + ) -> Result, ImapError> { + let mut session = self.pool.checkout(creds).await?; + session.select(folder).await.map_err(map_imap_error)?; + let uid_str = uid.to_string(); + let raw = { + let mut stream = session + .uid_fetch(&uid_str, "(UID BODY.PEEK[])") + .await + .map_err(map_imap_error)?; + let mut bytes = None; + while let Some(result) = stream.next().await { + let fetch = result.map_err(map_imap_error)?; + if let Some(body) = fetch.body() { + bytes = Some(body.to_vec()); + break; + } + } + bytes.ok_or(ImapError::MessageNotFound { + uid, + folder: folder.to_string(), + })? + }; + session.recycle(); + Ok(raw) + } + + async fn add_flags( + &self, + creds: &ImapCredentials, + folder: &str, + uid: u32, + flags: &[&str], + ) -> Result<(), ImapError> { + let mut session = connect(creds).await?; + session.select(folder).await.map_err(map_imap_error)?; + + let uid_str = uid.to_string(); + let flags_str = format!("+FLAGS ({})", flags.join(" ")); + { + let mut store_stream = session + .uid_store(&uid_str, &flags_str) + .await + .map_err(map_imap_error)?; + while let Some(result) = store_stream.next().await { + result.map_err(map_imap_error)?; + } + } + + let _ = session.logout().await; + Ok(()) + } + + async fn remove_flags( + &self, + creds: &ImapCredentials, + folder: &str, + uid: u32, + flags: &[&str], + ) -> Result<(), ImapError> { + let mut session = connect(creds).await?; + session.select(folder).await.map_err(map_imap_error)?; + + let uid_str = uid.to_string(); + let flags_str = format!("-FLAGS ({})", flags.join(" ")); + { + let mut store_stream = session + .uid_store(&uid_str, &flags_str) + .await + .map_err(map_imap_error)?; + while let Some(result) = store_stream.next().await { + result.map_err(map_imap_error)?; + } + } + + let _ = session.logout().await; + Ok(()) + } + + async fn set_flags( + &self, + creds: &ImapCredentials, + folder: &str, + uid: u32, + flags: &[&str], + ) -> Result<(), ImapError> { + let mut session = connect(creds).await?; + + session.select(folder).await.map_err(map_imap_error)?; + + let uid_str = uid.to_string(); + let flags_str = format!("FLAGS ({})", flags.join(" ")); + { + let mut store_stream = session + .uid_store(&uid_str, &flags_str) + .await + .map_err(map_imap_error)?; + + // Consume the stream to completion so the command finishes. + while let Some(result) = store_stream.next().await { + result.map_err(map_imap_error)?; + } + } + + let _ = session.logout().await; + Ok(()) + } + + async fn move_message( + &self, + creds: &ImapCredentials, + from_folder: &str, + uid: u32, + to_folder: &str, + ) -> Result<(), ImapError> { + let mut session = connect(creds).await?; + + session.select(from_folder).await.map_err(map_imap_error)?; + + let uid_str = uid.to_string(); + + // Try UID MOVE first; fall back to COPY + DELETE + EXPUNGE if the + // server does not support the MOVE extension. + match session.uid_mv(&uid_str, to_folder).await { + Ok(()) => {} + Err(async_imap::error::Error::No(_) | async_imap::error::Error::Bad(_)) => { + // Fallback: COPY, then flag \Deleted, then EXPUNGE. + session + .uid_copy(&uid_str, to_folder) + .await + .map_err(map_imap_error)?; + + { + let mut store_stream = session + .uid_store(&uid_str, "+FLAGS (\\Deleted)") + .await + .map_err(map_imap_error)?; + while let Some(r) = store_stream.next().await { + r.map_err(map_imap_error)?; + } + } + + { + let expunge_stream = session.expunge().await.map_err(map_imap_error)?; + let mut expunge_stream = std::pin::pin!(expunge_stream); + while let Some(r) = expunge_stream.next().await { + r.map_err(map_imap_error)?; + } + } + } + Err(e) => return Err(map_imap_error(e)), + } + + let _ = session.logout().await; + Ok(()) + } + + async fn copy_message( + &self, + creds: &ImapCredentials, + from_folder: &str, + uid: u32, + to_folder: &str, + ) -> Result<(), ImapError> { + let mut session = connect(creds).await?; + session.select(from_folder).await.map_err(map_imap_error)?; + session + .uid_copy(&uid.to_string(), to_folder) + .await + .map_err(map_imap_error)?; + let _ = session.logout().await; + Ok(()) + } + + async fn mark_all_read(&self, creds: &ImapCredentials, folder: &str) -> Result<(), ImapError> { + let mut session = connect(creds).await?; + session.select(folder).await.map_err(map_imap_error)?; + { + let mut stream = session + .uid_store("1:*", "+FLAGS (\\Seen)") + .await + .map_err(map_imap_error)?; + while let Some(result) = stream.next().await { + result.map_err(map_imap_error)?; + } + } + let _ = session.logout().await; + Ok(()) + } + + async fn empty_folder(&self, creds: &ImapCredentials, folder: &str) -> Result<(), ImapError> { + let mut session = connect(creds).await?; + session.select(folder).await.map_err(map_imap_error)?; + { + let mut stream = session + .uid_store("1:*", "+FLAGS (\\Deleted)") + .await + .map_err(map_imap_error)?; + while let Some(result) = stream.next().await { + result.map_err(map_imap_error)?; + } + } + { + let stream = session.expunge().await.map_err(map_imap_error)?; + let mut stream = std::pin::pin!(stream); + while let Some(result) = stream.next().await { + result.map_err(map_imap_error)?; + } + } + let _ = session.logout().await; + Ok(()) + } + + async fn expunge_message( + &self, + creds: &ImapCredentials, + folder: &str, + uid: u32, + ) -> Result<(), ImapError> { + let mut session = connect(creds).await?; + + session.select(folder).await.map_err(map_imap_error)?; + + let uid_str = uid.to_string(); + + // Mark the message as \Deleted. + { + let mut store_stream = session + .uid_store(&uid_str, "+FLAGS (\\Deleted)") + .await + .map_err(map_imap_error)?; + while let Some(r) = store_stream.next().await { + r.map_err(map_imap_error)?; + } + } + + // Try UID EXPUNGE for precision; fall back to EXPUNGE. + let uid_expunge_ok = { + match session.uid_expunge(&uid_str).await { + Ok(stream) => { + let mut stream = std::pin::pin!(stream); + while let Some(r) = stream.next().await { + r.map_err(map_imap_error)?; + } + true + } + Err(_) => false, + } + }; + if !uid_expunge_ok { + let stream = session.expunge().await.map_err(map_imap_error)?; + let mut stream = std::pin::pin!(stream); + while let Some(r) = stream.next().await { + r.map_err(map_imap_error)?; + } + } + + let _ = session.logout().await; + Ok(()) + } + + async fn append_message( + &self, + creds: &ImapCredentials, + folder: &str, + message_bytes: &[u8], + flags: &[&str], + ) -> Result<(), ImapError> { + let mut session = connect(creds).await?; + + let flags_str: Vec = flags.iter().map(|f| f.to_string()).collect(); + let flags_joined = if flags_str.is_empty() { + None + } else { + Some(format!("({})", flags_str.join(" "))) + }; + session + .append(folder, flags_joined.as_deref(), None, message_bytes) + .await + .map_err(map_imap_error)?; + + let _ = session.logout().await; + Ok(()) + } + + async fn append_message_resolved_uid( + &self, + creds: &ImapCredentials, + folder: &str, + message_bytes: &[u8], + flags: &[&str], + identity_header: &str, + identity_value: &str, + ) -> Result<(u32, u32), ImapError> { + fn quote_search(value: &str) -> String { + value.replace('\\', "\\\\").replace('"', "\\\"") + } + + if identity_header.is_empty() + || !identity_header + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-') + || identity_value.contains('\r') + || identity_value.contains('\n') + { + return Err(ImapError::ProtocolError( + "Invalid draft identity header".to_string(), + )); + } + + let mut session = connect(creds).await?; + let mailbox = session.select(folder).await.map_err(map_imap_error)?; + let uid_validity = mailbox.uid_validity.unwrap_or(0); + let uid_next_before = mailbox.uid_next.unwrap_or(0); + + let flags_str: Vec = flags.iter().map(|flag| flag.to_string()).collect(); + let flags_joined = if flags_str.is_empty() { + None + } else { + Some(format!("({})", flags_str.join(" "))) + }; + session + .append(folder, flags_joined.as_deref(), None, message_bytes) + .await + .map_err(map_imap_error)?; + + let query = format!( + "HEADER {} \"{}\"", + identity_header, + quote_search(identity_value) + ); + let matches = session.uid_search(query).await.map_err(map_imap_error)?; + let mut candidates: Vec = matches + .into_iter() + .filter(|uid| uid_next_before == 0 || *uid >= uid_next_before) + .collect(); + candidates.sort_unstable(); + + let resolved = match candidates.as_slice() { + [uid] => *uid, + [] => { + let _ = session.logout().await; + return Err(ImapError::ProtocolError( + "IMAP APPEND succeeded but the new draft UID could not be resolved".to_string(), + )); + } + _ => { + let _ = session.logout().await; + return Err(ImapError::ProtocolError( + "IMAP APPEND succeeded but more than one matching draft UID was returned" + .to_string(), + )); + } + }; + + let _ = session.logout().await; + Ok((resolved, uid_validity)) + } + + async fn create_folder( + &self, + creds: &ImapCredentials, + folder_name: &str, + ) -> Result<(), ImapError> { + let mut session = connect(creds).await?; + session.create(folder_name).await.map_err(map_imap_error)?; + session + .subscribe(folder_name) + .await + .map_err(map_imap_error)?; + session.logout().await.ok(); + Ok(()) + } + + async fn rename_folder( + &self, + creds: &ImapCredentials, + from: &str, + to: &str, + ) -> Result<(), ImapError> { + let mut session = connect(creds).await?; + session.rename(from, to).await.map_err(map_imap_error)?; + session.logout().await.ok(); + Ok(()) + } + + async fn delete_folder( + &self, + creds: &ImapCredentials, + folder_name: &str, + ) -> Result<(), ImapError> { + let mut session = connect(creds).await?; + session.delete(folder_name).await.map_err(map_imap_error)?; + session.logout().await.ok(); + Ok(()) + } + + async fn subscribe_folder( + &self, + creds: &ImapCredentials, + folder_name: &str, + subscribe: bool, + ) -> Result<(), ImapError> { + let mut session = connect(creds).await?; + if subscribe { + session + .subscribe(folder_name) + .await + .map_err(map_imap_error)?; + } else { + session + .unsubscribe(folder_name) + .await + .map_err(map_imap_error)?; + } + session.logout().await.ok(); + Ok(()) + } + + async fn fetch_uids_and_flags( + &self, + creds: &ImapCredentials, + folder: &str, + ) -> Result)>, ImapError> { + let mut session = self.pool.checkout(creds).await?; + + session.select(folder).await.map_err(|e| match &e { + async_imap::error::Error::No(msg) + if msg.to_lowercase().contains("not found") + || msg.to_lowercase().contains("doesn't exist") + || msg.to_lowercase().contains("does not exist") + || msg.to_lowercase().contains("no such") => + { + ImapError::FolderNotFound(folder.to_string()) + } + _ => map_imap_error(e), + })?; + + let results = { + let mut fetch_stream = session + .uid_fetch("1:*", "(UID FLAGS)") + .await + .map_err(map_imap_error)?; + + let mut items = Vec::new(); + while let Some(result) = fetch_stream.next().await { + let fetch = result.map_err(map_imap_error)?; + if let Some(uid) = fetch.uid { + let flags: Vec = fetch.flags().map(|f| flag_to_string(&f)).collect(); + items.push((uid, flags)); + } + } + items + }; + + session.recycle(); + Ok(results) + } + + async fn folder_status_extended( + &self, + creds: &ImapCredentials, + folder: &str, + ) -> Result { + let mut session = self.pool.checkout(creds).await?; + + let mailbox = session + .status( + folder, + "(MESSAGES UIDNEXT UIDVALIDITY UNSEEN HIGHESTMODSEQ)", + ) + .await + .map_err(map_imap_error)?; + + let result = FolderStatusExtended { + uid_validity: mailbox.uid_validity.unwrap_or(0), + exists: mailbox.exists, + uid_next: mailbox.uid_next.unwrap_or(0), + unseen: mailbox.unseen.unwrap_or(0), + highest_modseq: mailbox.highest_modseq.unwrap_or(0), + }; + + session.recycle(); + Ok(result) + } + + async fn fetch_changed_flags( + &self, + creds: &ImapCredentials, + folder: &str, + since_modseq: u64, + ) -> Result<(Vec<(u32, Vec)>, u64), ImapError> { + let mut session = self.pool.checkout(creds).await?; + + let mailbox = session + .select_condstore(folder) + .await + .map_err(map_imap_error)?; + + let new_modseq = mailbox.highest_modseq.unwrap_or(0); + + let items = { + let mut fetch_stream = session + .uid_fetch( + "1:*", + format!("(UID FLAGS) (CHANGEDSINCE {})", since_modseq), + ) + .await + .map_err(map_imap_error)?; + + let mut items = Vec::new(); + while let Some(result) = fetch_stream.next().await { + let fetch = result.map_err(map_imap_error)?; + if let Some(uid) = fetch.uid { + let flags: Vec = fetch.flags().map(|f| flag_to_string(&f)).collect(); + items.push((uid, flags)); + } + } + items + }; + + session.recycle(); + Ok((items, new_modseq)) + } + + async fn get_quota(&self, creds: &ImapCredentials) -> Result, ImapError> { + use async_imap::imap_proto::{Response, Status}; + let mut session = self.pool.checkout(creds).await?; + let req_id = session + .run_command("GETQUOTAROOT INBOX") + .await + .map_err(map_imap_error)?; + // Matching tagged OK/NO/BAD completes the command. Transport failures + // and incomplete replies still discard the session. + let (quota, reusable) = tokio::time::timeout(std::time::Duration::from_secs(15), async { + let mut quota = None; + while let Some(response) = session.read_response().await { + let response = response.map_err(|e| ImapError::ConnectionFailed(e.to_string()))?; + match response.parsed() { + Response::Quota(q) => { + for resource in &q.resources { + if matches!( + resource.name, + async_imap::imap_proto::types::QuotaResourceName::Storage + ) { + quota = Some(MailboxQuota { + usage_bytes: resource.usage * 1024, + limit_bytes: resource.limit * 1024, + }); + } + } + } + Response::Done { tag, status, .. } if *tag == req_id => { + return match status { + Status::Ok => Ok((quota, true)), + Status::No | Status::Bad => Ok((None, true)), + _ => Err(ImapError::ProtocolError( + "unexpected GETQUOTAROOT status".into(), + )), + }; + } + Response::Data { + status: Status::Bye, + .. + } => break, + _ => {} + } + } + Err(ImapError::ConnectionFailed( + "connection lost during GETQUOTAROOT".into(), + )) + }) + .await + .map_err(|_| ImapError::ConnectionFailed("GETQUOTAROOT timed out after 15s".into()))??; + if reusable { + session.recycle(); + } + Ok(quota) + } + + async fn fetch_folder_size( + &self, + creds: &ImapCredentials, + folder: &str, + ) -> Result { + let mut session = self.pool.checkout(creds).await?; + + let mailbox = session.select(folder).await.map_err(map_imap_error)?; + if mailbox.exists == 0 { + session.recycle(); + return Ok(0); + } + + let mut total: u64 = 0; + + // Require a tagged completion: async-imap's FETCH stream hides tagged + // NO/BAD and can end at EOF, which is unsafe when returning a pooled session. + let fetch_result = tokio::time::timeout(std::time::Duration::from_secs(60), async { + use async_imap::imap_proto::{Response, Status, types::AttributeValue}; + let req_id = session + .run_command("UID FETCH 1:* (RFC822.SIZE)") + .await + .map_err(map_imap_error)?; + while let Some(response) = session.read_response().await { + let response = response.map_err(|e| ImapError::ConnectionFailed(e.to_string()))?; + match response.parsed() { + Response::Fetch(_, attributes) => { + for attribute in attributes { + if let AttributeValue::Rfc822Size(size) = attribute { + total += u64::from(*size); + } + } + } + Response::Done { tag, status, .. } if *tag == req_id => { + return if *status == Status::Ok { + Ok(()) + } else { + Err(ImapError::ProtocolError( + "folder-size FETCH rejected".into(), + )) + }; + } + Response::Data { + status: Status::Bye, + .. + } => break, + _ => {} + } + } + Err(ImapError::ConnectionFailed( + "connection lost during folder-size FETCH".into(), + )) + }) + .await; + + match fetch_result { + Ok(Err(e)) => { + return Err(e); + } + Err(_) => { + tracing::warn!(folder = %folder, "fetch_folder_size timed out after 60s"); + } + Ok(Ok(())) => session.recycle(), + } + Ok(total) + } + + async fn folder_keyword_support( + &self, + creds: &ImapCredentials, + folder: &str, + ) -> Result { + let mut session = self.pool.checkout(creds).await?; + let mailbox = session.select(folder).await.map_err(map_imap_error)?; + let supported = mailbox.permanent_flags.is_empty() + || mailbox + .permanent_flags + .iter() + .any(|flag| matches!(flag, async_imap::types::Flag::MayCreate)); + session.recycle(); + Ok(supported) + } +} diff --git a/src/imap/connection.rs b/src/imap/connection.rs new file mode 100644 index 0000000..12475f8 --- /dev/null +++ b/src/imap/connection.rs @@ -0,0 +1,198 @@ +use super::error::ImapError; +use super::types::ImapCredentials; + +// Counts connection attempts by credential identity for isolated regression tests. +#[cfg(test)] +static CONNECTION_COUNTS: std::sync::LazyLock< + std::sync::Mutex>, +> = std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashMap::new())); + +#[cfg(test)] +pub(super) fn connection_count(creds: &ImapCredentials) -> usize { + *CONNECTION_COUNTS + .lock() + .unwrap() + .get(&super::pool::identity(creds)) + .unwrap_or(&0) +} + +// ---- Connection helper ---------------------------------------------------- + +/// Establish an authenticated IMAP session. +/// +/// Returns a `Session` over a TLS stream (when `creds.tls` is true) or a +/// plain TCP stream. Because the two stream types are different concrete +/// types we use an enum wrapper that implements the traits `async-imap` +/// requires (`tokio::io::AsyncRead + AsyncWrite + Unpin + Debug`). +pub(crate) async fn connect( + creds: &ImapCredentials, +) -> Result, ImapError> { + #[cfg(test)] + { + *CONNECTION_COUNTS + .lock() + .unwrap() + .entry(super::pool::identity(creds)) + .or_default() += 1; + } + let connect_future = tokio::net::TcpStream::connect((creds.host.as_str(), creds.port)); + // 10 second timeout for the initial TCP connection + let tcp = tokio::time::timeout(std::time::Duration::from_secs(10), connect_future) + .await + .map_err(|_| ImapError::ConnectionFailed("connection timed out".to_string()))? + .map_err(|e| ImapError::ConnectionFailed(e.to_string()))?; + + if creds.tls { + let tls = async_native_tls::TlsConnector::new(); + let tls_stream = tls + .connect(&creds.host, tcp) + .await + .map_err(|e| ImapError::ConnectionFailed(e.to_string()))?; + let client = async_imap::Client::new(ImapStream::Tls(tls_stream)); + let mut session = client + .login(&creds.email, &creds.password) + .await + .map_err(|(e, _)| classify_login_error(e))?; + maybe_enable_utf8_accept(&mut session).await?; + Ok(session) + } else { + let client = async_imap::Client::new(ImapStream::Plain(tcp)); + let mut session = client + .login(&creds.email, &creds.password) + .await + .map_err(|(e, _)| classify_login_error(e))?; + maybe_enable_utf8_accept(&mut session).await?; + Ok(session) + } +} + +/// Enable RFC 6855 UTF8=ACCEPT once per freshly authenticated session when +/// the server explicitly advertises both ENABLE and UTF8=ACCEPT. A tagged +/// NO/BAD is treated as a capability mismatch and leaves the session usable +/// in legacy mode; transport loss still invalidates the connection. +async fn maybe_enable_utf8_accept( + session: &mut async_imap::Session, +) -> Result<(), ImapError> { + use async_imap::imap_proto::{Response, Status}; + let capabilities = session.capabilities().await.map_err(map_imap_error)?; + let has_atom = |needle: &str| { + capabilities.iter().any(|capability| match capability { + async_imap::types::Capability::Atom(name) => name.eq_ignore_ascii_case(needle), + _ => false, + }) + }; + if !has_atom("ENABLE") || !has_atom("UTF8=ACCEPT") { + return Ok(()); + } + + let id = session + .run_command("ENABLE UTF8=ACCEPT") + .await + .map_err(map_imap_error)?; + while let Some(response) = session.read_response().await { + let response = response.map_err(|error| ImapError::ConnectionFailed(error.to_string()))?; + match response.parsed() { + Response::Done { tag, status, .. } if *tag == id => { + if *status != Status::Ok { + tracing::warn!( + ?status, + "IMAP server advertised UTF8=ACCEPT but rejected ENABLE; continuing without it" + ); + } + return Ok(()); + } + Response::Data { + status: Status::Bye, + .. + } => { + return Err(ImapError::ConnectionFailed( + "IMAP server closed the connection while enabling UTF8=ACCEPT".to_string(), + )); + } + _ => {} + } + } + Err(ImapError::ConnectionFailed( + "connection lost while enabling UTF8=ACCEPT".to_string(), + )) +} + +/// Classify an `async_imap::error::Error` that occurred during LOGIN. +pub(crate) fn classify_login_error(err: async_imap::error::Error) -> ImapError { + match err { + async_imap::error::Error::No(_) => ImapError::AuthenticationFailed, + async_imap::error::Error::Io(e) => ImapError::ConnectionFailed(e.to_string()), + async_imap::error::Error::ConnectionLost => { + ImapError::ConnectionFailed("connection lost".to_string()) + } + other => ImapError::ProtocolError(other.to_string()), + } +} + +/// Map a generic `async_imap` error to our `ImapError`. +pub(crate) fn map_imap_error(err: async_imap::error::Error) -> ImapError { + match err { + async_imap::error::Error::No(msg) => ImapError::ProtocolError(format!("NO: {msg}")), + async_imap::error::Error::Io(e) => ImapError::ConnectionFailed(e.to_string()), + async_imap::error::Error::ConnectionLost => { + ImapError::ConnectionFailed("connection lost".to_string()) + } + other => ImapError::ProtocolError(other.to_string()), + } +} + +// ---- Stream enum ---------------------------------------------------------- + +/// A wrapper enum so that `Session` can be generic over a single type +/// regardless of whether TLS is used. +#[derive(Debug)] +pub(crate) enum ImapStream { + Tls(async_native_tls::TlsStream), + Plain(tokio::net::TcpStream), +} + +impl tokio::io::AsyncRead for ImapStream { + fn poll_read( + self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + buf: &mut tokio::io::ReadBuf<'_>, + ) -> std::task::Poll> { + match self.get_mut() { + ImapStream::Tls(s) => std::pin::Pin::new(s).poll_read(cx, buf), + ImapStream::Plain(s) => std::pin::Pin::new(s).poll_read(cx, buf), + } + } +} + +impl tokio::io::AsyncWrite for ImapStream { + fn poll_write( + self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + buf: &[u8], + ) -> std::task::Poll> { + match self.get_mut() { + ImapStream::Tls(s) => std::pin::Pin::new(s).poll_write(cx, buf), + ImapStream::Plain(s) => std::pin::Pin::new(s).poll_write(cx, buf), + } + } + + fn poll_flush( + self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + match self.get_mut() { + ImapStream::Tls(s) => std::pin::Pin::new(s).poll_flush(cx), + ImapStream::Plain(s) => std::pin::Pin::new(s).poll_flush(cx), + } + } + + fn poll_shutdown( + self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + match self.get_mut() { + ImapStream::Tls(s) => std::pin::Pin::new(s).poll_shutdown(cx), + ImapStream::Plain(s) => std::pin::Pin::new(s).poll_shutdown(cx), + } + } +} diff --git a/src/imap/error.rs b/src/imap/error.rs new file mode 100644 index 0000000..1363be6 --- /dev/null +++ b/src/imap/error.rs @@ -0,0 +1,32 @@ +use std::fmt; + +/// Errors that can occur during IMAP operations. +#[derive(Debug)] +pub enum ImapError { + /// Could not connect to the IMAP server. + ConnectionFailed(String), + /// The server rejected our credentials. + AuthenticationFailed, + /// The requested folder does not exist. + FolderNotFound(String), + /// The requested message UID was not found in the given folder. + MessageNotFound { uid: u32, folder: String }, + /// A low-level IMAP protocol error. + ProtocolError(String), +} + +impl fmt::Display for ImapError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ImapError::ConnectionFailed(msg) => write!(f, "Connection failed: {msg}"), + ImapError::AuthenticationFailed => write!(f, "Authentication failed"), + ImapError::FolderNotFound(name) => write!(f, "Folder not found: {name}"), + ImapError::MessageNotFound { uid, folder } => { + write!(f, "Message UID {uid} not found in folder {folder}") + } + ImapError::ProtocolError(msg) => write!(f, "Protocol error: {msg}"), + } + } +} + +impl std::error::Error for ImapError {} diff --git a/src/imap/mock.rs b/src/imap/mock.rs new file mode 100644 index 0000000..7308c83 --- /dev/null +++ b/src/imap/mock.rs @@ -0,0 +1,832 @@ +use super::*; +use std::sync::Mutex; + +/// A mock IMAP client that returns pre-loaded data. +/// +/// Uses interior mutability (`Mutex`) so it can be shared behind `&self`. +/// Build it up with the `.with_*()` builder methods, then pass it wherever +/// an `&dyn ImapClient` is needed. +pub struct MockImapClient { + folders: Mutex>, + headers: Mutex>, + bodies: Mutex>, + folder_status: Mutex>, + folder_status_ext: Mutex>, + should_fail: Mutex>, + pub appended: Mutex)>>, + pub header_requests: Mutex>, + /// One-shot gate after taking a header snapshot, before returning it. + pub header_fetch_gate: Option<(tokio::sync::Notify, tokio::sync::Notify)>, +} + +impl MockImapClient { + /// Create a new empty mock. + pub fn new() -> Self { + Self { + folders: Mutex::new(Vec::new()), + headers: Mutex::new(Vec::new()), + bodies: Mutex::new(Vec::new()), + folder_status: Mutex::new(None), + folder_status_ext: Mutex::new(None), + should_fail: Mutex::new(None), + appended: Mutex::new(Vec::new()), + header_requests: Mutex::new(Vec::new()), + header_fetch_gate: None, + } + } + + /// Pre-load folders that `list_folders` will return. + pub fn with_folders(self, folders: Vec) -> Self { + *self.folders.lock().unwrap() = folders; + self + } + + /// Pre-load message headers that `fetch_headers` will return. + pub fn with_headers(self, headers: Vec) -> Self { + *self.headers.lock().unwrap() = headers; + self + } + + /// Pre-load a folder status that `folder_status` will return. + #[allow(dead_code)] + pub fn with_folder_status(self, status: FolderStatus) -> Self { + *self.folder_status.lock().unwrap() = Some(status); + self + } + + /// Pre-load an extended folder status that `folder_status_extended` will return. + #[allow(dead_code)] + pub fn with_folder_status_extended(self, status: FolderStatusExtended) -> Self { + *self.folder_status_ext.lock().unwrap() = Some(status); + self + } + + /// Pre-load message bodies that `fetch_body` will match against by UID. + pub fn with_bodies(self, bodies: Vec) -> Self { + *self.bodies.lock().unwrap() = bodies; + self + } + + /// Make every subsequent call return this error. + pub fn with_error(self, error: ImapError) -> Self { + *self.should_fail.lock().unwrap() = Some(error); + self + } +} + +/// Helper to clone an `ImapError` for the mock (the real errors are not +/// `Clone`, so we reconstruct them). +fn clone_error(err: &ImapError) -> ImapError { + match err { + ImapError::ConnectionFailed(msg) => ImapError::ConnectionFailed(msg.clone()), + ImapError::AuthenticationFailed => ImapError::AuthenticationFailed, + ImapError::FolderNotFound(name) => ImapError::FolderNotFound(name.clone()), + ImapError::MessageNotFound { uid, folder } => ImapError::MessageNotFound { + uid: *uid, + folder: folder.clone(), + }, + ImapError::ProtocolError(msg) => ImapError::ProtocolError(msg.clone()), + } +} + +#[async_trait] +impl ImapClient for MockImapClient { + async fn capabilities(&self, _creds: &ImapCredentials) -> Result, ImapError> { + if let Some(ref err) = *self.should_fail.lock().unwrap() { + return Err(clone_error(err)); + } + Ok(vec![ + "IMAP4REV1".to_string(), + "IDLE".to_string(), + "MOVE".to_string(), + "UIDPLUS".to_string(), + ]) + } + + async fn list_folders(&self, _creds: &ImapCredentials) -> Result, ImapError> { + if let Some(ref err) = *self.should_fail.lock().unwrap() { + return Err(clone_error(err)); + } + Ok(self.folders.lock().unwrap().clone()) + } + + async fn folder_status( + &self, + _creds: &ImapCredentials, + _folder: &str, + ) -> Result { + { + let fail = self.should_fail.lock().unwrap(); + if let Some(ref err) = *fail { + return Err(clone_error(err)); + } + } + { + let status = self.folder_status.lock().unwrap(); + if let Some(ref s) = *status { + return Ok(s.clone()); + } + } + // Derive from headers (separate lock scope). + let headers = self.headers.lock().unwrap(); + let exists = headers.len() as u32; + let uid_next = headers.iter().map(|h| h.uid).max().unwrap_or(0) + 1; + Ok(FolderStatus { + uid_validity: 1, + exists, + uid_next, + }) + } + + async fn fetch_headers( + &self, + _creds: &ImapCredentials, + folder: &str, + uid_range: &str, + ) -> Result, ImapError> { + self.header_requests + .lock() + .unwrap() + .push((folder.to_owned(), uid_range.to_owned())); + if let Some(ref err) = *self.should_fail.lock().unwrap() { + return Err(clone_error(err)); + } + let headers = self.headers.lock().unwrap().clone(); + if let Some((fetched, resume)) = &self.header_fetch_gate { + fetched.notify_one(); + resume.notified().await; + } + Ok(headers) + } + + async fn fetch_body( + &self, + _creds: &ImapCredentials, + _folder: &str, + uid: u32, + ) -> Result { + if let Some(ref err) = *self.should_fail.lock().unwrap() { + return Err(clone_error(err)); + } + let bodies = self.bodies.lock().unwrap(); + bodies + .iter() + .find(|b| b.uid == uid) + .cloned() + .ok_or_else(|| ImapError::MessageNotFound { + uid, + folder: _folder.to_string(), + }) + } + + async fn add_flags( + &self, + _creds: &ImapCredentials, + _folder: &str, + _uid: u32, + _flags: &[&str], + ) -> Result<(), ImapError> { + if let Some(ref err) = *self.should_fail.lock().unwrap() { + return Err(clone_error(err)); + } + Ok(()) + } + + async fn remove_flags( + &self, + _creds: &ImapCredentials, + _folder: &str, + _uid: u32, + _flags: &[&str], + ) -> Result<(), ImapError> { + if let Some(ref err) = *self.should_fail.lock().unwrap() { + return Err(clone_error(err)); + } + Ok(()) + } + + async fn set_flags( + &self, + _creds: &ImapCredentials, + _folder: &str, + _uid: u32, + _flags: &[&str], + ) -> Result<(), ImapError> { + if let Some(ref err) = *self.should_fail.lock().unwrap() { + return Err(clone_error(err)); + } + Ok(()) + } + + async fn move_message( + &self, + _creds: &ImapCredentials, + _from_folder: &str, + _uid: u32, + _to_folder: &str, + ) -> Result<(), ImapError> { + if let Some(ref err) = *self.should_fail.lock().unwrap() { + return Err(clone_error(err)); + } + Ok(()) + } + + async fn copy_message( + &self, + _creds: &ImapCredentials, + _from_folder: &str, + _uid: u32, + _to_folder: &str, + ) -> Result<(), ImapError> { + if let Some(ref err) = *self.should_fail.lock().unwrap() { + return Err(clone_error(err)); + } + Ok(()) + } + + async fn mark_all_read( + &self, + _creds: &ImapCredentials, + _folder: &str, + ) -> Result<(), ImapError> { + if let Some(ref err) = *self.should_fail.lock().unwrap() { + return Err(clone_error(err)); + } + Ok(()) + } + + async fn empty_folder( + &self, + _creds: &ImapCredentials, + _folder: &str, + ) -> Result<(), ImapError> { + if let Some(ref err) = *self.should_fail.lock().unwrap() { + return Err(clone_error(err)); + } + Ok(()) + } + + async fn expunge_message( + &self, + _creds: &ImapCredentials, + _folder: &str, + _uid: u32, + ) -> Result<(), ImapError> { + if let Some(ref err) = *self.should_fail.lock().unwrap() { + return Err(clone_error(err)); + } + Ok(()) + } + + async fn append_message( + &self, + _creds: &ImapCredentials, + folder: &str, + message_bytes: &[u8], + _flags: &[&str], + ) -> Result<(), ImapError> { + if let Some(ref err) = *self.should_fail.lock().unwrap() { + return Err(clone_error(err)); + } + self.appended + .lock() + .unwrap() + .push((folder.to_string(), message_bytes.to_vec())); + Ok(()) + } + + async fn append_message_resolved_uid( + &self, + creds: &ImapCredentials, + folder: &str, + message_bytes: &[u8], + _flags: &[&str], + _identity_header: &str, + _identity_value: &str, + ) -> Result<(u32, u32), ImapError> { + if let Some(ref err) = *self.should_fail.lock().unwrap() { + return Err(clone_error(err)); + } + let status = self.folder_status(creds, folder).await?; + self.appended + .lock() + .unwrap() + .push((folder.to_string(), message_bytes.to_vec())); + Ok((status.uid_next.max(1), status.uid_validity)) + } + + async fn create_folder( + &self, + _creds: &ImapCredentials, + _folder_name: &str, + ) -> Result<(), ImapError> { + if let Some(ref err) = *self.should_fail.lock().unwrap() { + return Err(clone_error(err)); + } + Ok(()) + } + + async fn rename_folder( + &self, + _creds: &ImapCredentials, + _from: &str, + _to: &str, + ) -> Result<(), ImapError> { + if let Some(ref err) = *self.should_fail.lock().unwrap() { + return Err(clone_error(err)); + } + Ok(()) + } + + async fn delete_folder( + &self, + _creds: &ImapCredentials, + _folder_name: &str, + ) -> Result<(), ImapError> { + if let Some(ref err) = *self.should_fail.lock().unwrap() { + return Err(clone_error(err)); + } + Ok(()) + } + + async fn subscribe_folder( + &self, + _creds: &ImapCredentials, + _folder_name: &str, + _subscribe: bool, + ) -> Result<(), ImapError> { + if let Some(ref err) = *self.should_fail.lock().unwrap() { + return Err(clone_error(err)); + } + Ok(()) + } + + async fn fetch_uids_and_flags( + &self, + _creds: &ImapCredentials, + _folder: &str, + ) -> Result)>, ImapError> { + if let Some(ref err) = *self.should_fail.lock().unwrap() { + return Err(clone_error(err)); + } + let headers = self.headers.lock().unwrap(); + Ok(headers.iter().map(|h| (h.uid, h.flags.clone())).collect()) + } + + async fn folder_status_extended( + &self, + _creds: &ImapCredentials, + _folder: &str, + ) -> Result { + { + let fail = self.should_fail.lock().unwrap(); + if let Some(ref err) = *fail { + return Err(clone_error(err)); + } + } + { + let status = self.folder_status_ext.lock().unwrap(); + if let Some(ref s) = *status { + return Ok(s.clone()); + } + } + // Derive from headers. + let headers = self.headers.lock().unwrap(); + let exists = headers.len() as u32; + let uid_next = headers.iter().map(|h| h.uid).max().unwrap_or(0) + 1; + Ok(FolderStatusExtended { + uid_validity: 1, + exists, + uid_next, + unseen: 0, + highest_modseq: 0, + }) + } + + async fn fetch_changed_flags( + &self, + _creds: &ImapCredentials, + _folder: &str, + _since_modseq: u64, + ) -> Result<(Vec<(u32, Vec)>, u64), ImapError> { + if let Some(ref err) = *self.should_fail.lock().unwrap() { + return Err(clone_error(err)); + } + // In mock, return all headers as "changed" with modseq 0. + let headers = self.headers.lock().unwrap(); + let items: Vec<(u32, Vec)> = + headers.iter().map(|h| (h.uid, h.flags.clone())).collect(); + Ok((items, 0)) + } + + async fn get_quota(&self, _creds: &ImapCredentials) -> Result, ImapError> { + if let Some(ref err) = *self.should_fail.lock().unwrap() { + return Err(clone_error(err)); + } + Ok(None) + } + + async fn fetch_folder_size( + &self, + _creds: &ImapCredentials, + _folder: &str, + ) -> Result { + if let Some(ref err) = *self.should_fail.lock().unwrap() { + return Err(clone_error(err)); + } + Ok(0) + } +} + +// ----------------------------------------------------------------------- +// Tests +// ----------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + /// Convenience helper to build dummy credentials for tests. + fn test_creds() -> ImapCredentials { + ImapCredentials { + host: "imap.example.com".to_string(), + port: 993, + tls: true, + email: "user@example.com".to_string(), + password: "hunter2".to_string(), + } + } + + #[tokio::test] + async fn mock_list_folders_returns_preloaded_data() { + let mock = MockImapClient::new().with_folders(vec![ + ImapFolder { + name: "INBOX".to_string(), + delimiter: Some("/".to_string()), + attributes: vec!["\\HasNoChildren".to_string()], + }, + ImapFolder { + name: "Sent".to_string(), + delimiter: Some("/".to_string()), + attributes: vec![], + }, + ]); + + let folders = mock.list_folders(&test_creds()).await.unwrap(); + assert_eq!(folders.len(), 2); + assert_eq!(folders[0].name, "INBOX"); + assert_eq!(folders[1].name, "Sent"); + } + + #[tokio::test] + async fn mock_fetch_headers_returns_preloaded_data() { + let mock = MockImapClient::new().with_headers(vec![ImapMessageHeader { + uid: 42, + subject: Some("Hello".to_string()), + from: vec![EmailAddress { + name: Some("Alice".to_string()), + address: "alice@example.com".to_string(), + }], + to: vec![EmailAddress { + name: None, + address: "bob@example.com".to_string(), + }], + date: Some("Mon, 1 Jan 2024 00:00:00 +0000".to_string()), + flags: vec!["\\Seen".to_string()], + has_attachments: false, + size: 1024, + message_id: None, + in_reply_to: None, + references: None, + cc: vec![], + reaction: None, + }]); + + let headers = mock + .fetch_headers(&test_creds(), "INBOX", "1:*") + .await + .unwrap(); + assert_eq!(headers.len(), 1); + assert_eq!(headers[0].uid, 42); + assert_eq!(headers[0].subject.as_deref(), Some("Hello")); + } + + #[tokio::test] + async fn mock_fetch_body_returns_matching_uid() { + let mock = MockImapClient::new().with_bodies(vec![ + ImapMessageBody { + uid: 1, + text_plain: Some("First message".to_string()), + text_html: None, + attachments: vec![], + raw_headers: String::new(), + }, + ImapMessageBody { + uid: 2, + text_plain: None, + text_html: Some("

Second

".to_string()), + attachments: vec![ImapAttachment { + filename: Some("doc.pdf".to_string()), + content_type: "application/pdf".to_string(), + size: 1024, + data: vec![0u8; 1024], + content_id: None, + }], + raw_headers: String::new(), + }, + ]); + + let body = mock.fetch_body(&test_creds(), "INBOX", 2).await.unwrap(); + assert_eq!(body.uid, 2); + assert!(body.text_html.is_some()); + assert_eq!(body.attachments.len(), 1); + assert_eq!(body.attachments[0].filename.as_deref(), Some("doc.pdf")); + } + + #[tokio::test] + async fn mock_fetch_body_returns_not_found_for_missing_uid() { + let mock = MockImapClient::new().with_bodies(vec![ImapMessageBody { + uid: 1, + text_plain: Some("only message".to_string()), + text_html: None, + attachments: vec![], + raw_headers: String::new(), + }]); + + let err = mock + .fetch_body(&test_creds(), "INBOX", 999) + .await + .unwrap_err(); + match err { + ImapError::MessageNotFound { uid, folder } => { + assert_eq!(uid, 999); + assert_eq!(folder, "INBOX"); + } + other => panic!("Expected MessageNotFound, got: {other}"), + } + } + + #[tokio::test] + async fn mock_with_error_overrides_all_methods() { + let mock = MockImapClient::new() + .with_folders(vec![ImapFolder { + name: "INBOX".to_string(), + delimiter: None, + attributes: vec![], + }]) + .with_error(ImapError::AuthenticationFailed); + + let err = mock.list_folders(&test_creds()).await.unwrap_err(); + assert!(matches!(err, ImapError::AuthenticationFailed)); + + let err = mock + .fetch_headers(&test_creds(), "INBOX", "1:*") + .await + .unwrap_err(); + assert!(matches!(err, ImapError::AuthenticationFailed)); + + let err = mock + .set_flags(&test_creds(), "INBOX", 1, &["\\Seen"]) + .await + .unwrap_err(); + assert!(matches!(err, ImapError::AuthenticationFailed)); + + let err = mock + .move_message(&test_creds(), "INBOX", 1, "Trash") + .await + .unwrap_err(); + assert!(matches!(err, ImapError::AuthenticationFailed)); + + let err = mock + .expunge_message(&test_creds(), "INBOX", 1) + .await + .unwrap_err(); + assert!(matches!(err, ImapError::AuthenticationFailed)); + } + + #[tokio::test] + async fn mock_set_flags_succeeds_without_error() { + let mock = MockImapClient::new(); + let result = mock + .set_flags(&test_creds(), "INBOX", 1, &["\\Seen", "\\Flagged"]) + .await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn mock_move_message_succeeds_without_error() { + let mock = MockImapClient::new(); + let result = mock + .move_message(&test_creds(), "INBOX", 1, "Archive") + .await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn mock_expunge_message_succeeds_without_error() { + let mock = MockImapClient::new(); + let result = mock.expunge_message(&test_creds(), "Trash", 5).await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn real_imap_client_connection_fails_with_bad_host() { + let client = RealImapClient::default(); + let creds = test_creds(); + + let err = client.list_folders(&creds).await.unwrap_err(); + // With a fake host the connection should fail. + assert!( + matches!(err, ImapError::ConnectionFailed(_)), + "Expected ConnectionFailed, got: {err}" + ); + } + + #[tokio::test] + async fn imap_error_display_formats_correctly() { + let cases: Vec<(ImapError, &str)> = vec![ + ( + ImapError::ConnectionFailed("timeout".to_string()), + "Connection failed: timeout", + ), + (ImapError::AuthenticationFailed, "Authentication failed"), + ( + ImapError::FolderNotFound("Drafts".to_string()), + "Folder not found: Drafts", + ), + ( + ImapError::MessageNotFound { + uid: 7, + folder: "INBOX".to_string(), + }, + "Message UID 7 not found in folder INBOX", + ), + ( + ImapError::ProtocolError("unexpected EOF".to_string()), + "Protocol error: unexpected EOF", + ), + ]; + + for (err, expected) in cases { + assert_eq!(err.to_string(), expected); + } + } + + #[tokio::test] + async fn email_address_serializes_and_deserializes() { + let addr = EmailAddress { + name: Some("Test User".to_string()), + address: "test@example.com".to_string(), + }; + + let json = serde_json::to_string(&addr).unwrap(); + let deserialized: EmailAddress = serde_json::from_str(&json).unwrap(); + assert_eq!(addr, deserialized); + } + + #[tokio::test] + async fn mock_append_message_succeeds() { + let mock = MockImapClient::new(); + let result = mock + .append_message( + &test_creds(), + "Sent", + b"From: test\r\n\r\nBody", + &["\\Seen"], + ) + .await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn mock_append_captures_data() { + let mock = MockImapClient::new(); + mock.append_message(&test_creds(), "Sent", b"test message", &["\\Seen"]) + .await + .unwrap(); + let appended = mock.appended.lock().unwrap(); + assert_eq!(appended.len(), 1); + assert_eq!(appended[0].0, "Sent"); + assert_eq!(appended[0].1, b"test message"); + } + + #[tokio::test] + async fn mock_empty_returns_empty_collections() { + let mock = MockImapClient::new(); + let creds = test_creds(); + + let folders = mock.list_folders(&creds).await.unwrap(); + assert!(folders.is_empty()); + + let headers = mock.fetch_headers(&creds, "INBOX", "1:*").await.unwrap(); + assert!(headers.is_empty()); + } + + #[tokio::test] + async fn mock_create_folder_succeeds() { + let mock = MockImapClient::new(); + let result = mock.create_folder(&test_creds(), "NewFolder").await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn mock_rename_folder_succeeds() { + let mock = MockImapClient::new(); + let result = mock + .rename_folder(&test_creds(), "OldName", "NewName") + .await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn mock_delete_folder_succeeds() { + let mock = MockImapClient::new(); + let result = mock.delete_folder(&test_creds(), "OldFolder").await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn mock_subscribe_folder_succeeds() { + let mock = MockImapClient::new(); + let result = mock + .subscribe_folder(&test_creds(), "SomeFolder", true) + .await; + assert!(result.is_ok()); + + let result = mock + .subscribe_folder(&test_creds(), "SomeFolder", false) + .await; + assert!(result.is_ok()); + } + + // ------------------------------------------------------------------- + // Integration tests (run manually against a real IMAP server) + // ------------------------------------------------------------------- + // + // cargo test real_imap -- --ignored + // + // Required env vars: + // TEST_IMAP_HOST (e.g. "imap.gmail.com") + // TEST_IMAP_PORT (e.g. "993") + // TEST_IMAP_EMAIL (e.g. "you@gmail.com") + // TEST_IMAP_PASSWORD (e.g. "app-password") + // TEST_IMAP_TLS (e.g. "true") + + fn real_creds() -> Option { + let host = std::env::var("TEST_IMAP_HOST").ok()?; + let port: u16 = std::env::var("TEST_IMAP_PORT").ok()?.parse().ok()?; + let email = std::env::var("TEST_IMAP_EMAIL").ok()?; + let password = std::env::var("TEST_IMAP_PASSWORD").ok()?; + let tls = std::env::var("TEST_IMAP_TLS") + .unwrap_or_else(|_| "true".to_string()) + .parse() + .unwrap_or(true); + Some(ImapCredentials { + host, + port, + tls, + email, + password, + }) + } + + #[tokio::test] + #[ignore] // Run manually: cargo test real_imap_list_folders -- --ignored + async fn real_imap_list_folders() { + let creds = real_creds().expect("TEST_IMAP_* env vars required"); + let client = RealImapClient::default(); + let folders = client.list_folders(&creds).await.unwrap(); + assert!(!folders.is_empty(), "expected at least one folder"); + let names: Vec<_> = folders.iter().map(|f| f.name.as_str()).collect(); + assert!( + names.iter().any(|n| n.eq_ignore_ascii_case("INBOX")), + "expected INBOX in folder list, got: {names:?}" + ); + } + + #[tokio::test] + #[ignore] // Run manually: cargo test real_imap_fetch_headers -- --ignored + async fn real_imap_fetch_headers() { + let creds = real_creds().expect("TEST_IMAP_* env vars required"); + let client = RealImapClient::default(); + let headers = client.fetch_headers(&creds, "INBOX", "1:5").await.unwrap(); + // The mailbox might be empty, so we just check it doesn't error. + for h in &headers { + assert!(h.uid > 0); + } + } + + #[tokio::test] + #[ignore] // Run manually: cargo test real_imap_fetch_body -- --ignored + async fn real_imap_fetch_body() { + let creds = real_creds().expect("TEST_IMAP_* env vars required"); + let client = RealImapClient::default(); + + // First fetch headers to find a UID. + let headers = client.fetch_headers(&creds, "INBOX", "1:1").await.unwrap(); + if let Some(h) = headers.first() { + let body = client.fetch_body(&creds, "INBOX", h.uid).await.unwrap(); + assert_eq!(body.uid, h.uid); + } + } +} diff --git a/src/imap/mod.rs b/src/imap/mod.rs new file mode 100644 index 0000000..392274d --- /dev/null +++ b/src/imap/mod.rs @@ -0,0 +1,6 @@ +pub mod client; +pub mod connection; +pub mod error; +pub mod parse; +pub(crate) mod pool; +pub mod types; diff --git a/src/imap/parse.rs b/src/imap/parse.rs new file mode 100644 index 0000000..9f661ce --- /dev/null +++ b/src/imap/parse.rs @@ -0,0 +1,193 @@ +use super::types::EmailAddress; + +// ---- Helper: convert NameAttribute to string ------------------------------ + +pub(crate) fn name_attribute_to_string(attr: &async_imap::types::NameAttribute<'_>) -> String { + use async_imap::types::NameAttribute; + match attr { + NameAttribute::NoInferiors => "\\Noinferiors".to_string(), + NameAttribute::NoSelect => "\\Noselect".to_string(), + NameAttribute::Marked => "\\Marked".to_string(), + NameAttribute::Unmarked => "\\Unmarked".to_string(), + NameAttribute::All => "\\All".to_string(), + NameAttribute::Archive => "\\Archive".to_string(), + NameAttribute::Drafts => "\\Drafts".to_string(), + NameAttribute::Flagged => "\\Flagged".to_string(), + NameAttribute::Junk => "\\Junk".to_string(), + NameAttribute::Sent => "\\Sent".to_string(), + NameAttribute::Trash => "\\Trash".to_string(), + NameAttribute::Extension(s) => s.to_string(), + _ => format!("{attr:?}"), + } +} + +// ---- Helper: convert Flag to string --------------------------------------- + +pub(crate) fn flag_to_string(flag: &async_imap::types::Flag<'_>) -> String { + use async_imap::types::Flag; + match flag { + Flag::Seen => "\\Seen".to_string(), + Flag::Answered => "\\Answered".to_string(), + Flag::Flagged => "\\Flagged".to_string(), + Flag::Deleted => "\\Deleted".to_string(), + Flag::Draft => "\\Draft".to_string(), + Flag::Recent => "\\Recent".to_string(), + Flag::MayCreate => "\\*".to_string(), + Flag::Custom(s) => s.to_string(), + } +} + +// ---- Helper: decode RFC 2047 encoded words -------------------------------- + +/// Decode RFC 2047 encoded-word sequences like `=?utf-8?b?SGVsbG8=?=`. +pub(crate) fn decode_rfc2047(input: &str) -> String { + // Quick check: if there's no encoded-word marker, return as-is. + if !input.contains("=?") { + return input.to_string(); + } + + let mut result = String::with_capacity(input.len()); + let mut remaining = input; + + while let Some(start) = remaining.find("=?") { + // Add any text before the encoded word. + result.push_str(&remaining[..start]); + + let after_start = &remaining[start + 2..]; + // Find charset?encoding?text?= pattern. + if let Some(q1) = after_start.find('?') { + let charset = &after_start[..q1]; + let after_charset = &after_start[q1 + 1..]; + if let Some(q2) = after_charset.find('?') { + let encoding = &after_charset[..q2]; + let after_encoding = &after_charset[q2 + 1..]; + if let Some(end) = after_encoding.find("?=") { + let encoded_text = &after_encoding[..end]; + let decoded_bytes = match encoding.to_ascii_uppercase().as_str() { + "B" => base64::Engine::decode( + &base64::engine::general_purpose::STANDARD, + encoded_text, + ) + .ok(), + "Q" => decode_quoted_printable_header(encoded_text), + _ => None, + }; + + if let Some(bytes) = decoded_bytes { + let text = decode_charset(charset, &bytes); + result.push_str(&text); + } else { + // Failed to decode — keep the original encoded word. + let full_len = 2 + q1 + 1 + q2 + 1 + end + 2; + result.push_str(&remaining[start..start + full_len]); + } + + // Skip past the encoded word. + let skip = q1 + 1 + q2 + 1 + end + 2; + remaining = &after_start[skip..]; + // Strip whitespace between consecutive encoded words (RFC 2047 §6.2). + if remaining.starts_with("=?") || remaining.trim_start().starts_with("=?") { + remaining = remaining.trim_start(); + } + continue; + } + } + } + + // Malformed encoded word — keep the `=?` and move past it. + result.push_str("=?"); + remaining = after_start; + } + + result.push_str(remaining); + result +} + +/// Decode quoted-printable as used in RFC 2047 headers (underscores = spaces). +pub(crate) fn decode_quoted_printable_header(input: &str) -> Option> { + let mut bytes = Vec::with_capacity(input.len()); + let mut chars = input.bytes(); + while let Some(b) = chars.next() { + match b { + b'_' => bytes.push(b' '), + b'=' => { + let hi = chars.next()?; + let lo = chars.next()?; + let hex = [hi, lo]; + let hex_str = std::str::from_utf8(&hex).ok()?; + let byte = u8::from_str_radix(hex_str, 16).ok()?; + bytes.push(byte); + } + _ => bytes.push(b), + } + } + Some(bytes) +} + +/// Best-effort charset decoding. UTF-8 and Latin-1 are the most common. +pub(crate) fn decode_charset(charset: &str, bytes: &[u8]) -> String { + match charset.to_ascii_lowercase().as_str() { + "utf-8" | "utf8" => String::from_utf8_lossy(bytes).into_owned(), + "iso-8859-1" | "latin1" | "latin-1" => bytes.iter().map(|&b| b as char).collect(), + // For other charsets, try UTF-8 first, then fall back to Latin-1. + _ => String::from_utf8(bytes.to_vec()) + .unwrap_or_else(|_| bytes.iter().map(|&b| b as char).collect()), + } +} + +// ---- Helper: convert imap_proto::Address to EmailAddress ------------------ + +pub(crate) fn imap_address_to_email(addr: &async_imap::imap_proto::types::Address<'_>) -> EmailAddress { + let name = addr + .name + .as_ref() + .and_then(|b| std::str::from_utf8(b).ok()) + .map(decode_rfc2047); + + let mailbox = addr + .mailbox + .as_ref() + .and_then(|b| std::str::from_utf8(b).ok()) + .unwrap_or(""); + let host = addr + .host + .as_ref() + .and_then(|b| std::str::from_utf8(b).ok()) + .unwrap_or(""); + + let address = if host.is_empty() { + mailbox.to_string() + } else { + format!("{mailbox}@{host}") + }; + + EmailAddress { name, address } +} + +// ---- Helper: detect attachments from BODYSTRUCTURE ----------------------- + +pub(crate) fn has_attachments(bs: &async_imap::imap_proto::types::BodyStructure<'_>) -> bool { + use async_imap::imap_proto::types::BodyStructure; + match bs { + BodyStructure::Basic { common, .. } => { + // Check for explicit "attachment" disposition. + if let Some(ref disp) = common.disposition + && disp.ty.eq_ignore_ascii_case("attachment") + { + return true; + } + // Non-text basic parts (image, application, audio, video) are likely attachments. + let ty = common.ty.ty.to_ascii_lowercase(); + matches!(ty.as_str(), "application" | "image" | "audio" | "video") + } + BodyStructure::Text { common, .. } => { + // Text parts with "attachment" disposition count. + if let Some(ref disp) = common.disposition { + return disp.ty.eq_ignore_ascii_case("attachment"); + } + false + } + BodyStructure::Message { body, .. } => has_attachments(body), + BodyStructure::Multipart { bodies, .. } => bodies.iter().any(has_attachments), + } +} diff --git a/src/imap/pool.rs b/src/imap/pool.rs new file mode 100644 index 0000000..6f6ef90 --- /dev/null +++ b/src/imap/pool.rs @@ -0,0 +1,551 @@ +//! Request-path sessions only; IDLE owns a separate connection. +use super::{ + connection::{ImapStream, connect}, + error::ImapError, + types::ImapCredentials, +}; +use sha2::{Digest, Sha256}; +use std::{ + collections::HashMap, + ops::{Deref, DerefMut}, + sync::{ + Arc, Mutex, + atomic::{AtomicBool, Ordering}, + }, + time::Duration, +}; +use tokio::{ + sync::{OwnedSemaphorePermit, Semaphore}, + time::Instant, +}; + +/// Mark IMAP operations in this future as background work. +pub(crate) async fn background(work: impl std::future::Future) -> T { + BACKGROUND.scope((), work).await +} + +tokio::task_local! { static BACKGROUND: (); } + +type Session = async_imap::Session; +const MAX_SESSIONS: usize = 3; +const IDLE_TTL: Duration = Duration::from_secs(120); +const CHECK_TIMEOUT: Duration = Duration::from_secs(10); + +#[derive(Default)] +pub(crate) struct ConnectionPool { + inner: Arc, +} +#[derive(Default)] +struct PoolInner { + users: Mutex>>, + reaper_started: AtomicBool, +} +struct UserPool { + identity_prefix: u32, + slots: Arc, + background_slots: Arc, + idle: Mutex>, +} + +// Length-prefix fields to avoid ambiguous identities. Never retain plaintext passwords. +pub(super) fn identity(creds: &ImapCredentials) -> [u8; 32] { + let mut hash = Sha256::new(); + for field in [ + creds.host.as_bytes(), + &creds.port.to_be_bytes(), + &[u8::from(creds.tls)], + creds.email.as_bytes(), + creds.password.as_bytes(), + ] { + hash.update((field.len() as u64).to_be_bytes()); + hash.update(field); + } + hash.finalize().into() +} + +// async-imap 0.10's noop parser accepts EOF without a tagged completion. +// Require the matching tagged OK ourselves so dead sockets cannot pass validation. +async fn validate(session: &mut Session) -> Result<(), ImapError> { + use async_imap::imap_proto::{Response, Status}; + let id = session + .run_command("NOOP") + .await + .map_err(super::connection::map_imap_error)?; + while let Some(response) = session.read_response().await { + let response = response.map_err(|e| ImapError::ConnectionFailed(e.to_string()))?; + match response.parsed() { + Response::Done { tag, status, .. } if *tag == id => { + return if *status == Status::Ok { + Ok(()) + } else { + Err(ImapError::ProtocolError("NOOP rejected".into())) + }; + } + Response::Data { + status: Status::Bye, + .. + } => break, + _ => {} + } + } + Err(ImapError::ConnectionFailed( + "connection lost during NOOP".into(), + )) +} + +impl PoolInner { + fn evict(&self) { + self.users.lock().unwrap().retain(|_, user| { + let mut idle = user.idle.lock().unwrap(); + // Dropping a session closes its socket; never CLOSE (which expunges). + idle.retain(|(returned, _)| returned.elapsed() < IDLE_TTL); + !idle.is_empty() || Arc::strong_count(user) > 1 + }); + } +} + +impl ConnectionPool { + /// Drop all reusable request-path IMAP sessions for one account. Active + /// leases are detached from the pool and will be closed when returned. + pub(crate) fn clear_user(&self, creds: &ImapCredentials) { + self.inner.users.lock().unwrap().remove(&identity(creds)); + } + + pub(crate) async fn checkout(&self, creds: &ImapCredentials) -> Result { + if !self.inner.reaper_started.swap(true, Ordering::Relaxed) { + let weak = Arc::downgrade(&self.inner); + tokio::spawn(async move { + loop { + tokio::time::sleep(Duration::from_secs(30)).await; + let Some(inner) = weak.upgrade() else { break }; + inner.evict(); + } + }); + } + let key = identity(creds); + let user = self + .inner + .users + .lock() + .unwrap() + .entry(key) + .or_insert_with(|| { + Arc::new(UserPool { + identity_prefix: u32::from_be_bytes(key[..4].try_into().unwrap()), + slots: Arc::new(Semaphore::new(MAX_SESSIONS)), + background_slots: Arc::new(Semaphore::new(MAX_SESSIONS - 1)), + idle: Mutex::new(Vec::new()), + }) + }) + .clone(); + // Wait outside the shared budget so queued sync work cannot starve reads. + let background_permit = if BACKGROUND.try_with(|_| ()).is_ok() { + Some( + user.background_slots + .clone() + .acquire_owned() + .await + .map_err(|_| ImapError::ConnectionFailed("pool closed".into()))?, + ) + } else { + None + }; + let permit = tokio::time::timeout(CHECK_TIMEOUT, user.slots.clone().acquire_owned()) + .await + .map_err(|_| ImapError::ConnectionFailed("pool checkout timed out".into()))? + .map_err(|_| ImapError::ConnectionFailed("pool closed".into()))?; + loop { + let candidate = user.idle.lock().unwrap().pop(); + let Some((returned, mut session)) = candidate else { + break; + }; + if returned.elapsed() < IDLE_TTL + && matches!( + tokio::time::timeout(CHECK_TIMEOUT, validate(&mut session)).await, + Ok(Ok(())) + ) + { + tracing::debug!( + pool_identity = format_args!("{:08x}", user.identity_prefix), + action = "reuse", + "IMAP pool checkout" + ); + return Ok(Lease { + session: Some(session), + user, + _permit: permit, + _background_permit: background_permit, + }); + } + tracing::debug!( + pool_identity = format_args!("{:08x}", user.identity_prefix), + action = "discard", + "IMAP pool validation failed or session expired" + ); + } + tracing::debug!( + pool_identity = format_args!("{:08x}", user.identity_prefix), + action = "connect", + "IMAP pool checkout" + ); + // Bound TCP, TLS and LOGIN together, retaining connect's TCP timeout too. + let session = tokio::time::timeout(CHECK_TIMEOUT, connect(creds)) + .await + .map_err(|_| ImapError::ConnectionFailed("connection timed out".into()))??; + Ok(Lease { + session: Some(session), + user, + _permit: permit, + _background_permit: background_permit, + }) + } +} + +/// Defaults to discard, including when a request is cancelled mid-command. +/// Only explicitly completed operations may return the session for reuse. +pub(crate) struct Lease { + session: Option, + user: Arc, + _permit: OwnedSemaphorePermit, + _background_permit: Option, +} +impl Lease { + pub(crate) fn recycle(mut self) { + tracing::debug!( + pool_identity = format_args!("{:08x}", self.user.identity_prefix), + action = "recycle", + "IMAP pool return" + ); + self.user + .idle + .lock() + .unwrap() + .push((Instant::now(), self.session.take().unwrap())); + } +} +impl Deref for Lease { + type Target = Session; + fn deref(&self) -> &Session { + self.session.as_ref().unwrap() + } +} +impl DerefMut for Lease { + fn deref_mut(&mut self) -> &mut Session { + self.session.as_mut().unwrap() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::imap::client::{ImapClient, RealImapClient}; + use std::sync::atomic::AtomicUsize; + use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + + async fn server() -> ( + ImapCredentials, + Arc, + Arc, + tokio::task::JoinHandle<()>, + ) { + server_with_quota("{tag} OK no quota configured\r\n").await + } + + async fn server_with_quota( + quota_reply: &'static str, + ) -> ( + ImapCredentials, + Arc, + Arc, + tokio::task::JoinHandle<()>, + ) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let creds = ImapCredentials { + host: "127.0.0.1".into(), + port: listener.local_addr().unwrap().port(), + tls: false, + email: "test@example.com".into(), + password: "secret".into(), + }; + let logins = Arc::new(AtomicUsize::new(0)); + let count = logins.clone(); + let kill_noop = Arc::new(AtomicBool::new(false)); + let kill = kill_noop.clone(); + let task = tokio::spawn(async move { + while let Ok((socket, _)) = listener.accept().await { + let count = count.clone(); + let kill = kill.clone(); + tokio::spawn(async move { + let mut socket = BufReader::new(socket); + socket + .get_mut() + .write_all(b"* OK test server\r\n") + .await + .unwrap(); + let mut line = String::new(); + let mut selected = String::new(); + loop { + line.clear(); + if socket.read_line(&mut line).await.unwrap_or(0) == 0 { + break; + } + let tag = line.split_whitespace().next().unwrap(); + if line.contains(" NOOP") && kill.swap(false, Ordering::SeqCst) { + break; + } + if line.contains(" GETQUOTAROOT ") && quota_reply.is_empty() { + break; + } + if line.contains(" UID FETCH ") && selected == "stalled" { + // No completion. The caller must discard on cancellation/timeout. + continue; + } + let response = if line.contains(" GETQUOTAROOT ") { + quota_reply.replace("{tag}", tag) + } else if line.contains(" UID FETCH ") && selected == "broken" { + break; + } else if line.contains(" UID FETCH ") && selected == "rejected" { + format!("{tag} NO fetch rejected\r\n") + } else if line.contains(" UID FETCH ") { + format!("* 1 FETCH (UID 1 RFC822.SIZE 42)\r\n{tag} OK fetched\r\n") + } else if line.contains(" LOGIN ") { + count.fetch_add(1, Ordering::SeqCst); + format!("{tag} OK authenticated\r\n") + } else if line.contains(" LIST ") { + format!("* LIST () \"/\" \"INBOX\"\r\n{tag} OK listed\r\n") + } else if line.contains(" SELECT ") && line.contains("missing") { + format!("{tag} NO missing folder\r\n") + } else if line.contains(" SELECT ") { + selected = line + .split_whitespace() + .nth(2) + .unwrap() + .trim_matches('"') + .to_string(); + let exists = if selected == "INBOX" || selected == "Archive" { + 0 + } else { + 1 + }; + format!( + "* {exists} EXISTS\r\n* OK [UIDVALIDITY 1] stable\r\n* OK [UIDNEXT 1] next\r\n{tag} OK selected\r\n" + ) + } else { + format!("{tag} OK done\r\n") + }; + if socket + .get_mut() + .write_all(response.as_bytes()) + .await + .is_err() + { + break; + } + } + }); + } + }); + (creds, logins, kill_noop, task) + } + + #[tokio::test] + async fn read_operations_reuse_login_and_errors_discard() { + let (creds, logins, _, task) = server().await; + let client = RealImapClient::default(); + for _ in 0..5 { + assert_eq!(client.list_folders(&creds).await.unwrap()[0].name, "INBOX"); + } + assert_eq!(logins.load(Ordering::SeqCst), 1); + assert!(client.folder_status(&creds, "missing").await.is_err()); + client.list_folders(&creds).await.unwrap(); + assert_eq!(logins.load(Ordering::SeqCst), 2); + task.abort(); + } + + #[tokio::test] + async fn background_work_leaves_an_interactive_slot() { + let (creds, _, _, task) = server().await; + let pool = ConnectionPool::default(); + let first = background(pool.checkout(&creds)).await.unwrap(); + let second = background(pool.checkout(&creds)).await.unwrap(); + let third = background(pool.checkout(&creds)); + tokio::pin!(third); + assert!( + tokio::time::timeout(Duration::from_millis(50), &mut third) + .await + .is_err() + ); + let interactive = tokio::time::timeout(Duration::from_millis(200), pool.checkout(&creds)) + .await + .expect("background work starved interactive checkout") + .unwrap(); + drop(interactive); + drop(first); + let third = third.await.unwrap(); + drop((second, third)); + task.abort(); + } + + #[tokio::test] + async fn bounds_exclusive_leases_and_discards_cancelled_work() { + let (creds, logins, _, task) = server().await; + let pool = ConnectionPool::default(); + let mut leases = Vec::new(); + for _ in 0..MAX_SESSIONS { + leases.push(pool.checkout(&creds).await.unwrap()); + } + assert!( + tokio::time::timeout(Duration::from_millis(50), pool.checkout(&creds)) + .await + .is_err() + ); + assert_eq!(logins.load(Ordering::SeqCst), MAX_SESSIONS); + drop(leases.pop()); // Unfinished work must not be reused. + let lease = pool.checkout(&creds).await.unwrap(); + assert_eq!(logins.load(Ordering::SeqCst), MAX_SESSIONS + 1); + lease.recycle(); + let reused = pool.checkout(&creds).await.unwrap(); + assert_eq!(logins.load(Ordering::SeqCst), MAX_SESSIONS + 1); + drop(reused); + task.abort(); + } + + #[tokio::test] + async fn stale_sessions_and_credential_identities_are_isolated() { + let (creds, logins, _, task) = server().await; + let pool = ConnectionPool::default(); + pool.checkout(&creds).await.unwrap().recycle(); + let mut changed = creds.clone(); + changed.password = "different".into(); + pool.checkout(&changed).await.unwrap().recycle(); + assert_eq!(logins.load(Ordering::SeqCst), 2); + { + let users = pool.inner.users.lock().unwrap(); + for user in users.values() { + for (returned, _) in user.idle.lock().unwrap().iter_mut() { + *returned = Instant::now() - IDLE_TTL; + } + } + } + pool.inner.evict(); + assert!(pool.inner.users.lock().unwrap().is_empty()); + pool.checkout(&creds).await.unwrap().recycle(); + assert_eq!(logins.load(Ordering::SeqCst), 3); + task.abort(); + } + #[tokio::test] + async fn dead_session_is_replaced_before_operation() { + let (creds, logins, kill_noop, task) = server().await; + let client = RealImapClient::default(); + client.list_folders(&creds).await.unwrap(); + kill_noop.store(true, Ordering::SeqCst); + client.list_folders(&creds).await.unwrap(); + assert_eq!(logins.load(Ordering::SeqCst), 2); + task.abort(); + } + #[tokio::test] + #[ignore = "requires TEST_IMAP_HOST, TEST_IMAP_EMAIL and TEST_IMAP_PASSWORD"] + async fn real_tls_reads_reuse_one_login() { + let creds = ImapCredentials { + host: std::env::var("TEST_IMAP_HOST").expect("TEST_IMAP_HOST required"), + port: 993, + tls: true, + email: std::env::var("TEST_IMAP_EMAIL").expect("TEST_IMAP_EMAIL required"), + password: std::env::var("TEST_IMAP_PASSWORD").expect("TEST_IMAP_PASSWORD required"), + }; + let client = RealImapClient::default(); + let before = super::super::connection::connection_count(&creds); + for _ in 0..3 { + client.list_folders(&creds).await.unwrap(); + client.folder_status(&creds, "INBOX").await.unwrap(); + client.fetch_headers(&creds, "INBOX", "1:1").await.unwrap(); + let quota = client.get_quota(&creds).await.unwrap(); + println!("Server returned quota: {}", quota.is_some()); + client.fetch_folder_size(&creds, "INBOX").await.unwrap(); + } + let connections = super::super::connection::connection_count(&creds) - before; + println!("15 TLS read operations established {connections} connections"); + assert_eq!(connections, 1); + } + + #[tokio::test] + async fn quota_fallback_reads_reuse_the_request_pool() { + let (creds, logins, _, task) = server().await; + let client = RealImapClient::default(); + for _ in 0..5 { + client.list_folders(&creds).await.unwrap(); + assert!(client.get_quota(&creds).await.unwrap().is_none()); + // A server without a configured quota triggers a size read per folder. + assert_eq!(client.fetch_folder_size(&creds, "INBOX").await.unwrap(), 0); + assert_eq!( + client.fetch_folder_size(&creds, "Archive").await.unwrap(), + 0 + ); + } + assert_eq!(logins.load(Ordering::SeqCst), 1); + task.abort(); + } + + #[tokio::test] + async fn quota_payload_is_consumed_before_reuse() { + let (creds, logins, _, task) = server_with_quota( + "* QUOTAROOT INBOX root\r\n* QUOTA root (STORAGE 10 100)\r\n{tag} OK quota\r\n", + ) + .await; + let client = RealImapClient::default(); + for _ in 0..3 { + let quota = client.get_quota(&creds).await.unwrap().unwrap(); + assert_eq!(quota.usage_bytes, 10 * 1024); + assert_eq!(quota.limit_bytes, 100 * 1024); + client.list_folders(&creds).await.unwrap(); + } + assert_eq!(logins.load(Ordering::SeqCst), 1); + task.abort(); + } + + #[tokio::test] + async fn rejected_quota_reuses_but_interrupted_quota_discards_session() { + for reply in [ + "{tag} NO unsupported\r\n", + "{tag} BAD unsupported\r\n", + "* BYE closing\r\n", + "", + ] { + let (creds, logins, _, task) = server_with_quota(reply).await; + let client = RealImapClient::default(); + let result = client.get_quota(&creds).await; + if reply.contains("unsupported") { + assert!(result.unwrap().is_none()); + } else { + assert!(result.is_err()); + } + client.list_folders(&creds).await.unwrap(); + assert_eq!( + logins.load(Ordering::SeqCst), + if reply.contains("unsupported") { 1 } else { 2 } + ); + task.abort(); + } + } + + #[tokio::test] + async fn folder_size_completion_reuses_and_incomplete_work_discards() { + let (creds, logins, _, task) = server().await; + let client = RealImapClient::default(); + for _ in 0..3 { + assert_eq!(client.fetch_folder_size(&creds, "Full").await.unwrap(), 42); + } + assert_eq!(logins.load(Ordering::SeqCst), 1); + for folder in ["rejected", "broken", "stalled"] { + let result = tokio::time::timeout( + Duration::from_millis(200), + client.fetch_folder_size(&creds, folder), + ) + .await; + assert!(!matches!(result, Ok(Ok(_)))); + client.list_folders(&creds).await.unwrap(); + } + assert_eq!(logins.load(Ordering::SeqCst), 4); + task.abort(); + } +} diff --git a/src/imap/types.rs b/src/imap/types.rs new file mode 100644 index 0000000..f5d74a2 --- /dev/null +++ b/src/imap/types.rs @@ -0,0 +1,124 @@ +use serde::{Deserialize, Serialize}; + +/// Represents an IMAP folder (mailbox). +#[derive(Debug, Clone, Serialize)] +pub struct ImapFolder { + /// Folder name as returned by the IMAP server (e.g. "INBOX", "Sent"). + pub name: String, + /// Delimiter used by the server (e.g. "/" or "."). + pub delimiter: Option, + /// IMAP attributes for this folder (e.g. `\Noselect`, `\HasChildren`). + pub attributes: Vec, +} + +/// A parsed email address with optional display name. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct EmailAddress { + /// Display name, if present (e.g. "Alice Smith"). + pub name: Option, + /// The actual email address (e.g. "alice@example.com"). + pub address: String, +} + +/// A lightweight summary of an email message (envelope data). +#[derive(Debug, Clone, Serialize)] +pub struct ImapMessageHeader { + /// IMAP UID of the message within its folder. + pub uid: u32, + /// Subject line. + pub subject: Option, + /// Sender(s) of the message. + pub from: Vec, + /// Recipient(s) of the message. + pub to: Vec, + /// Date header value (raw string from the server). + pub date: Option, + /// IMAP flags currently set on this message (e.g. `\Seen`, `\Flagged`). + pub flags: Vec, + /// Whether this message has attachments (derived from BODYSTRUCTURE). + pub has_attachments: bool, + /// RFC 2822 size of the message in bytes. + pub size: u32, + /// Message-ID header value for threading. + pub message_id: Option, + /// In-Reply-To header value for threading. + pub in_reply_to: Option, + /// References header value for threading. + pub references: Option, + /// CC addresses. + pub cc: Vec, + /// Reaction emoji if this is an Outlook/Exchange reaction notification. + pub reaction: Option, +} + +/// The full body of an email message, including attachments. +#[derive(Debug, Clone, Serialize)] +pub struct ImapMessageBody { + /// IMAP UID of the message within its folder. + pub uid: u32, + /// Plain-text body part, if available. + pub text_plain: Option, + /// HTML body part, if available. + pub text_html: Option, + /// List of attachments found in the message. + pub attachments: Vec, + /// Raw RFC 822 headers as a single string. + pub raw_headers: String, +} + +/// Metadata about a single attachment in an email message. +#[derive(Debug, Clone, Serialize)] +pub struct ImapAttachment { + /// Filename of the attachment, if provided by the sender. + pub filename: Option, + /// MIME content type (e.g. "application/pdf"). + pub content_type: String, + /// Size in bytes. + pub size: usize, + /// Raw attachment content. + pub data: Vec, + /// Content-ID for inline images (e.g. "image001@01D1234"), without angle brackets. + pub content_id: Option, +} + +/// Parameters needed to establish an IMAP connection. +/// Passed explicitly to every trait method so the trait stays stateless. +#[derive(Debug, Clone)] +pub struct ImapCredentials { + pub host: String, + pub port: u16, + pub tls: bool, + pub email: String, + pub password: String, +} + +/// Lightweight result of an IMAP `SELECT` command. +#[derive(Debug, Clone, Serialize)] +pub struct FolderStatus { + /// UIDVALIDITY - changes when the mailbox is rebuilt or UIDs are reassigned. + pub uid_validity: u32, + /// The total number of messages currently in the folder. + pub exists: u32, + /// The highest UID that will be assigned to the next appended message. + pub uid_next: u32, +} + +/// Mailbox quota information from IMAP GETQUOTAROOT. +#[derive(Debug, Clone, Serialize)] +pub struct MailboxQuota { + /// Storage used in bytes (STORAGE resource reports in KB, we convert). + pub usage_bytes: u64, + /// Storage limit in bytes. + pub limit_bytes: u64, +} + +/// Extended folder status from an IMAP `STATUS` command with CONDSTORE fields. +/// Used for cheap pre-checks before full sync. +#[derive(Debug, Clone, Serialize)] +pub struct FolderStatusExtended { + pub uid_validity: u32, + pub exists: u32, + pub uid_next: u32, + pub unseen: u32, + pub highest_modseq: u64, +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..6044a72 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,149 @@ +mod auth; +mod calendar; +mod config; +mod db; +mod email_theme; +mod error; +mod imap; +mod pgp; +mod realtime; +mod routes; +mod search; +mod sieve; +mod smtp; +mod ui; + +use std::sync::Arc; +use std::time::Duration; + +use config::AppConfig; +use tracing_subscriber::{EnvFilter, fmt, prelude::*}; + +use crate::auth::session::SessionStore; +use crate::imap::client::RealImapClient; +use crate::realtime::idle::IdleManager; +use crate::smtp::client::RealSmtpClient; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let args: Vec = std::env::args().collect(); + if args.iter().any(|arg| arg == "--version" || arg == "-V") { + println!("rustbox {}", env!("CARGO_PKG_VERSION")); + return Ok(()); + } + + // Initialize structured JSON logging with env filter. + // Default to INFO level; override with RUST_LOG env var. + tracing_subscriber::registry() + .with(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"))) + .with(fmt::layer().json()) + .init(); + + // Load configuration via figment (serde defaults + env vars). + let loaded_config = AppConfig::load()?; + loaded_config.validate().map_err(|message| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("invalid RustBox configuration: {message}"), + ) + })?; + if args.iter().any(|arg| arg == "--check-config") { + println!("RustBox configuration OK"); + return Ok(()); + } + let config = Arc::new(loaded_config); + + // Create the in-memory session store with the configured timeout. + let store = Arc::new(SessionStore::new(Duration::from_secs( + config.session_timeout_hours * 3600, + ))); + + // Spawn a background task that periodically purges expired sessions. + { + let store = Arc::clone(&store); + tokio::spawn(async move { + let mut interval = tokio::time::interval(Duration::from_secs(300)); + loop { + interval.tick().await; + store.purge_expired(); + tracing::debug!("Purged expired sessions"); + } + }); + } + + // Create the IMAP and SMTP clients for production use. + let imap_client: Arc = Arc::new(RealImapClient::default()); + let smtp_client: Arc = Arc::new(RealSmtpClient); + + // Create the Tantivy search engine for full-text indexing. + let search_engine = Arc::new(search::engine::SearchEngine::new(std::path::PathBuf::from( + &config.data_dir, + ))); + + // Create the real-time event bus and IDLE manager. + let event_bus = Arc::new(realtime::events::EventBus::new()); + let idle_manager = Arc::new(realtime::idle::IdleManager::new()); + + // Build the application router with auth, session, and static file serving. + let app = routes::create_router( + config.clone(), + store, + imap_client, + smtp_client, + search_engine, + event_bus, + idle_manager.clone(), + ); + + // Bind to the configured host and port. + let bind_addr = format!("{}:{}", config.host, config.port); + let listener = tokio::net::TcpListener::bind(&bind_addr).await?; + + tracing::info!( + host = %config.host, + port = %config.port, + "RustBox server starting" + ); + + axum::serve( + listener, + app.into_make_service_with_connect_info::(), + ) + .with_graceful_shutdown(shutdown_signal(idle_manager)) + .await?; + + tracing::info!("RustBox server stopped"); + Ok(()) +} + +async fn shutdown_signal(idle_manager: Arc) { + let ctrl_c = async { + if let Err(error) = tokio::signal::ctrl_c().await { + tracing::error!(error = %error, "Failed to install Ctrl+C handler"); + } + }; + + #[cfg(unix)] + let terminate = async { + match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) { + Ok(mut signal) => { + signal.recv().await; + } + Err(error) => { + tracing::error!(error = %error, "Failed to install SIGTERM handler"); + std::future::pending::<()>().await; + } + } + }; + + #[cfg(not(unix))] + let terminate = std::future::pending::<()>(); + + tokio::select! { + _ = ctrl_c => {}, + _ = terminate => {}, + } + + tracing::info!("Shutdown signal received; stopping IDLE tasks"); + idle_manager.stop_all(); +} diff --git a/src/pgp/discovery.rs b/src/pgp/discovery.rs new file mode 100644 index 0000000..99a6ee6 --- /dev/null +++ b/src/pgp/discovery.rs @@ -0,0 +1,315 @@ +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; +use std::time::Duration; + +use sha1::{Digest, Sha1}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpStream; +use tokio::time::timeout; +use url::Url; + +pub async fn discover_wkd( + email: &str, + timeout_secs: u64, + max_bytes: usize, +) -> Result>, String> { + let (local, domain) = split_email(email)?; + let hash = zbase32(&Sha1::digest(local.to_ascii_lowercase().as_bytes())); + let encoded_local: String = url::form_urlencoded::byte_serialize(local.as_bytes()).collect(); + let candidates = [ + format!( + "https://openpgpkey.{domain}/.well-known/openpgpkey/{domain}/hu/{hash}?l={encoded_local}" + ), + format!("https://{domain}/.well-known/openpgpkey/hu/{hash}?l={encoded_local}"), + ]; + let mut last_error = None; + for candidate in candidates { + match https_get(&candidate, timeout_secs, max_bytes).await { + Ok(Some(body)) => return Ok(Some(body)), + Ok(None) => {} + Err(error) => last_error = Some(error), + } + } + if let Some(error) = last_error { + tracing::debug!(email, error = %error, "WKD lookup did not yield a key"); + } + Ok(None) +} + +pub async fn discover_hkp( + base_url: &str, + email: &str, + timeout_secs: u64, + max_bytes: usize, +) -> Result>, String> { + let mut url = Url::parse(base_url).map_err(|e| format!("Invalid HKP URL: {e}"))?; + if url.scheme() != "https" || !url.username().is_empty() || url.password().is_some() { + return Err("HKP endpoint must be a credential-free HTTPS URL".to_string()); + } + url.set_path("/pks/lookup"); + url.set_query(None); + url.query_pairs_mut() + .append_pair("op", "get") + .append_pair("options", "mr") + .append_pair("search", email.trim()); + https_get(url.as_str(), timeout_secs, max_bytes).await +} + +async fn https_get( + url: &str, + timeout_secs: u64, + max_bytes: usize, +) -> Result>, String> { + let url = Url::parse(url).map_err(|e| format!("Invalid HTTPS URL: {e}"))?; + if url.scheme() != "https" || !url.username().is_empty() || url.password().is_some() { + return Err("OpenPGP discovery requires credential-free HTTPS".to_string()); + } + let host = url + .host_str() + .ok_or_else(|| "OpenPGP discovery URL has no host".to_string())?; + let port = url.port_or_known_default().unwrap_or(443); + let duration = Duration::from_secs(timeout_secs.clamp(1, 60)); + let addresses: Vec = timeout(duration, tokio::net::lookup_host((host, port))) + .await + .map_err(|_| "OpenPGP discovery DNS lookup timed out".to_string())? + .map_err(|e| format!("OpenPGP discovery DNS lookup failed: {e}"))? + .filter(|address| is_public_ip(address.ip())) + .collect(); + if addresses.is_empty() { + return Err("OpenPGP discovery host resolves only to non-public addresses".to_string()); + } + + let mut last_error = None; + for address in addresses { + match https_get_address(&url, host, port, address, duration, max_bytes).await { + Ok(result) => return Ok(result), + Err(error) => last_error = Some(error), + } + } + Err(last_error.unwrap_or_else(|| "OpenPGP discovery request failed".to_string())) +} + +async fn https_get_address( + url: &Url, + host: &str, + port: u16, + address: SocketAddr, + duration: Duration, + max_bytes: usize, +) -> Result>, String> { + let tcp = timeout(duration, TcpStream::connect(address)) + .await + .map_err(|_| "OpenPGP discovery connection timed out".to_string())? + .map_err(|e| format!("OpenPGP discovery connection failed: {e}"))?; + let tls = async_native_tls::TlsConnector::new(); + let mut stream = timeout(duration, tls.connect(host, tcp)) + .await + .map_err(|_| "OpenPGP discovery TLS handshake timed out".to_string())? + .map_err(|e| format!("OpenPGP discovery TLS handshake failed: {e}"))?; + + let mut target = url.path().to_string(); + if target.is_empty() { + target.push('/'); + } + if let Some(query) = url.query() { + target.push('?'); + target.push_str(query); + } + let host_header = if host.contains(':') { + if port == 443 { + format!("[{host}]") + } else { + format!("[{host}]:{port}") + } + } else if port == 443 { + host.to_string() + } else { + format!("{host}:{port}") + }; + let request = format!( + "GET {target} HTTP/1.1\r\nHost: {host_header}\r\nUser-Agent: RustBox/0.0.1\r\nAccept: application/octet-stream, application/pgp-keys, text/plain\r\nConnection: close\r\n\r\n" + ); + timeout(duration, stream.write_all(request.as_bytes())) + .await + .map_err(|_| "OpenPGP discovery request timed out".to_string())? + .map_err(|e| format!("OpenPGP discovery write failed: {e}"))?; + + let hard_limit = max_bytes.clamp(16 * 1024, 4 * 1024 * 1024) + 64 * 1024; + let mut response = Vec::with_capacity(8192); + let mut chunk = [0u8; 8192]; + loop { + let read = timeout(duration, stream.read(&mut chunk)) + .await + .map_err(|_| "OpenPGP discovery response timed out".to_string())? + .map_err(|e| format!("OpenPGP discovery read failed: {e}"))?; + if read == 0 { + break; + } + if response.len().saturating_add(read) > hard_limit { + return Err("OpenPGP discovery response exceeds configured size limit".to_string()); + } + response.extend_from_slice(&chunk[..read]); + } + + let header_end = response + .windows(4) + .position(|w| w == b"\r\n\r\n") + .ok_or_else(|| "OpenPGP discovery returned an invalid HTTP response".to_string())?; + let header_bytes = &response[..header_end]; + let body = &response[header_end + 4..]; + let headers = String::from_utf8_lossy(header_bytes); + let status = headers + .lines() + .next() + .and_then(|line| line.split_whitespace().nth(1)) + .and_then(|value| value.parse::().ok()) + .ok_or_else(|| "OpenPGP discovery returned an invalid HTTP status".to_string())?; + if status == 404 || status == 410 { + return Ok(None); + } + if !(200..300).contains(&status) { + return Err(format!("OpenPGP discovery returned HTTP {status}")); + } + if headers + .lines() + .any(|line| line.eq_ignore_ascii_case("Transfer-Encoding: chunked")) + { + let decoded = decode_chunked(body, max_bytes)?; + return Ok(Some(decoded)); + } + if body.len() > max_bytes { + return Err("OpenPGP key exceeds configured size limit".to_string()); + } + Ok(Some(body.to_vec())) +} + +fn decode_chunked(mut input: &[u8], max_bytes: usize) -> Result, String> { + let mut output = Vec::new(); + loop { + let line_end = input + .windows(2) + .position(|w| w == b"\r\n") + .ok_or_else(|| "Invalid chunked HTTP response".to_string())?; + let line = std::str::from_utf8(&input[..line_end]) + .map_err(|_| "Invalid HTTP chunk size".to_string())?; + let size_text = line.split(';').next().unwrap_or(line).trim(); + let size = usize::from_str_radix(size_text, 16) + .map_err(|_| "Invalid HTTP chunk size".to_string())?; + input = &input[line_end + 2..]; + if size == 0 { + break; + } + if input.len() < size + 2 || &input[size..size + 2] != b"\r\n" { + return Err("Truncated chunked HTTP response".to_string()); + } + if output.len().saturating_add(size) > max_bytes { + return Err("OpenPGP key exceeds configured size limit".to_string()); + } + output.extend_from_slice(&input[..size]); + input = &input[size + 2..]; + } + Ok(output) +} + +fn split_email(email: &str) -> Result<(&str, &str), String> { + let email = email.trim(); + let (local, domain) = email + .rsplit_once('@') + .ok_or_else(|| "Invalid email address".to_string())?; + if local.is_empty() || domain.is_empty() || domain.contains('/') || domain.contains('\\') { + return Err("Invalid email address".to_string()); + } + Ok((local, domain)) +} + +fn zbase32(bytes: &[u8]) -> String { + const ALPHABET: &[u8; 32] = b"ybndrfg8ejkmcpqxot1uwisza345h769"; + let mut output = String::new(); + let mut buffer: u32 = 0; + let mut bits = 0u8; + for &byte in bytes { + buffer = (buffer << 8) | u32::from(byte); + bits += 8; + while bits >= 5 { + bits -= 5; + output.push(ALPHABET[((buffer >> bits) & 0x1f) as usize] as char); + } + } + if bits > 0 { + output.push(ALPHABET[((buffer << (5 - bits)) & 0x1f) as usize] as char); + } + output +} + +fn is_public_ip(ip: IpAddr) -> bool { + match ip { + IpAddr::V4(ip) => is_public_ipv4(ip), + IpAddr::V6(ip) => is_public_ipv6(ip), + } +} + +fn is_public_ipv4(ip: Ipv4Addr) -> bool { + let [a, b, c, d] = ip.octets(); + if ip.is_private() + || ip.is_loopback() + || ip.is_link_local() + || ip.is_multicast() + || ip.is_broadcast() + || ip.is_unspecified() + { + return false; + } + !matches!( + (a, b, c, d), + (0, _, _, _) + | (100, 64..=127, _, _) + | (192, 0, 0, _) + | (192, 0, 2, _) + | (198, 18..=19, _, _) + | (198, 51, 100, _) + | (203, 0, 113, _) + | (240..=255, _, _, _) + ) +} + +fn is_public_ipv6(ip: Ipv6Addr) -> bool { + if ip.is_loopback() + || ip.is_unspecified() + || ip.is_multicast() + || ip.is_unique_local() + || ip.is_unicast_link_local() + { + return false; + } + let segments = ip.segments(); + if segments[0] == 0x2001 && segments[1] == 0x0db8 { + return false; + } + if let Some(v4) = ip.to_ipv4_mapped() { + return is_public_ipv4(v4); + } + true +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn wkd_hash_matches_zbase32_shape() { + let value = zbase32(&Sha1::digest(b"joe")); + assert_eq!(value.len(), 32); + assert!( + value + .chars() + .all(|c| "ybndrfg8ejkmcpqxot1uwisza345h769".contains(c)) + ); + } + + #[test] + fn blocks_private_addresses() { + assert!(!is_public_ip("127.0.0.1".parse().unwrap())); + assert!(!is_public_ip("10.0.0.1".parse().unwrap())); + assert!(!is_public_ip("::1".parse().unwrap())); + assert!(is_public_ip("1.1.1.1".parse().unwrap())); + } +} diff --git a/src/pgp/mod.rs b/src/pgp/mod.rs new file mode 100644 index 0000000..1a9283d --- /dev/null +++ b/src/pgp/mod.rs @@ -0,0 +1,1152 @@ +pub mod discovery; + +use std::fs; +use std::io::{BufRead, BufReader, Cursor, Read, Write}; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +use pgp::composed::{ + ArmorOptions, CleartextSignedMessage, Deserializable, DetachedSignature, EncryptionCaps, + KeyType, Message, MessageBuilder, SecretKeyParamsBuilder, SignedPublicKey, SignedSecretKey, + SubkeyParamsBuilder, +}; +use pgp::crypto::{ecc_curve::ECCCurve, hash::HashAlgorithm, sym::SymmetricKeyAlgorithm}; +use pgp::types::{KeyDetails, Password}; +use serde::Serialize as SerdeSerialize; + +const MAX_DECRYPTED_MESSAGE_BYTES: usize = 64 * 1024 * 1024; +use smallvec::smallvec; + +#[derive(Debug, Clone)] +pub struct ImportedKey { + pub fingerprint: String, + pub key_id: String, + pub user_id: String, + pub email: String, + pub public_armor: String, + pub secret_armor: Option, +} + +#[derive(Debug, Clone, SerdeSerialize)] +#[serde(rename_all = "camelCase")] +pub struct DecryptResult { + pub plaintext: String, + pub signed: bool, + pub verified: bool, + pub signer_fingerprint: Option, +} + +pub fn generate_key(name: &str, email: &str, passphrase: &str) -> Result { + let user_id = if name.trim().is_empty() { + email.trim().to_string() + } else { + format!("{} <{}>", name.trim(), email.trim()) + }; + + let passphrase = (!passphrase.is_empty()).then(|| passphrase.to_string()); + let subkey = SubkeyParamsBuilder::default() + .key_type(KeyType::ECDH(ECCCurve::Curve25519Legacy)) + .can_encrypt(EncryptionCaps::All) + .passphrase(passphrase.clone()) + .build() + .map_err(|e| format!("Failed to configure PGP encryption subkey: {e}"))?; + + let params = SecretKeyParamsBuilder::default() + .key_type(KeyType::Ed25519Legacy) + .can_certify(true) + .can_sign(true) + .primary_user_id(user_id) + .passphrase(passphrase) + .preferred_symmetric_algorithms(smallvec![ + SymmetricKeyAlgorithm::AES256, + SymmetricKeyAlgorithm::AES128, + ]) + .preferred_hash_algorithms(smallvec![HashAlgorithm::Sha256]) + .preferred_compression_algorithms(smallvec![]) + .subkeys(vec![subkey]) + .build() + .map_err(|e| format!("Failed to configure OpenPGP key: {e}"))?; + + let mut rng = rand08::thread_rng(); + let secret = params + .generate(&mut rng) + .map_err(|e| format!("Failed to generate OpenPGP key: {e}"))?; + secret + .verify_bindings() + .map_err(|e| format!("Generated OpenPGP key failed self-check: {e}"))?; + material_from_secret(secret) +} + +pub fn import_key(data: &[u8]) -> Result { + // rPGP exposes separate helpers for ASCII armor and binary packet streams. + // Accept both so keys exported by GnuPG, Thunderbird and WKD/HKP all work. + let armored = data.starts_with(b"-----BEGIN PGP"); + if armored { + let text = std::str::from_utf8(data) + .map_err(|_| "Armored OpenPGP key is not valid UTF-8".to_string())?; + if let Ok((secret, _)) = SignedSecretKey::from_string(text) { + secret + .verify_bindings() + .map_err(|e| format!("Invalid OpenPGP secret-key bindings: {e}"))?; + return material_from_secret(secret); + } + let (public, _) = SignedPublicKey::from_string(text) + .map_err(|e| format!("Input is not a supported OpenPGP public or secret key: {e}"))?; + public + .verify_bindings() + .map_err(|e| format!("Invalid OpenPGP public-key bindings: {e}"))?; + return material_from_public(public); + } + + if let Ok((secret, _)) = SignedSecretKey::from_reader_single(Cursor::new(data)) { + secret + .verify_bindings() + .map_err(|e| format!("Invalid OpenPGP secret-key bindings: {e}"))?; + return material_from_secret(secret); + } + let (public, _) = SignedPublicKey::from_reader_single(Cursor::new(data)) + .map_err(|e| format!("Input is not a supported OpenPGP public or secret key: {e}"))?; + public + .verify_bindings() + .map_err(|e| format!("Invalid OpenPGP public-key bindings: {e}"))?; + material_from_public(public) +} + +fn material_from_secret(secret: SignedSecretKey) -> Result { + let public = secret.to_public_key(); + let mut material = material_from_public(public)?; + material.secret_armor = Some( + secret + .to_armored_string(ArmorOptions::default()) + .map_err(|e| format!("Failed to armor secret key: {e}"))?, + ); + Ok(material) +} + +fn material_from_public(public: SignedPublicKey) -> Result { + let fingerprint = normalize_fingerprint(&format!("{}", public.primary_key.fingerprint())); + let key_id = fingerprint + .chars() + .rev() + .take(16) + .collect::() + .chars() + .rev() + .collect::(); + let user_id = public + .details + .users + .first() + .map(|user| String::from_utf8_lossy(user.id.id()).trim().to_string()) + .unwrap_or_default(); + let email = extract_email(&user_id); + let public_armor = public + .to_armored_string(ArmorOptions::default()) + .map_err(|e| format!("Failed to armor public key: {e}"))?; + Ok(ImportedKey { + fingerprint, + key_id, + user_id, + email, + public_armor, + secret_armor: None, + }) +} + +pub fn sign_detached(data: &[u8], secret_armor: &str, passphrase: &str) -> Result { + let (secret, _) = SignedSecretKey::from_string(secret_armor) + .map_err(|e| format!("Failed to parse OpenPGP secret key: {e}"))?; + let password: Password = passphrase.into(); + let signature = DetachedSignature::sign_binary_data( + rand08::thread_rng(), + &secret.primary_key, + &password, + HashAlgorithm::Sha256, + data, + ) + .map_err(|e| format!("OpenPGP signing failed: {e}"))?; + signature + .to_armored_string(ArmorOptions::default()) + .map_err(|e| format!("Failed to armor OpenPGP signature: {e}")) +} + +pub fn test_secret_key_passphrase(secret_armor: &str, passphrase: &str) -> Result<(), String> { + // The key is never modified and the passphrase is not persisted. A short + // detached signature forces rPGP to unlock the private signing material. + // RustBox-generated keys always have a signing-capable primary key. + sign_detached( + b"RustBox OpenPGP unlock self-test", + secret_armor, + passphrase, + ) + .map(|_| ()) +} + +pub fn verify_detached( + data: &[u8], + signature_armor: &str, + public_armor: &str, +) -> Result<(), String> { + let (signature, _) = DetachedSignature::from_string(signature_armor) + .map_err(|e| format!("Failed to parse OpenPGP signature: {e}"))?; + let (public, _) = SignedPublicKey::from_string(public_armor) + .map_err(|e| format!("Failed to parse OpenPGP public key: {e}"))?; + signature + .verify(&public, data) + .map_err(|e| format!("OpenPGP signature verification failed: {e}")) +} + +pub fn encrypt_bytes(data: &[u8], public_armors: &[String]) -> Result { + if public_armors.is_empty() { + return Err("At least one OpenPGP recipient key is required".to_string()); + } + let keys = parse_public_keys(public_armors)?; + let mut rng = rand08::thread_rng(); + let mut builder = MessageBuilder::from_bytes("message.eml", data.to_vec()) + .seipd_v1(&mut rng, SymmetricKeyAlgorithm::AES256); + for key in &keys { + if let Some(subkey) = key.public_subkeys.first() { + builder + .encrypt_to_key(&mut rng, subkey) + .map_err(|e| format!("Failed to add OpenPGP recipient: {e}"))?; + } else { + builder + .encrypt_to_key(&mut rng, &key.primary_key) + .map_err(|e| format!("Failed to add OpenPGP recipient: {e}"))?; + } + } + builder + .to_armored_string(&mut rng, ArmorOptions::default()) + .map_err(|e| format!("OpenPGP encryption failed: {e}")) +} + +pub fn inline_sign(text: &str, secret_armor: &str, passphrase: &str) -> Result { + let (secret, _) = SignedSecretKey::from_string(secret_armor) + .map_err(|e| format!("Failed to parse OpenPGP secret key: {e}"))?; + let password: Password = passphrase.into(); + CleartextSignedMessage::sign(rand08::thread_rng(), text, &secret.primary_key, &password) + .and_then(|signed| signed.to_armored_string(ArmorOptions::default())) + .map_err(|e| format!("OpenPGP inline signing failed: {e}")) +} + +pub fn inline_encrypt( + text: &str, + public_armors: &[String], + signer: Option<(&str, &str)>, +) -> Result { + if public_armors.is_empty() { + return Err("At least one OpenPGP recipient key is required".to_string()); + } + let keys = parse_public_keys(public_armors)?; + let signing_secret = if let Some((secret_armor, _)) = signer { + let (secret, _) = SignedSecretKey::from_string(secret_armor) + .map_err(|e| format!("Failed to parse OpenPGP secret key: {e}"))?; + Some(secret) + } else { + None + }; + let mut rng = rand08::thread_rng(); + let mut builder = MessageBuilder::from_bytes("message.txt", text.as_bytes().to_vec()) + .seipd_v1(&mut rng, SymmetricKeyAlgorithm::AES256); + if let (Some(secret), Some((_, passphrase))) = (signing_secret.as_ref(), signer) { + let password: Password = passphrase.into(); + builder.sign(&secret.primary_key, password, HashAlgorithm::Sha256); + } + for key in &keys { + if let Some(subkey) = key.public_subkeys.first() { + builder + .encrypt_to_key(&mut rng, subkey) + .map_err(|e| format!("Failed to add OpenPGP recipient: {e}"))?; + } else { + builder + .encrypt_to_key(&mut rng, &key.primary_key) + .map_err(|e| format!("Failed to add OpenPGP recipient: {e}"))?; + } + } + builder + .to_armored_string(&mut rng, ArmorOptions::default()) + .map_err(|e| format!("OpenPGP inline encryption failed: {e}")) +} + +pub fn decrypt_inline( + armored: &str, + secret_armor: &str, + passphrase: &str, + public_armors: &[String], +) -> Result { + let (secret, _) = SignedSecretKey::from_string(secret_armor) + .map_err(|e| format!("Failed to parse OpenPGP secret key: {e}"))?; + let (message, _) = Message::from_string(armored) + .map_err(|e| format!("Failed to parse OpenPGP message: {e}"))?; + let password: Password = passphrase.into(); + let mut decrypted = message + .decrypt(&password, &secret) + .map_err(|e| format!("OpenPGP decryption failed: {e}"))?; + let signed = decrypted.is_signed() || decrypted.is_one_pass_signed(); + let plaintext_bytes = read_message_limited(&mut decrypted)?; + let plaintext = String::from_utf8(plaintext_bytes) + .map_err(|_| "Decrypted OpenPGP payload is not valid UTF-8/MIME text".to_string())?; + + let mut verified = false; + let mut signer_fingerprint = None; + if signed { + for armor in public_armors { + if let Ok((public, _)) = SignedPublicKey::from_string(armor) { + if decrypted.verify(&public).is_ok() { + verified = true; + signer_fingerprint = Some(normalize_fingerprint(&format!( + "{}", + public.primary_key.fingerprint() + ))); + break; + } + } + } + } + + Ok(DecryptResult { + plaintext, + signed, + verified, + signer_fingerprint, + }) +} + +pub fn verify_inline(armored: &str, public_armor: &str) -> Result { + let (public, _) = SignedPublicKey::from_string(public_armor) + .map_err(|e| format!("Failed to parse OpenPGP public key: {e}"))?; + if armored.contains("-----BEGIN PGP SIGNED MESSAGE-----") { + let (message, _) = CleartextSignedMessage::from_string(armored) + .map_err(|e| format!("Failed to parse OpenPGP cleartext signature: {e}"))?; + message + .verify(&public) + .map_err(|e| format!("OpenPGP signature verification failed: {e}"))?; + return Ok(message.signed_text()); + } + let (mut message, _) = Message::from_string(armored) + .map_err(|e| format!("Failed to parse OpenPGP message: {e}"))?; + let text = String::from_utf8(read_message_limited(&mut message)?) + .map_err(|_| "OpenPGP signed payload is not valid UTF-8 text".to_string())?; + message + .verify(&public) + .map_err(|e| format!("OpenPGP signature verification failed: {e}"))?; + Ok(text) +} + +fn read_message_limited(message: &mut Message<'_>) -> Result, String> { + let mut output = Vec::new(); + let limit = MAX_DECRYPTED_MESSAGE_BYTES.saturating_add(1) as u64; + (&mut *message) + .take(limit) + .read_to_end(&mut output) + .map_err(|e| format!("Failed to read OpenPGP payload: {e}"))?; + if output.len() > MAX_DECRYPTED_MESSAGE_BYTES { + return Err("OpenPGP payload exceeds the safe processing limit".to_string()); + } + Ok(output) +} + +fn parse_public_keys(armors: &[String]) -> Result, String> { + armors + .iter() + .map(|armor| { + SignedPublicKey::from_string(armor) + .map(|(key, _)| key) + .map_err(|e| format!("Failed to parse OpenPGP public key: {e}")) + }) + .collect() +} + +pub fn private_key_dir(data_dir: &str, user_hash: &str) -> PathBuf { + Path::new(data_dir) + .join(user_hash) + .join("pgp") + .join("private") +} + +pub fn store_secret_key( + data_dir: &str, + user_hash: &str, + fingerprint: &str, + armor: &str, +) -> Result { + let dir = private_key_dir(data_dir, user_hash); + fs::create_dir_all(&dir).map_err(|e| format!("Failed to create PGP key directory: {e}"))?; + set_dir_permissions(&dir)?; + let safe = normalize_fingerprint(fingerprint); + if safe.is_empty() || !safe.chars().all(|c| c.is_ascii_hexdigit()) { + return Err("Invalid OpenPGP fingerprint".to_string()); + } + let final_path = dir.join(format!("{safe}.asc")); + let temp_path = dir.join(format!(".{safe}.{}.part", uuid::Uuid::new_v4())); + write_private_file(&temp_path, armor.as_bytes())?; + fs::rename(&temp_path, &final_path).map_err(|e| { + let _ = fs::remove_file(&temp_path); + format!("Failed to install OpenPGP private key: {e}") + })?; + Ok(final_path.to_string_lossy().into_owned()) +} + +pub fn read_secret_key(path: &str) -> Result { + fs::read_to_string(path).map_err(|e| format!("Failed to read OpenPGP private key: {e}")) +} + +pub fn delete_secret_key(path: &str) -> Result<(), String> { + match fs::remove_file(path) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(format!("Failed to delete OpenPGP private key: {e}")), + } +} + +/// Change the passphrase protecting an existing private key without exposing +/// passphrases in argv/environment. rPGP validates both the old and resulting +/// key; GnuPG is used only as a mature secret-packet re-wrapping engine. +pub fn change_secret_key_passphrase( + data_dir: &str, + user_hash: &str, + fingerprint: &str, + secret_path: &str, + old_passphrase: &str, + new_passphrase: &str, + gpg_binary: &str, +) -> Result<(), String> { + if old_passphrase.len() > 1024 || new_passphrase.len() > 1024 { + return Err("OpenPGP passphrase is too long".to_string()); + } + let safe = normalize_fingerprint(fingerprint); + if safe.is_empty() || !safe.chars().all(|c| c.is_ascii_hexdigit()) { + return Err("Invalid OpenPGP fingerprint".to_string()); + } + + let current = read_secret_key(secret_path)?; + test_secret_key_passphrase(¤t, old_passphrase) + .map_err(|_| "Current OpenPGP passphrase is incorrect".to_string())?; + + let private_dir = private_key_dir(data_dir, user_hash); + fs::create_dir_all(&private_dir) + .map_err(|e| format!("Failed to create OpenPGP key directory: {e}"))?; + set_dir_permissions(&private_dir)?; + let work_dir = private_dir.join(format!(".rewrap-{}", uuid::Uuid::new_v4())); + fs::create_dir(&work_dir) + .map_err(|e| format!("Failed to create OpenPGP rewrap directory: {e}"))?; + set_dir_permissions(&work_dir)?; + + let result = (|| { + let import = Command::new(gpg_binary) + .arg("--homedir") + .arg(&work_dir) + .args(["--no-options", "--batch", "--yes", "--import"]) + .arg(secret_path) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .output() + .map_err(|e| format!("OpenPGP passphrase change requires GnuPG ({gpg_binary}): {e}"))?; + if !import.status.success() { + return Err("GnuPG could not import the private key for passphrase change".to_string()); + } + + let mut child = Command::new(gpg_binary) + .arg("--homedir") + .arg(&work_dir) + .args([ + "--no-options", + "--batch", + "--yes", + "--pinentry-mode", + "loopback", + "--status-fd", + "2", + "--command-fd", + "0", + "--passphrase-repeat", + "0", + "--passwd", + ]) + .arg(&safe) + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| format!("Failed to start GnuPG passphrase change: {e}"))?; + let mut stdin = child + .stdin + .take() + .ok_or_else(|| "Failed to open GnuPG command input".to_string())?; + let stderr = child + .stderr + .take() + .ok_or_else(|| "Failed to open GnuPG status output".to_string())?; + let mut hidden_prompts = 0usize; + let mut bad_passphrase = false; + for line in BufReader::new(stderr).lines() { + let line = line.map_err(|e| format!("Failed to read GnuPG status: {e}"))?; + if line.contains("[GNUPG:] BAD_PASSPHRASE") { + bad_passphrase = true; + } else if line.contains("[GNUPG:] GET_HIDDEN") { + let response = if hidden_prompts == 0 && !old_passphrase.is_empty() { + old_passphrase + } else { + new_passphrase + }; + stdin + .write_all(response.as_bytes()) + .map_err(|e| format!("Failed to answer GnuPG passphrase prompt: {e}"))?; + stdin + .write_all(b"\n") + .map_err(|e| format!("Failed to answer GnuPG passphrase prompt: {e}"))?; + stdin + .flush() + .map_err(|e| format!("Failed to flush GnuPG passphrase prompt: {e}"))?; + hidden_prompts += 1; + } else if line.contains("[GNUPG:] GET_BOOL") { + stdin + .write_all(b"y\n") + .map_err(|e| format!("Failed to answer GnuPG confirmation: {e}"))?; + stdin + .flush() + .map_err(|e| format!("Failed to flush GnuPG confirmation: {e}"))?; + } else if line.contains("[GNUPG:] GET_LINE") { + stdin + .write_all(b"\n") + .map_err(|e| format!("Failed to answer GnuPG prompt: {e}"))?; + stdin + .flush() + .map_err(|e| format!("Failed to flush GnuPG prompt: {e}"))?; + } + } + drop(stdin); + let status = child + .wait() + .map_err(|e| format!("Failed to wait for GnuPG passphrase change: {e}"))?; + if bad_passphrase || !status.success() { + return Err("GnuPG rejected the OpenPGP passphrase change".to_string()); + } + + let mut export = Command::new(gpg_binary) + .arg("--homedir") + .arg(&work_dir) + .args([ + "--no-options", + "--batch", + "--yes", + "--pinentry-mode", + "loopback", + "--passphrase-fd", + "0", + "--armor", + "--export-secret-keys", + ]) + .arg(&safe) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .map_err(|e| format!("Failed to start GnuPG private-key export: {e}"))?; + if let Some(mut input) = export.stdin.take() { + input + .write_all(new_passphrase.as_bytes()) + .map_err(|e| format!("Failed to provide GnuPG export passphrase: {e}"))?; + input + .write_all(b"\n") + .map_err(|e| format!("Failed to provide GnuPG export passphrase: {e}"))?; + } + let output = export + .wait_with_output() + .map_err(|e| format!("Failed to export rewrapped OpenPGP key: {e}"))?; + if !output.status.success() || output.stdout.is_empty() { + return Err("GnuPG did not return the rewrapped OpenPGP private key".to_string()); + } + let armor = String::from_utf8(output.stdout) + .map_err(|_| "GnuPG returned non-UTF-8 armored private-key data".to_string())?; + let imported = import_key(armor.as_bytes())?; + if normalize_fingerprint(&imported.fingerprint) != safe { + return Err("Rewrapped OpenPGP key fingerprint changed unexpectedly".to_string()); + } + let secret = imported + .secret_armor + .ok_or_else(|| "Rewrapped key contains no private material".to_string())?; + test_secret_key_passphrase(&secret, new_passphrase).map_err(|_| { + "Rewrapped OpenPGP key could not be unlocked with the new passphrase".to_string() + })?; + store_secret_key(data_dir, user_hash, &safe, &secret)?; + Ok(()) + })(); + + let _ = fs::remove_dir_all(&work_dir); + result +} + +#[cfg(unix)] +fn set_dir_permissions(path: &Path) -> Result<(), String> { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(path, fs::Permissions::from_mode(0o700)) + .map_err(|e| format!("Failed to protect PGP key directory: {e}")) +} + +#[cfg(not(unix))] +fn set_dir_permissions(_path: &Path) -> Result<(), String> { + Ok(()) +} + +#[cfg(unix)] +fn write_private_file(path: &Path, data: &[u8]) -> Result<(), String> { + use std::io::Write; + use std::os::unix::fs::OpenOptionsExt; + let mut file = fs::OpenOptions::new() + .create_new(true) + .write(true) + .mode(0o600) + .open(path) + .map_err(|e| format!("Failed to create OpenPGP private key: {e}"))?; + file.write_all(data) + .map_err(|e| format!("Failed to write OpenPGP private key: {e}"))?; + file.sync_all() + .map_err(|e| format!("Failed to sync OpenPGP private key: {e}")) +} + +#[cfg(not(unix))] +fn write_private_file(path: &Path, data: &[u8]) -> Result<(), String> { + fs::write(path, data).map_err(|e| format!("Failed to write OpenPGP private key: {e}")) +} + +fn extract_email(user_id: &str) -> String { + if let Some(start) = user_id.rfind('<') + && let Some(end_rel) = user_id[start + 1..].find('>') + { + let value = user_id[start + 1..start + 1 + end_rel].trim(); + if value.contains('@') { + return value.to_ascii_lowercase(); + } + } + user_id + .split_whitespace() + .map(|part| part.trim_matches(|c: char| matches!(c, '<' | '>' | '(' | ')' | ',' | ';'))) + .find(|part| part.contains('@')) + .unwrap_or("") + .to_ascii_lowercase() +} + +pub fn normalize_fingerprint(value: &str) -> String { + value + .chars() + .filter(|c| c.is_ascii_hexdigit()) + .flat_map(char::to_uppercase) + .collect() +} + +#[derive(Debug, Clone, SerdeSerialize)] +#[serde(rename_all = "camelCase")] +pub struct ProcessedAttachment { + pub filename: Option, + pub content_type: String, + pub size: usize, + pub data_base64: String, +} + +#[derive(Debug, Clone, SerdeSerialize)] +#[serde(rename_all = "camelCase")] +pub struct ProcessedMessage { + pub pgp: bool, + pub encrypted: bool, + pub decrypted: bool, + pub signed: bool, + pub verified: bool, + pub signer_fingerprint: Option, + pub signer_email: Option, + pub signer_trust: Option, + pub text: Option, + pub html: Option, + pub attachments: Vec, +} + +pub fn looks_like_openpgp(raw: &[u8]) -> bool { + let lower = String::from_utf8_lossy(raw).to_ascii_lowercase(); + lower.contains("multipart/encrypted") && lower.contains("application/pgp-encrypted") + || lower.contains("multipart/signed") && lower.contains("application/pgp-signature") + || lower.contains("-----begin pgp message-----") + || lower.contains("-----begin pgp signed message-----") +} + +/// Process an OpenPGP/MIME or inline OpenPGP message. The caller supplies +/// private key armor candidates and the public keyring. Passphrases are never +/// persisted; one request may try the same supplied passphrase against every +/// candidate key. +pub fn process_message( + raw: &[u8], + secret_armors: &[String], + passphrase: &str, + public_keys: &[(String, String)], +) -> Result { + let (headers, body) = split_headers_body(raw); + let content_type = header_value(headers, "content-type").unwrap_or_default(); + let content_type_lower = content_type.to_ascii_lowercase(); + + if content_type_lower.starts_with("multipart/encrypted") + && content_type_lower.contains("application/pgp-encrypted") + { + let boundary = mime_boundary(&content_type) + .ok_or_else(|| "OpenPGP/MIME encrypted message has no boundary".to_string())?; + let parts = split_multipart(body, &boundary)?; + if parts.len() < 2 { + return Err( + "OpenPGP/MIME encrypted message is missing its encrypted payload".to_string(), + ); + } + let encrypted_bytes = decoded_part_contents(&parts[1]); + let encrypted_text = String::from_utf8(encrypted_bytes) + .map_err(|_| "OpenPGP encrypted payload is not valid ASCII armor".to_string())?; + let public_armors: Vec = + public_keys.iter().map(|(_, armor)| armor.clone()).collect(); + let mut last_error = "No private OpenPGP key could decrypt this message".to_string(); + for secret in secret_armors { + match decrypt_inline(&encrypted_text, secret, passphrase, &public_armors) { + Ok(decrypted) => { + let nested = decrypted.plaintext.as_bytes(); + let mut processed = if is_multipart_signed(nested) { + process_multipart_signed(nested, public_keys)? + } else { + let mut clear = parse_clear_mime(nested)?; + clear.signed = decrypted.signed; + clear.verified = decrypted.verified; + clear.signer_fingerprint = decrypted.signer_fingerprint; + clear + }; + processed.pgp = true; + processed.encrypted = true; + processed.decrypted = true; + return Ok(processed); + } + Err(error) => last_error = error, + } + } + return Err(last_error); + } + + if is_multipart_signed(raw) { + return process_multipart_signed(raw, public_keys); + } + + let raw_text = String::from_utf8_lossy(raw); + if let Some(block) = extract_armored_block(&raw_text, "PGP MESSAGE") { + let public_armors: Vec = + public_keys.iter().map(|(_, armor)| armor.clone()).collect(); + let mut last_error = "No private OpenPGP key could decrypt this inline message".to_string(); + for secret in secret_armors { + match decrypt_inline(block, secret, passphrase, &public_armors) { + Ok(result) => { + return Ok(ProcessedMessage { + pgp: true, + encrypted: true, + decrypted: true, + signed: result.signed, + verified: result.verified, + signer_fingerprint: result.signer_fingerprint, + signer_email: None, + signer_trust: None, + text: Some(result.plaintext), + html: None, + attachments: vec![], + }); + } + Err(error) => last_error = error, + } + } + return Err(last_error); + } + + if let Some(block) = extract_armored_block(&raw_text, "PGP SIGNED MESSAGE") { + for (fingerprint, public) in public_keys { + if let Ok(text) = verify_inline(block, public) { + return Ok(ProcessedMessage { + pgp: true, + encrypted: false, + decrypted: false, + signed: true, + verified: true, + signer_fingerprint: Some(fingerprint.clone()), + signer_email: None, + signer_trust: None, + text: Some(text), + html: None, + attachments: vec![], + }); + } + } + return Ok(ProcessedMessage { + pgp: true, + encrypted: false, + decrypted: false, + signed: true, + verified: false, + signer_fingerprint: None, + signer_email: None, + signer_trust: None, + text: Some(block.to_string()), + html: None, + attachments: vec![], + }); + } + + Ok(ProcessedMessage { + pgp: false, + encrypted: false, + decrypted: false, + signed: false, + verified: false, + signer_fingerprint: None, + signer_email: None, + signer_trust: None, + text: None, + html: None, + attachments: vec![], + }) +} + +fn is_multipart_signed(raw: &[u8]) -> bool { + let (headers, _) = split_headers_body(raw); + header_value(headers, "content-type") + .map(|value| { + let lower = value.to_ascii_lowercase(); + lower.starts_with("multipart/signed") && lower.contains("application/pgp-signature") + }) + .unwrap_or(false) +} + +fn process_multipart_signed( + raw: &[u8], + public_keys: &[(String, String)], +) -> Result { + let (headers, body) = split_headers_body(raw); + let content_type = header_value(headers, "content-type").unwrap_or_default(); + let boundary = mime_boundary(&content_type) + .ok_or_else(|| "OpenPGP/MIME signed message has no boundary".to_string())?; + let parts = split_multipart(body, &boundary)?; + if parts.len() < 2 { + return Err("OpenPGP/MIME signature part is missing".to_string()); + } + let signature = String::from_utf8(decoded_part_contents(&parts[1])) + .map_err(|_| "OpenPGP signature is not valid ASCII armor".to_string())?; + + let mut verified = false; + let mut signer = None; + for (fingerprint, public) in public_keys { + if verify_detached(&parts[0], &signature, public).is_ok() + || verify_detached(&with_final_crlf(&parts[0]), &signature, public).is_ok() + { + verified = true; + signer = Some(fingerprint.clone()); + break; + } + } + let mut clear = parse_clear_mime(&parts[0])?; + clear.pgp = true; + clear.signed = true; + clear.verified = verified; + clear.signer_fingerprint = signer; + Ok(clear) +} + +fn parse_clear_mime(bytes: &[u8]) -> Result { + if bytes.len() > MAX_DECRYPTED_MESSAGE_BYTES { + return Err("Decrypted OpenPGP MIME payload exceeds the safe processing limit".to_string()); + } + use base64::Engine; + use mail_parser::MimeHeaders; + let parsed = mail_parser::MessageParser::default() + .parse(bytes) + .ok_or_else(|| "Failed to parse decrypted OpenPGP MIME payload".to_string())?; + let text = parsed.body_text(0).map(|value| value.to_string()); + let has_html = parsed.parts.iter().any(|part| { + part.content_type() + .is_some_and(|ct| ct.ctype() == "text" && ct.subtype() == Some("html")) + }); + let html = if has_html { + parsed.body_html(0).map(|value| value.to_string()) + } else { + None + }; + let mut attachments = Vec::new(); + for attachment in parsed.attachments() { + let data = attachment.contents(); + let content_type = attachment.content_type().map_or_else( + || "application/octet-stream".to_string(), + |ct| { + ct.subtype() + .map(|sub| format!("{}/{}", ct.ctype(), sub)) + .unwrap_or_else(|| ct.ctype().to_string()) + }, + ); + attachments.push(ProcessedAttachment { + filename: attachment.attachment_name().map(|value| value.to_string()), + content_type, + size: data.len(), + data_base64: base64::engine::general_purpose::STANDARD.encode(data), + }); + } + Ok(ProcessedMessage { + pgp: true, + encrypted: false, + decrypted: false, + signed: false, + verified: false, + signer_fingerprint: None, + signer_email: None, + signer_trust: None, + text, + html, + attachments, + }) +} + +fn decoded_part_contents(part: &[u8]) -> Vec { + if let Some(parsed) = mail_parser::MessageParser::default().parse(part) + && let Some(root) = parsed.parts.first() + { + return root.contents().to_vec(); + } + split_headers_body(part).1.to_vec() +} + +fn split_headers_body(raw: &[u8]) -> (&[u8], &[u8]) { + if let Some(pos) = find_bytes(raw, b"\r\n\r\n") { + return (&raw[..pos], &raw[pos + 4..]); + } + if let Some(pos) = find_bytes(raw, b"\n\n") { + return (&raw[..pos], &raw[pos + 2..]); + } + (raw, &[]) +} + +fn header_value(headers: &[u8], wanted: &str) -> Option { + let text = String::from_utf8_lossy(headers); + let mut current_name = String::new(); + let mut current_value = String::new(); + for line in text.lines() { + let line = line.trim_end_matches('\r'); + if line.starts_with(' ') || line.starts_with('\t') { + if !current_name.is_empty() { + current_value.push(' '); + current_value.push_str(line.trim()); + } + continue; + } + if current_name.eq_ignore_ascii_case(wanted) { + return Some(current_value.trim().to_string()); + } + if let Some((name, value)) = line.split_once(':') { + current_name = name.trim().to_string(); + current_value = value.trim().to_string(); + } + } + current_name + .eq_ignore_ascii_case(wanted) + .then(|| current_value.trim().to_string()) +} + +fn mime_boundary(content_type: &str) -> Option { + for parameter in content_type.split(';').skip(1) { + let (name, value) = parameter.trim().split_once('=')?; + if name.trim().eq_ignore_ascii_case("boundary") { + return Some(value.trim().trim_matches('"').to_string()); + } + } + None +} + +fn split_multipart(body: &[u8], boundary: &str) -> Result>, String> { + if boundary.is_empty() || boundary.len() > 200 { + return Err("Invalid MIME boundary".to_string()); + } + let marker = format!("--{boundary}").into_bytes(); + let mut positions = Vec::new(); + let mut cursor = 0usize; + while cursor < body.len() { + let Some(rel) = find_bytes(&body[cursor..], &marker) else { + break; + }; + let pos = cursor + rel; + if pos == 0 || body.get(pos.wrapping_sub(1)) == Some(&b'\n') { + positions.push(pos); + } + cursor = pos.saturating_add(marker.len()); + } + let mut parts = Vec::new(); + for window in positions.windows(2) { + let start_marker = window[0]; + let next_marker = window[1]; + let after = start_marker + marker.len(); + if body.get(after..after + 2) == Some(b"--") { + break; + } + let mut start = after; + if body.get(start..start + 2) == Some(b"\r\n") { + start += 2; + } else if body.get(start) == Some(&b'\n') { + start += 1; + } + let mut end = next_marker; + if end >= 2 && &body[end - 2..end] == b"\r\n" { + end -= 2; + } else if end >= 1 && body[end - 1] == b'\n' { + end -= 1; + } + if end >= start { + parts.push(body[start..end].to_vec()); + } + } + if parts.is_empty() { + return Err("Could not split OpenPGP MIME multipart body".to_string()); + } + Ok(parts) +} + +fn with_final_crlf(data: &[u8]) -> Vec { + let mut value = data.to_vec(); + if !value.ends_with(b"\r\n") { + value.extend_from_slice(b"\r\n"); + } + value +} + +fn find_bytes(haystack: &[u8], needle: &[u8]) -> Option { + if needle.is_empty() { + return Some(0); + } + haystack + .windows(needle.len()) + .position(|window| window == needle) +} + +fn extract_armored_block<'a>(text: &'a str, kind: &str) -> Option<&'a str> { + let begin = format!("-----BEGIN {kind}-----"); + let end = format!( + "-----END {}-----", + if kind == "PGP SIGNED MESSAGE" { + "PGP SIGNATURE" + } else { + kind + } + ); + let start = text.find(&begin)?; + let tail = &text[start..]; + let finish = tail.find(&end)? + end.len(); + Some(&tail[..finish]) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn openpgp_roundtrip_sign_encrypt_and_import() { + let key = + generate_key("RustBox Test", "pgp-test@example.invalid", "").expect("generate key"); + let secret = key.secret_armor.as_deref().expect("secret armor"); + + let signed = inline_sign("hello rustbox", secret, "").expect("sign"); + let verified = verify_inline(&signed, &key.public_armor).expect("verify"); + assert!(verified.contains("hello rustbox")); + + let encrypted = inline_encrypt( + "secret rustbox", + std::slice::from_ref(&key.public_armor), + Some((secret, "")), + ) + .expect("encrypt"); + let decrypted = decrypt_inline( + &encrypted, + secret, + "", + std::slice::from_ref(&key.public_armor), + ) + .expect("decrypt"); + assert_eq!(decrypted.plaintext, "secret rustbox"); + assert!(decrypted.signed); + assert!(decrypted.verified); + + let imported_public = import_key(key.public_armor.as_bytes()).expect("import public armor"); + assert_eq!(imported_public.fingerprint, key.fingerprint); + let imported_secret = import_key(secret.as_bytes()).expect("import secret armor"); + assert_eq!(imported_secret.fingerprint, key.fingerprint); + assert!(imported_secret.secret_armor.is_some()); + } + + #[test] + fn pgp_mime_signed_and_encrypted_processing() { + let key = + generate_key("RustBox Test", "mime-test@example.invalid", "").expect("generate key"); + let secret = key.secret_armor.as_deref().expect("secret armor"); + let clear = b"Content-Type: text/plain; charset=utf-8\r\n\r\nHello MIME"; + let signature = sign_detached(clear, secret, "").expect("sign mime"); + let signed = format!( + "Content-Type: multipart/signed; protocol=\"application/pgp-signature\"; boundary=\"rb\"\r\n\r\n--rb\r\n{}\r\n--rb\r\nContent-Type: application/pgp-signature\r\n\r\n{}\r\n--rb--\r\n", + String::from_utf8_lossy(clear), + signature + ); + let public = vec![(key.fingerprint.clone(), key.public_armor.clone())]; + let processed = + process_message(signed.as_bytes(), &[], "", &public).expect("process signed mime"); + assert!(processed.signed); + assert!(processed.verified); + + let encrypted = + encrypt_bytes(clear, std::slice::from_ref(&key.public_armor)).expect("encrypt mime"); + let encrypted_mime = format!( + "Content-Type: multipart/encrypted; protocol=\"application/pgp-encrypted\"; boundary=\"rbe\"\r\n\r\n--rbe\r\nContent-Type: application/pgp-encrypted\r\n\r\nVersion: 1\r\n--rbe\r\nContent-Type: application/octet-stream\r\n\r\n{}\r\n--rbe--\r\n", + encrypted + ); + let secrets = vec![secret.to_string()]; + let processed = process_message(encrypted_mime.as_bytes(), &secrets, "", &public) + .expect("process encrypted mime"); + assert!(processed.encrypted); + assert!(processed.decrypted); + assert_eq!(processed.text.as_deref(), Some("Hello MIME")); + } + #[cfg(unix)] + #[test] + fn private_key_storage_is_isolated_and_restrictive() { + use std::os::unix::fs::PermissionsExt; + + let root = std::env::temp_dir().join(format!("rustbox-pgp-test-{}", uuid::Uuid::new_v4())); + let root_text = root.to_string_lossy().into_owned(); + let fingerprint = "0123456789ABCDEF0123456789ABCDEF01234567"; + + let first = store_secret_key(&root_text, "user-a", fingerprint, "PRIVATE-A") + .expect("store first private key"); + let second = store_secret_key(&root_text, "user-b", fingerprint, "PRIVATE-B") + .expect("store second private key"); + + assert_ne!( + first, second, + "different users must never share a private-key path" + ); + assert!(first.contains("user-a/pgp/private")); + assert!(second.contains("user-b/pgp/private")); + assert_eq!( + fs::read_to_string(&first).expect("read first key"), + "PRIVATE-A" + ); + assert_eq!( + fs::read_to_string(&second).expect("read second key"), + "PRIVATE-B" + ); + + let first_mode = fs::metadata(&first) + .expect("first key metadata") + .permissions() + .mode() + & 0o777; + let first_dir_mode = fs::metadata(private_key_dir(&root_text, "user-a")) + .expect("first key directory metadata") + .permissions() + .mode() + & 0o777; + assert_eq!(first_mode, 0o600); + assert_eq!(first_dir_mode, 0o700); + + assert!(store_secret_key(&root_text, "user-a", "../../escape", "bad").is_err()); + fs::remove_dir_all(&root).expect("cleanup PGP test directory"); + } +} diff --git a/src/realtime/events.rs b/src/realtime/events.rs new file mode 100644 index 0000000..850ac18 --- /dev/null +++ b/src/realtime/events.rs @@ -0,0 +1,163 @@ +use std::collections::HashMap; + +use serde::Serialize; +use tokio::sync::{RwLock, broadcast}; + +/// Events that can be pushed to connected WebSocket clients. +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "type", content = "data")] +#[allow(dead_code)] +pub enum MailEvent { + /// New messages arrived in a folder. + NewMessages { + folder: String, + count: u32, + latest_sender: Option, + latest_subject: Option, + }, + /// Flags changed on messages in a folder. + FlagsChanged { folder: String }, + /// Folder list or counts changed. + FolderUpdated, +} + +/// Fan-out event bus: per-user broadcast channels. +/// +/// Each user (keyed by `user_hash`) gets a `broadcast::Sender` that can have +/// multiple receivers (one per WebSocket connection). When all receivers are +/// dropped the channel is cleaned up. +pub struct EventBus { + channels: RwLock>>, +} + +impl EventBus { + pub fn new() -> Self { + Self { + channels: RwLock::new(HashMap::new()), + } + } + + /// Subscribe to events for a user. Creates the channel if it doesn't exist. + /// Returns a receiver that will receive all future events for this user. + pub async fn subscribe(&self, user_hash: &str) -> broadcast::Receiver { + // Fast path: read lock. + { + let channels = self.channels.read().await; + if let Some(tx) = channels.get(user_hash) { + return tx.subscribe(); + } + } + + // Slow path: write lock to insert. + let mut channels = self.channels.write().await; + let tx = channels.entry(user_hash.to_string()).or_insert_with(|| { + let (tx, _) = broadcast::channel(64); + tx + }); + tx.subscribe() + } + + /// Publish an event to all connected clients for a user. + /// No-op if no one is listening. + pub async fn publish(&self, user_hash: &str, event: MailEvent) { + let channels = self.channels.read().await; + if let Some(tx) = channels.get(user_hash) { + // Ignore error (no receivers connected). + let _ = tx.send(event); + } + } + + /// Remove a user's channel when no more connections exist. + /// Called when the last WebSocket for a user disconnects. + pub async fn cleanup(&self, user_hash: &str) { + let mut channels = self.channels.write().await; + if let Some(tx) = channels.get(user_hash) { + // Only remove if no active receivers remain. + if tx.receiver_count() == 0 { + channels.remove(user_hash); + } + } + } +} + +impl Default for EventBus { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + + #[tokio::test] + async fn subscribe_and_publish() { + let bus = Arc::new(EventBus::new()); + let mut rx: tokio::sync::broadcast::Receiver = bus.subscribe("user1").await; + + bus.publish( + "user1", + MailEvent::NewMessages { + folder: "INBOX".to_string(), + count: 1, + latest_sender: Some("alice@example.com".to_string()), + latest_subject: Some("Hello".to_string()), + }, + ) + .await; + + let event: MailEvent = rx.recv().await.unwrap(); + match event { + MailEvent::NewMessages { folder, count, .. } => { + assert_eq!(folder, "INBOX"); + assert_eq!(count, 1); + } + _ => panic!("unexpected event type"), + } + } + + #[tokio::test] + async fn publish_to_nonexistent_user_is_noop() { + let bus = EventBus::new(); + // Should not panic. + bus.publish("ghost", MailEvent::FolderUpdated).await; + } + + #[tokio::test] + async fn multiple_subscribers_receive_same_event() { + let bus = Arc::new(EventBus::new()); + let mut rx1: tokio::sync::broadcast::Receiver = bus.subscribe("user1").await; + let mut rx2: tokio::sync::broadcast::Receiver = bus.subscribe("user1").await; + + bus.publish("user1", MailEvent::FolderUpdated).await; + + let e1: MailEvent = rx1.recv().await.unwrap(); + assert!(matches!(e1, MailEvent::FolderUpdated)); + let e2: MailEvent = rx2.recv().await.unwrap(); + assert!(matches!(e2, MailEvent::FolderUpdated)); + } + + #[tokio::test] + async fn cleanup_removes_empty_channel() { + let bus = EventBus::new(); + let rx: tokio::sync::broadcast::Receiver = bus.subscribe("user1").await; + drop(rx); + + bus.cleanup("user1").await; + + let channels = bus.channels.read().await; + assert!(!channels.contains_key("user1")); + } + + #[tokio::test] + async fn cleanup_keeps_channel_with_active_subscribers() { + let bus = EventBus::new(); + let _rx: tokio::sync::broadcast::Receiver = bus.subscribe("user1").await; + + bus.cleanup("user1").await; + + let channels = bus.channels.read().await; + assert!(channels.contains_key("user1")); + } +} diff --git a/src/realtime/idle.rs b/src/realtime/idle.rs new file mode 100644 index 0000000..af50367 --- /dev/null +++ b/src/realtime/idle.rs @@ -0,0 +1,974 @@ +use std::sync::Arc; + +use dashmap::DashMap; +use futures::StreamExt; +use tokio::task::JoinHandle; + +use crate::config::AppConfig; +use crate::imap::client::ImapCredentials; +use crate::imap::connection::ImapStream; +use crate::imap::parse::{decode_rfc2047, flag_to_string, has_attachments, imap_address_to_email}; +use crate::realtime::events::{EventBus, MailEvent}; +use crate::{db, imap}; + +/// Manages long-lived IMAP IDLE connections, one per (user, folder) pair. +/// Each WebSocket holds a subscription; only the last disconnect stops IDLE. +pub struct IdleManager { + tasks: DashMap<(String, String), IdleTask>, +} + +struct IdleTask { + handle: JoinHandle<()>, + subscribers: usize, +} + +impl IdleManager { + pub fn new() -> Self { + Self { + tasks: DashMap::new(), + } + } + + /// Subscribe to IDLE for a user + folder, sharing an existing task. + pub async fn start_idle( + &self, + user_hash: String, + folder: String, + creds: ImapCredentials, + event_bus: Arc, + config: Arc, + ) { + use dashmap::mapref::entry::Entry; + + match self.tasks.entry((user_hash.clone(), folder.clone())) { + Entry::Occupied(mut entry) => { + entry.get_mut().subscribers += 1; + } + Entry::Vacant(entry) => { + let handle = tokio::spawn(async move { + idle_loop(&user_hash, &folder, &creds, &event_bus, &config).await; + }); + entry.insert(IdleTask { + handle, + subscribers: 1, + }); + } + } + } + + /// Release this socket's subscription, without stopping other sockets. + pub async fn stop_idle(&self, user_hash: &str, folder: &str) { + use dashmap::mapref::entry::Entry; + + let key = (user_hash.to_string(), folder.to_string()); + if let Entry::Occupied(mut entry) = self.tasks.entry(key) { + if entry.get().subscribers > 1 { + entry.get_mut().subscribers -= 1; + } else { + entry.remove().handle.abort(); + } + } + } + + /// Abort all dedicated IDLE tasks during process shutdown. + pub fn stop_all(&self) { + for task in self.tasks.iter() { + task.value().handle.abort(); + } + self.tasks.clear(); + } + + pub fn task_count(&self) -> usize { + self.tasks.len() + } + + pub fn subscriber_count(&self) -> usize { + self.tasks.iter().map(|task| task.value().subscribers).sum() + } +} + +impl Default for IdleManager { + fn default() -> Self { + Self::new() + } +} + +/// The inner IDLE loop with auto-reconnect and exponential backoff. +/// +/// This opens a dedicated IMAP connection, SELECTs the folder, and enters +/// IDLE mode. When the server notifies of changes, it publishes an event +/// to the EventBus and re-enters IDLE. If the connection drops, it +/// reconnects with exponential backoff. +async fn idle_loop( + user_hash: &str, + folder: &str, + creds: &ImapCredentials, + event_bus: &EventBus, + config: &AppConfig, +) { + let mut backoff = std::time::Duration::from_secs(1); + let max_backoff = std::time::Duration::from_secs(60); + + loop { + match run_idle_session(user_hash, folder, creds, event_bus, config).await { + Ok(()) => { + // Session ended normally (shouldn't happen in practice). + tracing::info!(user_hash = %user_hash, folder = %folder, "IDLE session ended normally"); + break; + } + Err(e) => { + tracing::warn!( + user_hash = %user_hash, + folder = %folder, + error = %e, + backoff_secs = backoff.as_secs(), + "IDLE connection failed, will retry" + ); + tokio::time::sleep(backoff).await; + backoff = (backoff * 2).min(max_backoff); + } + } + } +} + +/// Run a single IDLE session. Returns Err on connection/protocol errors. +/// +/// The async-imap IDLE API follows an ownership pattern: +/// `Session` → `.idle()` consumes session → `Handle` → `.init()` sends IDLE +/// → `.wait()` listens → `.done()` sends DONE and returns `Session`. +async fn run_idle_session( + user_hash: &str, + folder: &str, + creds: &ImapCredentials, + event_bus: &EventBus, + config: &AppConfig, +) -> Result<(), String> { + use imap::client::connect; + + let mut session = connect(creds) + .await + .map_err(|e| format!("IDLE connect failed: {e}"))?; + + session + .select_condstore(folder) + .await + .map_err(|e| format!("IDLE SELECT failed: {e}"))?; + + tracing::info!(user_hash = %user_hash, folder = %folder, "IDLE session started"); + + // Re-enter IDLE every 25 minutes (RFC recommends max 29 min). + let idle_timeout = std::time::Duration::from_secs(25 * 60); + + loop { + // `.idle()` consumes the session, wrapping it in a Handle. + let mut idle_handle = session.idle(); + + // Initialize the IDLE command with the server. + idle_handle + .init() + .await + .map_err(|e| format!("IDLE init failed: {e}"))?; + + // Start listening for server notifications. + // IMPORTANT: `interrupt` must be kept alive — dropping it triggers + // ManualInterrupt and ends the wait immediately. + let (idle_wait, interrupt) = idle_handle.wait(); + + // Wait for the server to send an unsolicited response, or timeout. + let result = tokio::time::timeout(idle_timeout, idle_wait).await; + + // Drop the interrupt handle now that we're done waiting. + drop(interrupt); + + // Send DONE to end IDLE and get the session back. + session = idle_handle + .done() + .await + .map_err(|e| format!("IDLE done failed: {e}"))?; + + match result { + Ok(Ok(idle_response)) => { + use async_imap::extensions::idle::IdleResponse; + match idle_response { + IdleResponse::NewData(_) => { + tracing::info!( + user_hash = %user_hash, + folder = %folder, + "IDLE: new data from server" + ); + + // CONDSTORE sync is fast (~200-500ms), so run it + // BEFORE publishing. This ensures the DB is up-to-date + // when the frontend refetches. + let result; + (session, result) = + fetch_new_after_idle(session, user_hash, folder, config).await; + + // Publish the correct event based on what changed. + if result.new_count > 0 { + event_bus + .publish( + user_hash, + MailEvent::NewMessages { + folder: folder.to_string(), + count: result.new_count, + latest_sender: result.latest_sender, + latest_subject: result.latest_subject, + }, + ) + .await; + } else if result.flags_updated > 0 || result.deleted_count > 0 { + event_bus + .publish( + user_hash, + MailEvent::FlagsChanged { + folder: folder.to_string(), + }, + ) + .await; + } + } + IdleResponse::Timeout => { + tracing::debug!( + user_hash = %user_hash, + folder = %folder, + "IDLE: server-side timeout, re-entering" + ); + } + IdleResponse::ManualInterrupt => { + tracing::debug!( + user_hash = %user_hash, + folder = %folder, + "IDLE: manual interrupt, re-entering" + ); + } + } + } + Ok(Err(e)) => { + // IDLE protocol error. + return Err(format!("IDLE error: {e}")); + } + Err(_) => { + // Our timeout — re-enter IDLE to keep the connection alive. + tracing::debug!( + user_hash = %user_hash, + folder = %folder, + "IDLE keepalive timeout, re-entering" + ); + } + } + } +} + +/// Summary of what changed during a post-IDLE reconciliation. +struct ReconcileResult { + new_count: u32, + flags_updated: u32, + deleted_count: u32, + latest_sender: Option, + latest_subject: Option, +} + +impl ReconcileResult { + fn empty() -> Self { + Self { + new_count: 0, + flags_updated: 0, + deleted_count: 0, + latest_sender: None, + latest_subject: None, + } + } +} + +/// Reconcile the local cache after IDLE detects new data. +/// +/// Uses CONDSTORE when available for fast incremental sync (~200-500ms), +/// falling back to a full UID scan when CONDSTORE is not supported. +/// +/// If anything fails, it logs a warning and returns the session unchanged — +/// the periodic sync loop provides a safety net. +async fn fetch_new_after_idle( + mut session: async_imap::Session, + user_hash: &str, + folder: &str, + config: &AppConfig, +) -> (async_imap::Session, ReconcileResult) { + // Read cached folder state from DB. + let (cached_modseq, max_cached_uid, cached_count, cached_uid_validity, header_sync) = + match db::pool::open_user_db(&config.data_dir, user_hash) { + Ok(conn) => { + let header_sync = match db::header_sync::HeaderSync::capture(&conn) { + Ok(sync) => sync, + Err(e) => { + tracing::warn!(error = %e, "IDLE fetch: failed to read header generation"); + return (session, ReconcileResult::empty()); + } + }; + let cached = db::folders::get_folder(&conn, folder).ok().flatten(); + let modseq = cached.as_ref().map(|f| f.highest_modseq).unwrap_or(0); + let validity = cached.as_ref().map(|f| f.uid_validity).unwrap_or(0); + let max_uid = db::messages::max_uid(&conn, folder).unwrap_or(0); + let count = db::messages::count_messages(&conn, folder).unwrap_or(0); + (modseq, max_uid, count, validity, header_sync) + } + Err(e) => { + tracing::warn!(error = %e, "IDLE fetch: failed to open DB"); + return (session, ReconcileResult::empty()); + } + }; + + // Re-SELECT with CONDSTORE to get fresh mailbox state. + let mailbox = match session.select_condstore(folder).await { + Ok(mb) => mb, + Err(e) => { + tracing::warn!(error = %e, "IDLE fetch: select_condstore failed"); + return (session, ReconcileResult::empty()); + } + }; + + let server_exists = mailbox.exists; + let server_modseq = mailbox.highest_modseq.unwrap_or(0); + let uid_validity = mailbox.uid_validity.unwrap_or(0); + + // Let list sync reset the namespace before IDLE writes any reused UIDs. + if cached_uid_validity != 0 && cached_uid_validity != uid_validity { + if let Ok(conn) = db::pool::open_user_db(&config.data_dir, user_hash) { + let _ = db::folders::invalidate_folder_freshness(&conn, folder); + } + return (session, ReconcileResult::empty()); + } + + // Early exit: nothing changed (CONDSTORE modseq and count both match). + if server_modseq > 0 && server_modseq == cached_modseq && server_exists == cached_count { + tracing::debug!( + user_hash = %user_hash, + folder = %folder, + modseq = server_modseq, + "IDLE fetch: nothing changed (modseq match)" + ); + return (session, ReconcileResult::empty()); + } + + // ── CONDSTORE fast path ────────────────────────────────────────── + if cached_modseq > 0 && server_modseq > 0 { + let fetch_query = format!("(UID FLAGS) (CHANGEDSINCE {})", cached_modseq); + let changed_items: Option)>> = + match session.uid_fetch("1:*", &fetch_query).await { + Ok(stream) => { + let mut stream = std::pin::pin!(stream); + let mut items = Vec::new(); + while let Some(result) = stream.next().await { + match result { + Ok(fetch) => { + if let Some(uid) = fetch.uid { + let flags: Vec = + fetch.flags().map(|f| flag_to_string(&f)).collect(); + items.push((uid, flags)); + } + } + Err(e) => { + tracing::warn!( + error = %e, + "IDLE fetch: error reading CHANGEDSINCE stream" + ); + break; + } + } + } + Some(items) + } + Err(e) => { + tracing::warn!( + error = %e, + "IDLE fetch: CHANGEDSINCE fetch failed, falling back to full scan" + ); + None + } + }; + + if let Some(changed) = changed_items { + // Separate flag changes (uid ≤ max) from new messages (uid > max). + let mut flag_changes: Vec<(u32, Vec)> = Vec::new(); + let mut has_new = false; + for (uid, flags) in changed { + if uid > max_cached_uid { + has_new = true; + } else { + flag_changes.push((uid, flags)); + } + } + + // Apply flag changes to DB. + let mut flags_updated = 0u32; + if !flag_changes.is_empty() + && let Ok(conn) = db::pool::open_user_db(&config.data_dir, user_hash) + { + let sync_keywords = db::mail_preferences::get_preferences(&conn) + .map(|prefs| prefs.sync_imap_keywords) + .unwrap_or(false); + for (uid, flags) in &flag_changes { + let mut sorted = flags.clone(); + sorted.sort(); + let flags_csv = sorted.join(","); + if db::messages::update_message_flags(&conn, folder, *uid, &flags_csv).is_ok() { + if sync_keywords { + if let Err(error) = + db::tags::import_imap_keywords(&conn, *uid, folder, &sorted) + { + tracing::debug!(uid = uid, error = %error, "IDLE keyword import failed"); + } + } + flags_updated += 1; + } + } + } + + // Fetch full headers for new messages. + let mut new_count = 0u32; + if has_new { + let uid_range = format!("{}:*", max_cached_uid + 1); + new_count = fetch_and_store_headers( + &mut session, + user_hash, + folder, + config, + &uid_range, + max_cached_uid, + header_sync, + ) + .await; + } + + // Detect deletions if server count < cached count. + let mut deleted_count = 0u32; + if server_exists < cached_count { + deleted_count = + fetch_uids_and_remove_deleted(&mut session, user_hash, folder, config).await; + } + + // Look up the latest message's sender/subject if new messages arrived. + let (latest_sender, latest_subject) = if new_count > 0 { + lookup_latest_message(config, user_hash, folder) + } else { + (None, None) + }; + + // Update folder sync status with new modseq. + if let Ok(conn) = db::pool::open_user_db(&config.data_dir, user_hash) + && let Ok(transaction) = header_sync.begin_write(&conn) + { + let _ = db::folders::update_folder_sync_status( + &conn, + folder, + uid_validity, + server_exists, + server_modseq, + ); + let _ = db::folders::refresh_unread_count(&conn, folder); + let _ = transaction.commit(); + } + + if flags_updated > 0 || new_count > 0 || deleted_count > 0 { + tracing::info!( + user_hash = %user_hash, + folder = %folder, + flags_updated = flags_updated, + new = new_count, + deleted = deleted_count, + "Reconciled cache after IDLE (CONDSTORE)" + ); + } + + return ( + session, + ReconcileResult { + new_count, + flags_updated, + deleted_count, + latest_sender, + latest_subject, + }, + ); + } + // CHANGEDSINCE fetch failed — fall through to full scan below. + } + + // ── Fallback: full UID scan (no CONDSTORE) ─────────────────────── + let cached_uids: std::collections::HashSet = + match db::pool::open_user_db(&config.data_dir, user_hash) { + Ok(conn) => { + let uids_flags = + db::messages::get_all_uids_and_flags(&conn, folder).unwrap_or_default(); + uids_flags.iter().map(|(uid, _)| *uid).collect() + } + Err(e) => { + tracing::warn!(error = %e, "IDLE fetch: failed to open DB for fallback"); + return (session, ReconcileResult::empty()); + } + }; + + // Fetch all UIDs from server. + let imap_uids: Option> = + match session.uid_fetch("1:*", "UID").await { + Ok(stream) => { + let mut stream = std::pin::pin!(stream); + let mut uids = std::collections::HashSet::new(); + while let Some(result) = stream.next().await { + match result { + Ok(fetch) => { + if let Some(uid) = fetch.uid { + uids.insert(uid); + } + } + Err(e) => { + tracing::warn!(error = %e, "IDLE fetch: error reading UID stream"); + break; + } + } + } + Some(uids) + } + Err(e) => { + tracing::warn!(error = %e, "IDLE fetch: uid_fetch(UIDs) failed"); + None + } + }; + + let Some(imap_uids) = imap_uids else { + return (session, ReconcileResult::empty()); + }; + + // Fetch headers for new messages. + let has_new = imap_uids.iter().any(|&uid| uid > max_cached_uid); + let mut new_count = 0u32; + if has_new { + let uid_range = format!("{}:*", max_cached_uid + 1); + new_count = fetch_and_store_headers( + &mut session, + user_hash, + folder, + config, + &uid_range, + max_cached_uid, + header_sync, + ) + .await; + } + + // Delete cached UIDs no longer on server. + let mut deleted_count = 0u32; + let deleted_uids: Vec = cached_uids + .iter() + .copied() + .filter(|uid| !imap_uids.contains(uid)) + .collect(); + if !deleted_uids.is_empty() + && let Ok(conn) = db::pool::open_user_db(&config.data_dir, user_hash) + { + for uid in &deleted_uids { + if db::messages::delete_message(&conn, folder, *uid).is_ok() { + deleted_count += 1; + } + } + } + + // Update folder sync status. + if let Ok(conn) = db::pool::open_user_db(&config.data_dir, user_hash) + && let Ok(transaction) = header_sync.begin_write(&conn) + { + let _ = db::folders::update_folder_sync_status( + &conn, + folder, + uid_validity, + server_exists, + server_modseq, + ); + let _ = db::folders::refresh_unread_count(&conn, folder); + let _ = transaction.commit(); + } + + // Look up the latest message's sender/subject if new messages arrived. + let (latest_sender, latest_subject) = if new_count > 0 { + lookup_latest_message(config, user_hash, folder) + } else { + (None, None) + }; + + if new_count > 0 || deleted_count > 0 { + tracing::info!( + user_hash = %user_hash, + folder = %folder, + new = new_count, + deleted = deleted_count, + "Reconciled cache after IDLE (full scan)" + ); + } + + ( + session, + ReconcileResult { + new_count, + flags_updated: 0, + deleted_count, + latest_sender, + latest_subject, + }, + ) +} + +/// Look up the most recent message in the DB for sender/subject details. +fn lookup_latest_message( + config: &AppConfig, + user_hash: &str, + folder: &str, +) -> (Option, Option) { + let Ok(conn) = db::pool::open_user_db(&config.data_dir, user_hash) else { + return (None, None); + }; + let max_uid = db::messages::max_uid(&conn, folder).unwrap_or(0); + if max_uid == 0 { + return (None, None); + } + match db::messages::get_single_message(&conn, folder, max_uid) { + Ok(Some(msg)) => { + let sender = if !msg.from_name.is_empty() { + Some(msg.from_name) + } else if !msg.from_address.is_empty() { + Some(msg.from_address) + } else { + None + }; + let subject = if !msg.subject.is_empty() { + Some(msg.subject) + } else { + None + }; + (sender, subject) + } + _ => (None, None), + } +} + +/// Fetch full headers for messages in the given UID range and store them in the DB. +/// Skips messages with UID ≤ `min_uid` (handles the `*` wildcard returning the last message). +/// Returns the number of new messages stored. +async fn fetch_and_store_headers( + session: &mut async_imap::Session, + user_hash: &str, + folder: &str, + config: &AppConfig, + uid_range: &str, + min_uid: u32, + header_sync: db::header_sync::HeaderSync, +) -> u32 { + let fetches: Option> = match session + .uid_fetch( + uid_range, + "(UID ENVELOPE FLAGS BODYSTRUCTURE RFC822.SIZE BODY.PEEK[HEADER.FIELDS (Message-ID In-Reply-To References Content-Class x-ms-exchange-generated-message-class)])", + ) + .await + { + Ok(stream) => { + let mut stream = std::pin::pin!(stream); + let mut items = Vec::new(); + while let Some(result) = stream.next().await { + match result { + Ok(fetch) => items.push(fetch), + Err(e) => { + tracing::warn!(error = %e, "IDLE fetch: error reading header stream"); + break; + } + } + } + Some(items) + } + Err(e) => { + tracing::warn!(error = %e, "IDLE fetch: uid_fetch(headers) failed"); + None + } + }; + + let mut new_count = 0u32; + if let Some(fetches) = fetches + && let Ok(conn) = db::pool::open_user_db(&config.data_dir, user_hash) + { + let transaction = match header_sync.begin_write(&conn) { + Ok(transaction) => transaction, + Err(e) => { + tracing::debug!(error = %e, "IDLE fetch: discarded stale headers"); + return 0; + } + }; + let sync_keywords = db::mail_preferences::get_preferences(&conn) + .map(|prefs| prefs.sync_imap_keywords) + .unwrap_or(false); + for fetch in &fetches { + let uid = match fetch.uid { + Some(u) if u > min_uid => u, + Some(_) => continue, // UID "*" can return the last message + None => continue, + }; + + // Parse threading headers. + let raw_header_bytes = fetch.header(); + let parsed_threading = + raw_header_bytes.and_then(|raw| mail_parser::MessageParser::default().parse(raw)); + + let (subject, from, to, cc, date) = if let Some(env) = fetch.envelope() { + let subject = env + .subject + .as_ref() + .and_then(|b| std::str::from_utf8(b).ok()) + .map(decode_rfc2047); + + let from: Vec<_> = env + .from + .as_ref() + .map(|addrs| addrs.iter().map(imap_address_to_email).collect()) + .unwrap_or_default(); + + let to: Vec<_> = env + .to + .as_ref() + .map(|addrs| addrs.iter().map(imap_address_to_email).collect()) + .unwrap_or_default(); + + let cc: Vec<_> = env + .cc + .as_ref() + .map(|addrs| addrs.iter().map(imap_address_to_email).collect()) + .unwrap_or_default(); + + let date = env + .date + .as_ref() + .and_then(|b| std::str::from_utf8(b).ok()) + .map(|s| s.to_string()); + + (subject, from, to, cc, date) + } else { + (None, vec![], vec![], vec![], None) + }; + + let message_id = parsed_threading + .as_ref() + .and_then(|p| p.message_id().map(|s| format!("<{s}>"))); + let in_reply_to = parsed_threading.as_ref().and_then(|p| { + let val = p.in_reply_to(); + val.as_text().map(|s| format!("<{s}>")) + }); + let references = parsed_threading.as_ref().and_then(|p| { + let val = p.references(); + val.as_text_list() + .map(|list| { + list.iter() + .map(|s| format!("<{s}>")) + .collect::>() + .join(" ") + }) + .or_else(|| val.as_text().map(|s| format!("<{s}>"))) + }); + + // Detect Outlook/Exchange reaction emails from headers. + let reaction = raw_header_bytes.and_then(|raw| { + let header_str = std::str::from_utf8(raw).ok()?; + let lower = header_str.to_lowercase(); + let is_reaction = lower.contains("content-class: activitynotification") + || lower.contains("urn:content-class:reaction"); + if !is_reaction { + return None; + } + let subj = subject.as_deref().unwrap_or("").to_lowercase(); + let emoji = match subj.trim() { + s if s.contains("like") => "\u{1f44d}", + s if s.contains("heart") || s.contains("love") => "\u{2764}\u{fe0f}", + s if s.contains("laugh") => "\u{1f604}", + s if s.contains("surprised") || s.contains("wow") => "\u{1f62e}", + s if s.contains("sad") => "\u{1f622}", + s if s.contains("angry") => "\u{1f620}", + _ => "\u{1f44d}", + }; + Some(emoji.to_string()) + }); + + let flags: Vec = fetch.flags().map(|f| flag_to_string(&f)).collect(); + let has_attach = fetch + .bodystructure() + .map(|bs| has_attachments(bs)) + .unwrap_or(false); + let size = fetch.size.unwrap_or(0); + + let from_address = from.first().map(|a| a.address.as_str()).unwrap_or(""); + let from_name = from.first().and_then(|a| a.name.as_deref()).unwrap_or(""); + let to_json = serde_json::to_string(&to).unwrap_or_else(|_| "[]".to_string()); + let cc_json = serde_json::to_string(&cc).unwrap_or_else(|_| "[]".to_string()); + let subject_str = subject.as_deref().unwrap_or(""); + let date_str = date.as_deref().unwrap_or(""); + let flags_csv = flags.join(","); + + if let Err(e) = db::messages::upsert_message( + &conn, + folder, + uid, + message_id.as_deref(), + in_reply_to.as_deref(), + references.as_deref(), + subject_str, + from_address, + from_name, + &to_json, + &cc_json, + date_str, + &flags_csv, + size, + has_attach, + "", + reaction.as_deref(), + ) { + tracing::warn!(uid = uid, error = %e, "IDLE fetch: failed to upsert message"); + } else { + // Populate denormalized known_addresses table. + if let Err(e) = db::contacts::populate_known_addresses( + &conn, + from_address, + from_name, + &to_json, + &cc_json, + ) { + tracing::warn!(uid = uid, error = %e, "IDLE fetch: failed to populate known addresses"); + } + if sync_keywords { + if let Err(error) = db::tags::import_imap_keywords(&conn, uid, folder, &flags) { + tracing::debug!(uid = uid, error = %error, "IDLE keyword import failed"); + } + } + new_count += 1; + } + } + if let Err(e) = transaction.commit() { + tracing::warn!(error = %e, "IDLE fetch: failed to commit headers"); + return 0; + } + } + + new_count +} + +/// Fetch all UIDs from the server and remove cached messages no longer present. +/// Used for deletion detection when server count < cached count. +/// Returns the number of deleted messages. +async fn fetch_uids_and_remove_deleted( + session: &mut async_imap::Session, + user_hash: &str, + folder: &str, + config: &AppConfig, +) -> u32 { + let imap_uids: Option> = + match session.uid_fetch("1:*", "UID").await { + Ok(stream) => { + let mut stream = std::pin::pin!(stream); + let mut uids = std::collections::HashSet::new(); + while let Some(result) = stream.next().await { + match result { + Ok(fetch) => { + if let Some(uid) = fetch.uid { + uids.insert(uid); + } + } + Err(e) => { + tracing::warn!( + error = %e, + "IDLE fetch: error reading UID stream for deletion check" + ); + break; + } + } + } + Some(uids) + } + Err(e) => { + tracing::warn!( + error = %e, + "IDLE fetch: uid_fetch(UIDs) failed for deletion check" + ); + None + } + }; + + let Some(imap_uids) = imap_uids else { + return 0; + }; + + let mut deleted_count = 0u32; + if let Ok(conn) = db::pool::open_user_db(&config.data_dir, user_hash) { + let cached = db::messages::get_all_uids_and_flags(&conn, folder).unwrap_or_default(); + for (uid, _) in &cached { + if !imap_uids.contains(uid) && db::messages::delete_message(&conn, folder, *uid).is_ok() + { + deleted_count += 1; + } + } + } + + deleted_count +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn overlapping_sockets_keep_idle_until_the_last_disconnect() { + let manager = IdleManager::new(); + let config = Arc::new(serde_json::from_str::("{}").unwrap()); + let event_bus = Arc::new(EventBus::new()); + let creds = ImapCredentials { + host: "localhost".into(), + port: 143, + tls: false, + email: "test@example.com".into(), + password: "test".into(), + }; + let key = ("user".to_string(), "INBOX".to_string()); + + manager + .start_idle( + key.0.clone(), + key.1.clone(), + creds.clone(), + event_bus.clone(), + config.clone(), + ) + .await; + let original_task = manager.tasks.get(&key).unwrap().handle.id(); + // A reconnect may subscribe before the old socket finishes closing. + manager + .start_idle( + key.0.clone(), + key.1.clone(), + creds.clone(), + event_bus.clone(), + config.clone(), + ) + .await; + assert_eq!(manager.tasks.len(), 1); + assert_eq!(manager.tasks.get(&key).unwrap().subscribers, 2); + assert_eq!(manager.tasks.get(&key).unwrap().handle.id(), original_task); + + manager.stop_idle(&key.0, &key.1).await; + assert_eq!(manager.tasks.get(&key).unwrap().subscribers, 1); + assert!(!manager.tasks.get(&key).unwrap().handle.is_finished()); + manager.stop_idle(&key.0, &key.1).await; + assert!(manager.tasks.is_empty()); + + // A connection arriving after the last disconnect gets a new task. + manager + .start_idle(key.0.clone(), key.1.clone(), creds, event_bus, config) + .await; + assert_ne!(manager.tasks.get(&key).unwrap().handle.id(), original_task); + manager.stop_idle(&key.0, &key.1).await; + manager.stop_idle(&key.0, &key.1).await; + assert!(manager.tasks.is_empty()); + } +} diff --git a/src/realtime/mod.rs b/src/realtime/mod.rs new file mode 100644 index 0000000..8211659 --- /dev/null +++ b/src/realtime/mod.rs @@ -0,0 +1,4 @@ +pub mod events; +pub mod idle; +pub mod sync; +pub mod ws; diff --git a/src/realtime/sync.rs b/src/realtime/sync.rs new file mode 100644 index 0000000..b83f50b --- /dev/null +++ b/src/realtime/sync.rs @@ -0,0 +1,528 @@ +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; + +use crate::config::AppConfig; +use crate::db; +use crate::email_theme; +use crate::imap::client::{ImapClient, ImapCredentials}; +use crate::realtime::events::{EventBus, MailEvent}; +use crate::search::engine::UserIndex; +use crate::search::engine::{IndexableMessage, SearchEngine}; + +/// How often to run a sync check (seconds). +/// Reconcile every three minutes, including an INBOX safety net if IDLE drops. +const SYNC_INTERVAL_SECS: u64 = 180; + +/// Run a periodic reconciliation loop for a user. +/// +/// Uses a 3-tier strategy per folder: +/// 1. STATUS pre-check (cheap, no SELECT) +/// 2. CONDSTORE incremental fetch (only changed flags) +/// 3. Full fetch fallback (when CONDSTORE unavailable) +pub async fn sync_loop( + user_hash: String, + creds: ImapCredentials, + config: Arc, + imap_client: Arc, + event_bus: Arc, + search_engine: Arc, +) { + let mut interval = tokio::time::interval(std::time::Duration::from_secs(SYNC_INTERVAL_SECS)); + // Skip the first immediate tick — IDLE + initial list_messages handles that. + interval.tick().await; + + loop { + interval.tick().await; + + if let Err(e) = crate::imap::pool::background(run_sync( + &user_hash, + &creds, + &config, + imap_client.as_ref(), + &event_bus, + search_engine.clone(), + )) + .await + { + tracing::warn!( + user = %creds.email, + error = %e, + "Periodic sync failed, will retry next interval" + ); + } + } +} + +async fn run_sync( + user_hash: &str, + creds: &ImapCredentials, + config: &AppConfig, + imap_client: &dyn ImapClient, + event_bus: &EventBus, + search_engine: Arc, +) -> Result<(), String> { + let database = db::pool::UserDb::new(&config.data_dir, user_hash); + let folder_snapshots = database + .run(|conn| { + Ok(db::folders::get_all_folders(conn)? + .into_iter() + .map(|f| FolderSnapshot { + name: f.name, + uid_validity: f.uid_validity, + highest_modseq: f.highest_modseq, + }) + .collect::>()) + }) + .await?; + + let mut any_changes = false; + + for folder in &folder_snapshots { + let folder_name = &folder.name; + + // ── Tier 1: STATUS pre-check (no SELECT needed) ────────────── + let status = match imap_client.folder_status_extended(creds, folder_name).await { + Ok(s) => s, + Err(e) => { + tracing::debug!( + folder = %folder_name, + error = %e, + "Skipping folder sync (STATUS failed)" + ); + continue; + } + }; + + // Check UIDVALIDITY — if changed, the folder was rebuilt. + if folder.uid_validity != 0 && status.uid_validity != folder.uid_validity { + tracing::info!( + folder = %folder_name, + old_validity = folder.uid_validity, + new_validity = status.uid_validity, + "UIDVALIDITY changed, invalidating folder" + ); + let name = folder_name.clone(); + database + .run(move |conn| db::folders::invalidate_folder_freshness(conn, &name)) + .await?; + any_changes = true; + event_bus.publish(user_hash, MailEvent::FolderUpdated).await; + continue; + } + + // If CONDSTORE is supported (highest_modseq > 0) and matches cached → skip. + let cached_modseq = folder.highest_modseq; + if status.highest_modseq > 0 && cached_modseq > 0 && status.highest_modseq == cached_modseq + { + let name = folder_name.clone(); + let cached_count = database + .run(move |conn| db::messages::count_messages(conn, &name)) + .await?; + if status.exists == cached_count { + tracing::debug!( + folder = %folder_name, + modseq = status.highest_modseq, + "Skipping unchanged folder" + ); + continue; + } + } + + // ── Tier 2: CONDSTORE incremental fetch ────────────────────── + if status.highest_modseq > 0 && cached_modseq > 0 { + let folder_changed = sync_condstore( + config, + user_hash, + creds, + imap_client, + folder_name, + cached_modseq, + &status, + event_bus, + ) + .await?; + if folder_changed { + any_changes = true; + } + continue; + } + + // ── Tier 3: Full fetch fallback ────────────────────────────── + let folder_changed = sync_full( + config, + user_hash, + creds, + imap_client, + folder_name, + &status, + event_bus, + ) + .await?; + if folder_changed { + any_changes = true; + } + } + + if any_changes { + event_bus.publish(user_hash, MailEvent::FolderUpdated).await; + } + + // ── Deep index phase: fetch & index bodies if enabled ──────────── + if let Err(e) = index_message_bodies(user_hash, creds, config, imap_client, search_engine).await + { + tracing::debug!(error = %e, "Deep index phase skipped or failed"); + } + + Ok(()) +} + +/// Batch size for deep indexing per sync cycle. +const DEEP_INDEX_BATCH: u32 = 10; + +/// If the `deep_index` preference is enabled, fetch bodies for messages that +/// don't have a cached body yet, cache them, and re-index with body text. +async fn index_message_bodies( + user_hash: &str, + creds: &ImapCredentials, + config: &AppConfig, + imap_client: &dyn ImapClient, + search_engine: Arc, +) -> Result<(), String> { + let database = db::pool::UserDb::new(&config.data_dir, user_hash); + let unindexed = database + .run(|conn| { + if !db::display_preferences::get_preferences(conn)?.deep_index { + return Ok(Vec::new()); + } + db::messages::get_unindexed_messages(conn, DEEP_INDEX_BATCH) + }) + .await?; + + if unindexed.is_empty() { + return Ok(()); + } + + tracing::debug!(count = unindexed.len(), "Deep indexing message bodies"); + + for (folder, uid) in &unindexed { + if UserIndex::is_excluded_folder(folder) { + continue; + } + + let body = match imap_client.fetch_body(creds, folder, *uid).await { + Ok(b) => b, + Err(e) => { + tracing::debug!(folder = %folder, uid = uid, error = %e, "Deep index: failed to fetch body"); + continue; + } + }; + + let folder = folder.clone(); + let uid = *uid; + let engine = search_engine.clone(); + let hash = user_hash.to_owned(); + database + .run(move |conn| { + let att_json = serde_json::to_string(&body.attachments).ok(); + let detected_theme = body + .text_html + .as_ref() + .and_then(|h| email_theme::detect_email_theme(h)) + .map(|t| t.as_i32()); + db::messages::cache_message_body( + conn, + &folder, + uid, + body.text_html.as_deref(), + body.text_plain.as_deref(), + att_json.as_deref(), + Some(&body.raw_headers), + detected_theme, + )?; + if let Some(text) = body.text_plain + && let Some(msg) = db::messages::get_single_message(conn, &folder, uid)? + { + engine + .open_user_index(&hash)? + .index_message(&IndexableMessage { + uid: msg.uid, + folder: msg.folder, + subject: msg.subject, + from_address: msg.from_address, + from_name: msg.from_name, + to_addresses: msg.to_addresses, + body_text: text, + date_epoch: db::messages::parse_date_to_epoch_public(&msg.date), + has_attachments: msg.has_attachments, + })?; + } + Ok(()) + }) + .await?; + } + + tracing::debug!(count = unindexed.len(), "Deep index batch complete"); + Ok(()) +} + +/// Lightweight snapshot of a cached folder for the sync loop. +struct FolderSnapshot { + name: String, + uid_validity: u32, + highest_modseq: u64, +} + +/// CONDSTORE path: fetch only changed flags, detect deletions via count comparison. +#[allow(clippy::too_many_arguments)] +async fn sync_condstore( + config: &AppConfig, + user_hash: &str, + creds: &ImapCredentials, + imap_client: &dyn ImapClient, + folder_name: &str, + cached_modseq: u64, + status: &crate::imap::types::FolderStatusExtended, + event_bus: &EventBus, +) -> Result { + // Fetch only messages whose flags changed since our cached modseq. + let (changed, new_modseq) = match imap_client + .fetch_changed_flags(creds, folder_name, cached_modseq) + .await + { + Ok(result) => result, + Err(e) => { + tracing::debug!( + folder = %folder_name, + error = %e, + "CONDSTORE fetch failed, falling back to full sync" + ); + return sync_full( + config, + user_hash, + creds, + imap_client, + folder_name, + status, + event_bus, + ) + .await; + } + }; + + let database = db::pool::UserDb::new(&config.data_dir, user_hash); + let name = folder_name.to_owned(); + let cached_count = database + .run(move |conn| db::messages::count_messages(conn, &name)) + .await?; + let imap_uids = if status.exists < cached_count { + Some( + imap_client + .fetch_uids_and_flags(creds, folder_name) + .await + .map_err(|e| e.to_string())? + .into_iter() + .map(|(uid, _)| uid) + .collect::>(), + ) + } else { + None + }; + let name = folder_name.to_owned(); + let status = status.clone(); + let (folder_changed, new_messages) = database + .run(move |conn| { + let sync_keywords = db::mail_preferences::get_preferences(conn) + .map(|prefs| prefs.sync_imap_keywords) + .unwrap_or(false); + let transaction = conn.unchecked_transaction().map_err(|e| e.to_string())?; + let mut folder_changed = !changed.is_empty(); + for (uid, mut flags) in changed { + flags.sort(); + db::messages::update_message_flags(conn, &name, uid, &flags.join(","))?; + if sync_keywords { + db::tags::import_imap_keywords(conn, uid, &name, &flags)?; + } + } + if let Some(imap_uids) = imap_uids { + for (uid, _) in db::messages::get_all_uids_and_flags(conn, &name)? { + if !imap_uids.contains(&uid) { + db::messages::delete_message(conn, &name, uid)?; + folder_changed = true; + } + } + } + let max_uid = db::messages::max_uid(conn, &name)?; + let new_messages = status.uid_next > max_uid.saturating_add(1); + let modseq = if new_modseq > 0 { + new_modseq + } else { + status.highest_modseq + }; + db::folders::update_folder_sync_status( + conn, + &name, + status.uid_validity, + status.exists, + modseq, + )?; + if new_messages { + db::folders::invalidate_folder_freshness(conn, &name)?; + folder_changed = true; + } + transaction.commit().map_err(|e| e.to_string())?; + Ok((folder_changed, new_messages)) + }) + .await?; + if new_messages { + event_bus + .publish( + user_hash, + MailEvent::NewMessages { + folder: folder_name.to_owned(), + count: 0, + latest_sender: None, + latest_subject: None, + }, + ) + .await; + } + + if folder_changed { + event_bus + .publish( + user_hash, + MailEvent::FlagsChanged { + folder: folder_name.to_string(), + }, + ) + .await; + } + + Ok(folder_changed) +} + +/// Full fetch fallback: fetch all UIDs+FLAGS and reconcile (original behavior). +async fn sync_full( + config: &AppConfig, + user_hash: &str, + creds: &ImapCredentials, + imap_client: &dyn ImapClient, + folder_name: &str, + status: &crate::imap::types::FolderStatusExtended, + event_bus: &EventBus, +) -> Result { + let database = db::pool::UserDb::new(&config.data_dir, user_hash); + let name = folder_name.to_owned(); + let cached = database + .run(move |conn| db::messages::get_all_uids_and_flags(conn, &name)) + .await?; + if cached.is_empty() { + let name = folder_name.to_owned(); + let status = status.clone(); + database + .run(move |conn| { + db::folders::update_folder_sync_status( + conn, + &name, + status.uid_validity, + status.exists, + status.highest_modseq, + ) + }) + .await?; + return Ok(false); + } + + let imap_state = match imap_client.fetch_uids_and_flags(creds, folder_name).await { + Ok(state) => state, + Err(e) => { + tracing::debug!( + folder = %folder_name, + error = %e, + "Skipping folder sync (full fetch failed)" + ); + return Ok(false); + } + }; + + let name = folder_name.to_owned(); + let status = status.clone(); + let folder_changed = database + .run(move |conn| { + let folder_name = &name; + let sync_keywords = db::mail_preferences::get_preferences(conn) + .map(|prefs| prefs.sync_imap_keywords) + .unwrap_or(false); + let transaction = conn.unchecked_transaction().map_err(|e| e.to_string())?; + let imap_map: HashMap> = imap_state + .into_iter() + .map(|(uid, flags)| { + let mut sorted = flags; + sorted.sort(); + (uid, sorted) + }) + .collect(); + + let mut folder_changed = false; + + for (uid, cached_flags_csv) in &cached { + match imap_map.get(uid) { + None => { + db::messages::delete_message(conn, folder_name, *uid)?; + folder_changed = true; + tracing::debug!( + folder = %folder_name, + uid = uid, + "Removed deleted message from cache" + ); + } + Some(imap_flags) => { + let mut cached_sorted: Vec<&str> = cached_flags_csv + .split(',') + .filter(|flag| !flag.is_empty()) + .collect(); + cached_sorted.sort(); + let cached_normalized = cached_sorted.join(","); + let imap_flags_csv = imap_flags.join(","); + + if cached_normalized != imap_flags_csv { + db::messages::update_message_flags( + conn, + folder_name, + *uid, + &imap_flags_csv, + )?; + folder_changed = true; + } + if sync_keywords { + db::tags::import_imap_keywords(conn, *uid, folder_name, imap_flags)?; + } + } + } + } + + db::folders::update_folder_sync_status( + conn, + folder_name, + status.uid_validity, + status.exists, + status.highest_modseq, + )?; + transaction.commit().map_err(|e| e.to_string())?; + Ok(folder_changed) + }) + .await?; + + if folder_changed { + event_bus + .publish( + user_hash, + MailEvent::FlagsChanged { + folder: folder_name.to_string(), + }, + ) + .await; + } + + Ok(folder_changed) +} diff --git a/src/realtime/ws.rs b/src/realtime/ws.rs new file mode 100644 index 0000000..3ba0353 --- /dev/null +++ b/src/realtime/ws.rs @@ -0,0 +1,194 @@ +use std::sync::Arc; + +use axum::Extension; +use axum::extract::WebSocketUpgrade; +use axum::extract::ws::{Message, WebSocket}; +use axum::response::{IntoResponse, Response}; +use futures::{SinkExt, StreamExt}; +use tokio::sync::{broadcast, mpsc}; + +use crate::auth::session::{AccountSession, SessionStore}; +use crate::config::AppConfig; +use crate::imap::client::{ImapClient, ImapCredentials}; +use crate::realtime::events::EventBus; +use crate::realtime::idle::IdleManager; +use crate::search::engine::SearchEngine; + +fn extract_browser_id(cookie_header: &str) -> Option { + for segment in cookie_header.split(';') { + let trimmed = segment.trim(); + if let Some(id) = trimmed.strip_prefix("rustbox_browser=") { + let id = id.trim(); + if !id.is_empty() { + return Some(id.to_string()); + } + } + } + None +} + +#[allow(clippy::too_many_arguments)] +pub async fn ws_handler( + ws: WebSocketUpgrade, + headers: axum::http::HeaderMap, + Extension(store): Extension>, + Extension(config): Extension>, + Extension(event_bus): Extension>, + Extension(idle_manager): Extension>, + Extension(imap_client): Extension>, + Extension(search_engine): Extension>, +) -> Response { + let browser_id = headers + .get_all("cookie") + .iter() + .filter_map(|v| v.to_str().ok()) + .find_map(extract_browser_id); + + let Some(browser_id) = browser_id else { + return ( + axum::http::StatusCode::UNAUTHORIZED, + "Missing browser session", + ) + .into_response(); + }; + + let accounts = store.get_browser_accounts(&browser_id); + + if accounts.is_empty() { + return (axum::http::StatusCode::UNAUTHORIZED, "No active accounts").into_response(); + } + + ws.on_upgrade(move |socket| { + handle_socket_multi_account( + socket, + accounts, + config, + event_bus, + idle_manager, + imap_client, + search_engine, + ) + }) +} + +#[allow(clippy::too_many_arguments)] +async fn handle_socket_multi_account( + socket: WebSocket, + accounts: Vec, + config: Arc, + event_bus: Arc, + idle_manager: Arc, + imap_client: Arc, + search_engine: Arc, +) { + tracing::info!(account_count = accounts.len(), "WebSocket connected"); + + let mut sync_handles: Vec> = Vec::new(); + let mut forward_handles: Vec> = Vec::new(); + + let (event_tx, mut event_rx) = + mpsc::channel::<(String, crate::realtime::events::MailEvent)>(64); + + for session in &accounts { + let user_hash = session.user_hash.clone(); + let account_id = session.account_id.clone(); + + let mut rx = event_bus.subscribe(&user_hash).await; + let tx = event_tx.clone(); + let aid = account_id.clone(); + + let forward_handle = tokio::spawn(async move { + loop { + match rx.recv().await { + Ok(event) => { + if tx.send((aid.clone(), event)).await.is_err() { + break; + } + } + Err(broadcast::error::RecvError::Closed) => break, + Err(broadcast::error::RecvError::Lagged(_)) => continue, + } + } + }); + forward_handles.push(forward_handle); + + let creds = ImapCredentials { + host: session.imap_host.clone(), + port: session.imap_port, + tls: session.imap_tls, + email: session.email.clone(), + password: session.password.clone(), + }; + + idle_manager + .start_idle( + user_hash.clone(), + "INBOX".to_string(), + creds.clone(), + event_bus.clone(), + config.clone(), + ) + .await; + + let handle = tokio::spawn(super::sync::sync_loop( + user_hash, + creds, + config.clone(), + imap_client.clone(), + event_bus.clone(), + search_engine.clone(), + )); + sync_handles.push(handle); + } + + drop(event_tx); + + let (mut ws_tx, mut ws_rx) = socket.split(); + let mut ping_interval = tokio::time::interval(std::time::Duration::from_secs(30)); + + loop { + tokio::select! { + Some((account_id, mail_event)) = event_rx.recv() => { + let msg = serde_json::json!({ + "accountId": account_id, + "event": mail_event, + }); + if let Ok(json) = serde_json::to_string(&msg) + && ws_tx.send(Message::Text(json.into())).await.is_err() + { + break; + } + } + + msg = ws_rx.next() => { + match msg { + Some(Ok(Message::Close(_))) | None => break, + Some(Ok(Message::Pong(_))) => {} + Some(Ok(_)) => {} + Some(Err(_)) => break, + } + } + + _ = ping_interval.tick() => { + if ws_tx.send(Message::Ping(vec![].into())).await.is_err() { + break; + } + } + } + } + + tracing::info!("WebSocket disconnected"); + + for handle in sync_handles { + handle.abort(); + } + + for handle in forward_handles { + handle.abort(); + } + + for session in &accounts { + idle_manager.stop_idle(&session.user_hash, "INBOX").await; + event_bus.cleanup(&session.user_hash).await; + } +} diff --git a/src/routes/attachments.rs b/src/routes/attachments.rs new file mode 100644 index 0000000..037cf11 --- /dev/null +++ b/src/routes/attachments.rs @@ -0,0 +1,255 @@ +use std::path::Path; +use std::sync::Arc; +use std::time::{Duration, SystemTime}; + +use axum::extract::Multipart; +use axum::extract::Path as AxumPath; +use axum::response::{IntoResponse, Response}; +use axum::{Extension, Json}; +use serde::Serialize; +use tokio::io::AsyncWriteExt; + +use crate::auth::session::SessionState; +use crate::config::AppConfig; +use crate::db; +use crate::error::AppError; + +/// Maximum attachment file size: 25 MB. +const MAX_ATTACHMENT_SIZE: usize = 25 * 1024 * 1024; +const STALE_PART_MAX_AGE: Duration = Duration::from_secs(24 * 60 * 60); + +async fn cleanup_stale_upload_parts(dir: &Path) { + let Ok(mut entries) = tokio::fs::read_dir(dir).await else { + return; + }; + while let Ok(Some(entry)) = entries.next_entry().await { + let path = entry.path(); + let is_part = path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.ends_with(".part")); + if !is_part { + continue; + } + let stale = entry + .metadata() + .await + .ok() + .and_then(|meta| meta.modified().ok()) + .and_then(|modified| SystemTime::now().duration_since(modified).ok()) + .is_some_and(|age| age >= STALE_PART_MAX_AGE); + if stale { + let _ = tokio::fs::remove_file(path).await; + } + } +} + +#[derive(Debug, Serialize)] +struct UploadResponse { + id: String, + filename: String, + content_type: String, + size: i64, +} + +#[derive(Debug, Serialize)] +struct DeleteResponse { + status: String, +} + +/// Handler for `POST /api/drafts/{draft_id}/attachments`. +/// +/// Accepts a multipart upload containing one or more files. Each file is +/// saved to disk and recorded in the `draft_attachments` table. +pub async fn upload_attachment( + Extension(session): Extension, + Extension(config): Extension>, + AxumPath(draft_id): AxumPath, + mut multipart: Multipart, +) -> Result { + db::drafts::validate_draft_id(&draft_id).map_err(AppError::BadRequest)?; + let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) + .map_err(|e| AppError::InternalError(format!("Failed to open database: {e}")))?; + + // Ensure draft exists (create it if not — auto-creates on first attachment upload). + let existing = db::drafts::get_draft(&conn, &draft_id).map_err(AppError::InternalError)?; + if existing.is_none() { + db::drafts::upsert_draft( + &conn, &draft_id, "", "", "", "", "", None, None, None, false, "normal", "html", + "none", false, false, false, + ) + .map_err(AppError::InternalError)?; + } + + // Build the attachment storage directory. + let att_dir = Path::new(&config.data_dir) + .join(&session.user_hash) + .join("attachments") + .join(&draft_id); + tokio::fs::create_dir_all(&att_dir) + .await + .map_err(|e| AppError::InternalError(format!("Failed to create attachment dir: {e}")))?; + cleanup_stale_upload_parts(&att_dir).await; + + let mut uploaded = Vec::new(); + + while let Some(mut field) = multipart + .next_field() + .await + .map_err(|e| AppError::BadRequest(format!("Invalid multipart data: {e}")))? + { + let filename = field.file_name().unwrap_or("unnamed").to_string(); + let content_type = field + .content_type() + .unwrap_or("application/octet-stream") + .to_string(); + let att_id = uuid::Uuid::new_v4().to_string(); + let file_path = att_dir.join(&att_id); + let part_path = att_dir.join(format!(".{att_id}.part")); + let mut file = tokio::fs::File::create(&part_path).await.map_err(|e| { + AppError::InternalError(format!("Failed to create attachment temp file: {e}")) + })?; + let mut size: usize = 0; + + loop { + let chunk = match field.chunk().await { + Ok(chunk) => chunk, + Err(e) => { + drop(file); + let _ = tokio::fs::remove_file(&part_path).await; + return Err(AppError::BadRequest(format!( + "Failed to read field data: {e}" + ))); + } + }; + let Some(chunk) = chunk else { break }; + size = size.saturating_add(chunk.len()); + if size > MAX_ATTACHMENT_SIZE { + drop(file); + let _ = tokio::fs::remove_file(&part_path).await; + return Err(AppError::BadRequest(format!( + "File '{}' exceeds maximum size of 25 MB", + filename + ))); + } + if let Err(e) = file.write_all(&chunk).await { + drop(file); + let _ = tokio::fs::remove_file(&part_path).await; + return Err(AppError::InternalError(format!( + "Failed to write attachment: {e}" + ))); + } + } + if let Err(e) = file.flush().await { + drop(file); + let _ = tokio::fs::remove_file(&part_path).await; + return Err(AppError::InternalError(format!( + "Failed to flush attachment: {e}" + ))); + } + drop(file); + if let Err(e) = tokio::fs::rename(&part_path, &file_path).await { + let _ = tokio::fs::remove_file(&part_path).await; + return Err(AppError::InternalError(format!( + "Failed to finalize attachment: {e}" + ))); + } + + let size = size as i64; + if let Err(e) = db::drafts::add_draft_attachment( + &conn, + &att_id, + &draft_id, + &filename, + &content_type, + size, + file_path.to_str().unwrap_or(""), + ) { + let _ = tokio::fs::remove_file(&file_path).await; + return Err(AppError::InternalError(e)); + } + + uploaded.push(UploadResponse { + id: att_id, + filename, + content_type, + size, + }); + } + + if uploaded.is_empty() { + return Err(AppError::BadRequest("No files uploaded".to_string())); + } + + Ok(Json(serde_json::json!({ + "attachments": uploaded, + })) + .into_response()) +} + +/// Handler for `GET /api/drafts/{draft_id}/attachments/{attachment_id}/content`. +/// +/// Serves the raw file content of a draft attachment for inline preview. +pub async fn get_attachment_content( + Extension(session): Extension, + Extension(config): Extension>, + AxumPath((draft_id, attachment_id)): AxumPath<(String, String)>, +) -> Result { + db::drafts::validate_draft_id(&draft_id).map_err(AppError::BadRequest)?; + let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) + .map_err(|e| AppError::InternalError(format!("Failed to open database: {e}")))?; + + let attachments = + db::drafts::get_draft_attachments(&conn, &draft_id).map_err(AppError::InternalError)?; + + let attachment = attachments + .iter() + .find(|a| a.id == attachment_id) + .ok_or_else(|| AppError::NotFound("Attachment not found".to_string()))?; + + let data = tokio::fs::read(&attachment.file_path) + .await + .map_err(|e| AppError::InternalError(format!("Failed to read attachment file: {e}")))?; + + Ok(Response::builder() + .header("content-type", &attachment.content_type) + .header("cache-control", "private, max-age=3600") + .body(axum::body::Body::from(data)) + .unwrap()) +} + +/// Handler for `DELETE /api/drafts/{draft_id}/attachments/{attachment_id}`. +/// +/// Removes an attachment from the database and deletes the file from disk. +pub async fn delete_attachment( + Extension(session): Extension, + Extension(config): Extension>, + AxumPath((draft_id, attachment_id)): AxumPath<(String, String)>, +) -> Result { + db::drafts::validate_draft_id(&draft_id).map_err(AppError::BadRequest)?; + let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) + .map_err(|e| AppError::InternalError(format!("Failed to open database: {e}")))?; + + // Get the attachment record so we can find the file path. + let attachments = + db::drafts::get_draft_attachments(&conn, &draft_id).map_err(AppError::InternalError)?; + + let attachment = attachments + .iter() + .find(|a| a.id == attachment_id) + .ok_or_else(|| AppError::NotFound("Attachment not found".to_string()))?; + + // Delete file from disk (best-effort). + let file_path = attachment.file_path.clone(); + if let Err(e) = tokio::fs::remove_file(&file_path).await { + tracing::warn!(error = %e, path = %file_path, "Failed to delete attachment file from disk"); + } + + // Delete from DB. + db::drafts::delete_draft_attachment(&conn, &attachment_id).map_err(AppError::InternalError)?; + + Ok(Json(DeleteResponse { + status: "deleted".to_string(), + }) + .into_response()) +} diff --git a/src/routes/auth.rs b/src/routes/auth.rs new file mode 100644 index 0000000..b9c1aed --- /dev/null +++ b/src/routes/auth.rs @@ -0,0 +1,1050 @@ +use std::sync::Arc; + +use axum::extract::Path; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use axum::{Extension, Json}; +use serde::{Deserialize, Serialize}; + +use crate::auth::imap_auth::{self, AuthResult}; +use crate::auth::session::{SessionState, SessionStore}; +use crate::auth::user_data; +use crate::config::AppConfig; +use crate::db; + +pub const BROWSER_COOKIE: &str = "rustbox_browser"; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AuthPolicyResponse { + remember_me_enabled: bool, + remember_me_days: u64, + session_timeout_hours: u64, +} + +pub async fn auth_policy(Extension(config): Extension>) -> Json { + Json(AuthPolicyResponse { + remember_me_enabled: config.remember_me_enabled, + remember_me_days: config.remember_me_days, + session_timeout_hours: config.session_timeout_hours, + }) +} + +/// JSON body expected on `POST /api/auth/login`. +#[derive(Deserialize)] +pub struct LoginRequest { + pub email: String, + pub password: String, + #[serde(default)] + pub remember: bool, + pub browser_id: Option, +} + +fn same_site_policy(_config: &AppConfig) -> &'static str { + // RustBox UI and API are same-origin in RustBox, so Strict is always valid. + "Strict" +} + +fn cookie_path(config: &AppConfig) -> &str { + config.normalized_base_path() +} + +fn browser_cookie( + browser_id: &str, + max_age_secs: u64, + secure: bool, + same_site: &str, + path: &str, +) -> String { + let secure_flag = if secure { " Secure;" } else { "" }; + format!( + "{}={};{} HttpOnly; SameSite={}; Path={}; Max-Age={}", + BROWSER_COOKIE, browser_id, secure_flag, same_site, path, max_age_secs + ) +} + +fn account_session_cookie( + account_id: &str, + token: &str, + max_age_secs: u64, + secure: bool, + same_site: &str, + path: &str, +) -> String { + let secure_flag = if secure { " Secure;" } else { "" }; + format!( + "rustbox_session_{}={};{} HttpOnly; SameSite={}; Path={}; Max-Age={}", + account_id, token, secure_flag, same_site, path, max_age_secs + ) +} + +fn clearing_browser_cookie(secure: bool, same_site: &str, path: &str) -> String { + let secure_flag = if secure { " Secure;" } else { "" }; + format!( + "{}=;{} HttpOnly; SameSite={}; Path={}; Max-Age=0", + BROWSER_COOKIE, secure_flag, same_site, path + ) +} + +fn clearing_account_cookie(account_id: &str, secure: bool, same_site: &str, path: &str) -> String { + let secure_flag = if secure { " Secure;" } else { "" }; + format!( + "rustbox_session_{}=;{} HttpOnly; SameSite={}; Path={}; Max-Age=0", + account_id, secure_flag, same_site, path + ) +} + +fn extract_browser_id(headers: &axum::http::HeaderMap) -> Option { + headers + .get_all("cookie") + .iter() + .filter_map(|v| v.to_str().ok()) + .find_map(|cookie| { + for segment in cookie.split(';') { + let trimmed = segment.trim(); + if let Some(id) = trimmed.strip_prefix("rustbox_browser=") { + let id = id.trim(); + if !id.is_empty() { + return Some(id.to_string()); + } + } + } + None + }) +} + +fn extract_session_cookies(headers: &axum::http::HeaderMap) -> Vec<(String, String)> { + let prefix = "rustbox_session_"; + let mut sessions = Vec::new(); + + for cookie in headers + .get_all("cookie") + .iter() + .filter_map(|v| v.to_str().ok()) + { + for segment in cookie.split(';') { + let trimmed = segment.trim(); + if let Some(rest) = trimmed.strip_prefix(prefix) + && let Some(eq_pos) = rest.find('=') + { + let account_id = &rest[..eq_pos]; + let token = &rest[eq_pos + 1..]; + if !account_id.is_empty() && !token.is_empty() { + sessions.push((account_id.to_string(), token.to_string())); + } + } + } + } + sessions +} + +/// `POST /api/auth/login` +/// +/// Validates the user's credentials against the configured IMAP server. +/// On success, creates a session, provisions the user's data directory, +/// and returns session cookies. +pub async fn login( + Extension(store): Extension>, + Extension(config): Extension>, + headers: axum::http::HeaderMap, + Json(body): Json, +) -> Response { + if body.email.trim().is_empty() || body.password.trim().is_empty() { + return ( + StatusCode::BAD_REQUEST, + [("content-type", "application/json")], + serde_json::json!({ + "error": { + "code": "BAD_REQUEST", + "message": "Email and password are required", + "status": 400 + } + }) + .to_string(), + ) + .into_response(); + } + + // Server endpoints are administrator-owned configuration. The login + // request supplies only user credentials; per-request host overrides are + // intentionally ignored so credentials cannot be redirected to an + // arbitrary mail server. + let Some(imap_host) = config + .imap_host + .clone() + .filter(|host| !host.trim().is_empty()) + else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + [("content-type", "application/json")], + serde_json::json!({ + "error": { + "code": "SERVICE_UNAVAILABLE", + "message": "IMAP server is not configured in RustBox", + "status": 503 + } + }) + .to_string(), + ) + .into_response(); + }; + + let smtp_host = config + .smtp_host + .clone() + .filter(|host| !host.trim().is_empty()) + .unwrap_or_else(|| imap_host.clone()); + + let imap_port = config.imap_port; + let imap_tls = config.tls_enabled; + let smtp_port = config.smtp_port; + let smtp_tls = config.tls_enabled; + + let result = imap_auth::validate_imap_credentials( + &imap_host, + imap_port, + imap_tls, + &body.email, + &body.password, + ) + .await; + + match result { + AuthResult::Success => { + let provided_browser_id = body + .browser_id + .clone() + .or_else(|| extract_browser_id(&headers)); + + if let Some(ref browser_id) = provided_browser_id { + let existing_accounts = store.get_browser_accounts(browser_id); + let duplicate = existing_accounts + .iter() + .find(|a| a.email == body.email && a.imap_host == imap_host); + + if let Some(dup) = duplicate { + tracing::debug!( + email = %body.email, + imap_host = %imap_host, + existing_account_id = %dup.account_id, + "login: duplicate account detected" + ); + return ( + StatusCode::CONFLICT, + [("content-type", "application/json")], + serde_json::json!({ + "error": { + "code": "DUPLICATE_ACCOUNT", + "message": "This account is already logged in", + "status": 409 + } + }) + .to_string(), + ) + .into_response(); + } + } + + let user_hash = user_data::hash_email(&body.email); + if let Err(e) = user_data::provision_user_data(&config.data_dir, &user_hash) { + tracing::error!(error = %e, "failed to provision user data directory"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + [("content-type", "application/json")], + serde_json::json!({ + "error": { + "code": "INTERNAL_ERROR", + "message": "Failed to provision user data", + "status": 500 + } + }) + .to_string(), + ) + .into_response(); + } + + if let Ok(conn) = db::pool::open_user_db(&config.data_dir, &user_hash) { + match db::identities::has_identities(&conn) { + Ok(false) => { + let default_identity = db::identities::CreateIdentity { + email: body.email.clone(), + display_name: String::new(), + signature_html: String::new(), + is_default: true, + reply_to: String::new(), + bcc: String::new(), + organization: String::new(), + signature_text: String::new(), + signature_new: true, + signature_reply: true, + signature_forward: true, + signature_position: "above".to_string(), + pgp_fingerprint: None, + pgp_sign_default: false, + pgp_encrypt_default: false, + pgp_inline_default: false, + }; + if let Err(e) = db::identities::create_identity(&conn, &default_identity) { + tracing::warn!(error = %e, "Failed to auto-create default identity"); + } + } + Err(e) => { + tracing::warn!(error = %e, "Failed to check for existing identities"); + } + _ => {} + } + } + + let remember = body.remember && config.remember_me_enabled; + let session_hours = if remember { + config.remember_me_days.saturating_mul(24) + } else { + config.session_timeout_hours + }; + + let browser_id = provided_browser_id + .clone() + .unwrap_or_else(|| store.create_browser()); + tracing::debug!( + email = %body.email, + provided_browser_id = ?provided_browser_id, + effective_browser_id = %browser_id, + "login: creating session" + ); + + let timeout_override = if remember { + Some(std::time::Duration::from_secs(session_hours * 3600)) + } else { + None + }; + let (token, account_id) = store.add_account_to_browser_with_timeout( + &browser_id, + body.email.clone(), + body.password, + user_hash, + imap_host.clone(), + imap_port, + imap_tls, + smtp_host.clone(), + smtp_port, + smtp_tls, + timeout_override, + ); + + let user_agent = headers + .get(axum::http::header::USER_AGENT) + .and_then(|value| value.to_str().ok()) + .unwrap_or("") + .to_string(); + store.set_user_agent(&account_id, user_agent); + + let max_age = session_hours * 3600; + let secure = config.environment != "development"; + let same_site = same_site_policy(&config); + let path = cookie_path(&config); + let browser_cookie_header = + browser_cookie(&browser_id, max_age, secure, same_site, path); + let session_cookie_header = + account_session_cookie(&account_id, &token, max_age, secure, same_site, path); + + let response = ( + StatusCode::CREATED, + [("content-type", "application/json")], + serde_json::json!({ + "account": { + "id": account_id, + "email": body.email + } + }) + .to_string(), + ) + .into_response(); + + let mut response = response; + let headers = response.headers_mut(); + if let Ok(header_value) = browser_cookie_header.parse() { + headers.append("set-cookie", header_value); + } + if let Ok(header_value) = session_cookie_header.parse() { + headers.append("set-cookie", header_value); + } + + response + } + AuthResult::InvalidCredentials => ( + StatusCode::UNAUTHORIZED, + [("content-type", "application/json")], + serde_json::json!({ + "error": { + "code": "UNAUTHORIZED", + "message": "Invalid email or password", + "status": 401 + } + }) + .to_string(), + ) + .into_response(), + AuthResult::ServerUnreachable(msg) => ( + StatusCode::SERVICE_UNAVAILABLE, + [("content-type", "application/json")], + serde_json::json!({ + "error": { + "code": "SERVER_UNREACHABLE", + "message": msg, + "status": 503 + } + }) + .to_string(), + ) + .into_response(), + } +} + +/// `GET /api/auth/session` +/// +/// Returns the current user's session information. Requires authentication +/// (the `auth_guard` middleware injects `SessionState` into extensions). +pub async fn get_session(Extension(session): Extension) -> Response { + ( + StatusCode::OK, + [("content-type", "application/json")], + serde_json::json!({ "user": { "email": session.email } }).to_string(), + ) + .into_response() +} + +pub async fn list_accounts( + Extension(store): Extension>, + Extension(config): Extension>, + headers: axum::http::HeaderMap, +) -> Response { + let secure = config.environment != "development"; + let same_site = same_site_policy(&config); + + let browser_id = extract_browser_id(&headers); + + let Some(browser_id) = browser_id else { + tracing::debug!("list_accounts: no browser_id cookie found"); + return ( + StatusCode::OK, + [("content-type", "application/json")], + serde_json::json!({ "accounts": [], "browserSessionExpired": true }).to_string(), + ) + .into_response(); + }; + + let browser_exists = store.browser_exists(&browser_id); + if !browser_exists { + tracing::debug!(browser_id = %browser_id, "list_accounts: browser_id not found on server"); + let clear_browser = clearing_browser_cookie(secure, same_site, cookie_path(&config)); + return ( + StatusCode::OK, + [ + ("content-type", "application/json"), + ("set-cookie", &clear_browser), + ], + serde_json::json!({ "accounts": [], "browserSessionExpired": true }).to_string(), + ) + .into_response(); + } + + let session_cookies = extract_session_cookies(&headers); + let session_map: std::collections::HashMap = + session_cookies.into_iter().collect(); + + let browser_accounts = store.get_browser_accounts(&browser_id); + let browser_account_ids: Vec = browser_accounts + .iter() + .map(|s| s.account_id.clone()) + .collect(); + + let mut valid_accounts: Vec = Vec::new(); + let mut clear_cookies: Vec = Vec::new(); + + for session in &browser_accounts { + if let Some(token) = session_map.get(&session.account_id) { + if let Some(valid_session) = store.get(token) { + valid_accounts.push(serde_json::json!({ + "id": valid_session.account_id, + "email": valid_session.email + })); + } else { + store.remove_account(&session.account_id); + clear_cookies.push(clearing_account_cookie( + &session.account_id, + secure, + same_site, + cookie_path(&config), + )); + } + } else { + store.remove_account(&session.account_id); + } + } + + for account_id in session_map.keys() { + if !browser_account_ids.contains(account_id) { + clear_cookies.push(clearing_account_cookie( + account_id, + secure, + same_site, + cookie_path(&config), + )); + } + } + + if valid_accounts.is_empty() { + tracing::debug!( + browser_id = %browser_id, + "list_accounts: no valid accounts found" + ); + let clear_browser = clearing_browser_cookie(secure, same_site, cookie_path(&config)); + let body = serde_json::json!({ "accounts": [], "browserSessionExpired": true }).to_string(); + + let mut response = axum::response::Response::new(axum::body::Body::from(body)); + *response.status_mut() = StatusCode::OK; + response.headers_mut().insert( + axum::http::header::CONTENT_TYPE, + axum::http::HeaderValue::from_static("application/json"), + ); + response.headers_mut().append( + axum::http::header::SET_COOKIE, + axum::http::HeaderValue::from_str(&clear_browser).unwrap(), + ); + for cookie in clear_cookies { + response.headers_mut().append( + axum::http::header::SET_COOKIE, + axum::http::HeaderValue::from_str(&cookie).unwrap(), + ); + } + return response; + } + + tracing::debug!( + browser_id = %browser_id, + account_count = valid_accounts.len(), + accounts = ?valid_accounts.iter().filter_map(|a| a.get("email").and_then(|e| e.as_str())).collect::>(), + "list_accounts: returning valid accounts" + ); + + let body = serde_json::json!({ "accounts": valid_accounts }).to_string(); + let mut response = axum::response::Response::new(axum::body::Body::from(body)); + *response.status_mut() = StatusCode::OK; + response.headers_mut().insert( + axum::http::header::CONTENT_TYPE, + axum::http::HeaderValue::from_static("application/json"), + ); + for cookie in clear_cookies { + response.headers_mut().append( + axum::http::header::SET_COOKIE, + axum::http::HeaderValue::from_str(&cookie).unwrap(), + ); + } + response +} + +pub async fn remove_account( + Extension(store): Extension>, + Extension(config): Extension>, + Path(account_id): Path, + headers: axum::http::HeaderMap, +) -> Response { + let browser_id = extract_browser_id(&headers); + + let Some(browser_id) = browser_id else { + return ( + StatusCode::UNAUTHORIZED, + [("content-type", "application/json")], + serde_json::json!({ + "error": { + "code": "UNAUTHORIZED", + "message": "No browser session", + "status": 401 + } + }) + .to_string(), + ) + .into_response(); + }; + + let browser_accounts = store.get_browser_accounts(&browser_id); + let account_belongs_to_browser = browser_accounts.iter().any(|s| s.account_id == account_id); + + if !account_belongs_to_browser { + return ( + StatusCode::FORBIDDEN, + [("content-type", "application/json")], + serde_json::json!({ + "error": { + "code": "FORBIDDEN", + "message": "Account does not belong to this browser session", + "status": 403 + } + }) + .to_string(), + ) + .into_response(); + } + + store.remove_account(&account_id); + + let secure = config.environment != "development"; + let same_site = same_site_policy(&config); + let cookie = clearing_account_cookie(&account_id, secure, same_site, cookie_path(&config)); + + ( + StatusCode::OK, + [ + ("content-type", "application/json"), + ("set-cookie", &cookie), + ], + serde_json::json!({ "status": "logged_out" }).to_string(), + ) + .into_response() +} + +fn friendly_user_agent(user_agent: &str) -> String { + let ua = user_agent.trim(); + if ua.is_empty() { + return "Unknown device".to_string(); + } + + let browser = if ua.contains("Edg/") { + "Edge" + } else if ua.contains("Firefox/") || ua.contains("FxiOS/") { + "Firefox" + } else if ua.contains("OPR/") || ua.contains("Opera/") { + "Opera" + } else if ua.contains("Chrome/") || ua.contains("CriOS/") { + "Chrome" + } else if ua.contains("Safari/") && (ua.contains("Version/") || ua.contains("Mobile/")) { + "Safari" + } else { + "Browser" + }; + + let device = if ua.contains("iPhone") { + "iPhone" + } else if ua.contains("iPad") { + "iPad" + } else if ua.contains("Android") { + "Android" + } else if ua.contains("Windows") { + "Windows" + } else if ua.contains("Macintosh") || ua.contains("Mac OS X") { + "macOS" + } else if ua.contains("CrOS") { + "ChromeOS" + } else if ua.contains("Linux") { + "Linux" + } else { + "Unknown OS" + }; + + format!("{browser} · {device}") +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct ActiveSessionResponse { + id: String, + email: String, + created_at: u64, + last_active: u64, + expires_at: u64, + remembered: bool, + current: bool, + user_agent: String, + client_name: String, +} + +/// `GET /api/auth/sessions` +/// +/// Lists all live RustBox sessions for the currently authenticated user across +/// browser sessions. Session tokens and server connection data are never +/// returned to the UI. +pub async fn list_active_sessions( + Extension(store): Extension>, + Extension(config): Extension>, + Extension(session): Extension, +) -> Response { + let sessions: Vec = store + .get_user_sessions(&session.user_hash) + .into_iter() + .map(|item| { + let timeout_secs = item + .timeout_override + .map(|duration| duration.as_secs()) + .unwrap_or(config.session_timeout_hours * 3600); + ActiveSessionResponse { + current: item.account_id == session.account_id, + id: item.account_id, + email: item.email, + created_at: item.created_at_unix, + last_active: item.last_accessed_unix, + expires_at: item.last_accessed_unix.saturating_add(timeout_secs), + remembered: item.timeout_override.is_some(), + client_name: friendly_user_agent(&item.user_agent), + user_agent: item.user_agent, + } + }) + .collect(); + + Json(serde_json::json!({ "sessions": sessions })).into_response() +} + +/// `DELETE /api/auth/sessions/{id}` — revoke another active session. +pub async fn revoke_active_session( + Extension(store): Extension>, + Extension(session): Extension, + Path(account_id): Path, +) -> Response { + if account_id == session.account_id { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "error": { + "code": "CURRENT_SESSION", + "message": "Use Log out to close the current session", + "status": 400 + } + })), + ) + .into_response(); + } + + if store.remove_user_session(&session.user_hash, &account_id) { + Json(serde_json::json!({ "status": "revoked" })).into_response() + } else { + ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ + "error": { + "code": "NOT_FOUND", + "message": "Session not found", + "status": 404 + } + })), + ) + .into_response() + } +} + +/// `POST /api/auth/sessions/revoke-others` — keep the current session and +/// revoke every other live session for the same RustBox user. +pub async fn revoke_other_sessions( + Extension(store): Extension>, + Extension(session): Extension, +) -> Response { + let revoked = store.remove_other_user_sessions(&session.user_hash, &session.account_id); + Json(serde_json::json!({ "status": "revoked", "count": revoked })).into_response() +} + +/// `POST /api/auth/logout` +/// +/// Removes the current session from the store and clears the session cookie. +/// Requires authentication. +pub async fn logout( + Extension(store): Extension>, + Extension(config): Extension>, + headers: axum::http::HeaderMap, +) -> Response { + let browser_id = extract_browser_id(&headers); + + let mut cookies_to_clear: Vec = Vec::new(); + + let secure = config.environment != "development"; + let same_site = same_site_policy(&config); + + if let Some(ref browser_id) = browser_id { + let accounts = store.get_browser_accounts(browser_id); + for account in accounts { + cookies_to_clear.push(clearing_account_cookie( + &account.account_id, + secure, + same_site, + cookie_path(&config), + )); + } + store.remove_browser(browser_id); + } + + cookies_to_clear.push(clearing_browser_cookie( + secure, + same_site, + cookie_path(&config), + )); + + let response = ( + StatusCode::OK, + [("content-type", "application/json")], + serde_json::json!({ "status": "logged_out" }).to_string(), + ) + .into_response(); + + let mut response = response; + let headers = response.headers_mut(); + for cookie in cookies_to_clear { + if let Ok(header_value) = cookie.parse() { + headers.append("set-cookie", header_value); + } + } + + response +} + +#[cfg(test)] +mod tests { + use super::*; + use serde::Serialize; + + #[derive(Serialize)] + struct UserResponse { + user: UserInfo, + } + + #[derive(Serialize)] + struct UserInfo { + email: String, + } + + #[derive(Serialize)] + struct LogoutResponse { + status: &'static str, + } + + #[test] + fn friendly_user_agent_labels_common_clients() { + assert_eq!( + friendly_user_agent( + "Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0" + ), + "Firefox · Linux" + ); + assert_eq!( + friendly_user_agent( + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 Version/18.0 Mobile/15E148 Safari/604.1" + ), + "Safari · iPhone" + ); + assert_eq!(friendly_user_agent(""), "Unknown device"); + } + + #[test] + fn user_response_serialization() { + let resp = UserResponse { + user: UserInfo { + email: "test@example.com".to_string(), + }, + }; + let json = serde_json::to_value(&resp).unwrap(); + assert_eq!(json["user"]["email"], "test@example.com"); + } + + #[test] + fn logout_response_serialization() { + let resp = LogoutResponse { + status: "logged_out", + }; + let json = serde_json::to_value(&resp).unwrap(); + assert_eq!(json["status"], "logged_out"); + } + + fn test_config(environment: &str, _cors_origin: Option<&str>) -> AppConfig { + AppConfig { + host: "127.0.0.1".to_string(), + port: 3001, + imap_host: None, + imap_port: 993, + smtp_host: None, + smtp_port: 587, + tls_enabled: true, + data_dir: "/data".to_string(), + session_timeout_hours: 24, + environment: environment.to_string(), + base_path: None, + sieve_host: None, + sieve_port: 4190, + sieve_tls_mode: "starttls".to_string(), + sieve_allow_insecure_auth: false, + sieve_timeout_secs: 10, + trusted_proxies: None, + outgoing_headers: Vec::new(), + } + } + + #[tokio::test] + async fn login_reuses_scoped_browser_cookie_when_body_omits_browser_id() { + use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let imap = tokio::spawn(async move { + for _ in 0..3 { + let (stream, _) = listener.accept().await.unwrap(); + let mut stream = BufReader::new(stream); + stream + .get_mut() + .write_all(b"* OK test IMAP ready\r\n") + .await + .unwrap(); + let mut command = String::new(); + stream.read_line(&mut command).await.unwrap(); + let tag = command.split_whitespace().next().unwrap(); + let response = format!("{tag} OK LOGIN completed\r\n"); + stream + .get_mut() + .write_all(response.as_bytes()) + .await + .unwrap(); + command.clear(); + stream.read_line(&mut command).await.unwrap(); + let tag = command.split_whitespace().next().unwrap(); + let response = format!("* BYE logging out\r\n{tag} OK LOGOUT completed\r\n"); + stream + .get_mut() + .write_all(response.as_bytes()) + .await + .unwrap(); + } + }); + + let data_dir = tempfile::tempdir().unwrap(); + let mut config = test_config("development", Some("http://localhost:3000")); + config.base_path = Some("/mail".to_string()); + config.data_dir = data_dir.path().to_str().unwrap().to_string(); + config.imap_host = Some("127.0.0.1".to_string()); + config.imap_port = port; + config.smtp_host = Some("127.0.0.1".to_string()); + config.tls_enabled = false; + let config = Arc::new(config); + let store = Arc::new(SessionStore::new(std::time::Duration::from_secs(3600))); + let request = |email: &str| LoginRequest { + email: email.to_string(), + password: "password".to_string(), + remember: false, + browser_id: None, + }; + + let first = login( + Extension(store.clone()), + Extension(config.clone()), + axum::http::HeaderMap::new(), + Json(request("first@example.com")), + ) + .await; + assert_eq!(first.status(), StatusCode::CREATED); + let cookie = first + .headers() + .get_all("set-cookie") + .iter() + .map(|value| value.to_str().unwrap()) + .find(|value| value.starts_with("rustbox_browser=")) + .unwrap(); + assert!(cookie.contains("Path=/mail;")); + let cookie_pair = cookie.split(';').next().unwrap(); + let browser_id = cookie_pair.strip_prefix("rustbox_browser=").unwrap(); + let mut headers = axum::http::HeaderMap::new(); + headers.insert("cookie", cookie_pair.parse().unwrap()); + + let second = login( + Extension(store.clone()), + Extension(config.clone()), + headers.clone(), + Json(request("second@example.com")), + ) + .await; + assert_eq!(second.status(), StatusCode::CREATED); + assert!(second.headers().get_all("set-cookie").iter().any(|value| { + value + .to_str() + .unwrap() + .starts_with(&format!("{cookie_pair};")) + })); + let accounts = store.get_browser_accounts(browser_id); + assert_eq!(accounts.len(), 2); + assert!( + accounts + .iter() + .any(|account| account.email == "first@example.com") + ); + assert!( + accounts + .iter() + .any(|account| account.email == "second@example.com") + ); + + let duplicate = login( + Extension(store.clone()), + Extension(config), + headers, + Json(request("second@example.com")), + ) + .await; + assert_eq!(duplicate.status(), StatusCode::CONFLICT); + assert_eq!(store.get_browser_accounts(browser_id).len(), 2); + imap.await.unwrap(); + } + + #[test] + fn issued_and_clearing_cookies_use_the_normalized_base_path() { + for (base_path, expected_path) in [ + (None, "/"), + (Some(""), "/"), + (Some("/"), "/"), + (Some("/oxi"), "/oxi"), + ] { + let mut config = test_config("development", None); + config.base_path = base_path.map(str::to_string); + let path = cookie_path(&config); + let cookies = [ + browser_cookie("browser", 3600, false, "Strict", path), + account_session_cookie("account", "token", 3600, false, "Strict", path), + clearing_browser_cookie(false, "Strict", path), + clearing_account_cookie("account", false, "Strict", path), + ]; + + for cookie in cookies { + let path_attribute = cookie + .split(';') + .map(str::trim) + .find_map(|attribute| attribute.strip_prefix("Path=")); + assert_eq!( + path_attribute, + Some(expected_path), + "{base_path:?}: {cookie}" + ); + } + } + } + + #[test] + fn same_site_is_strict_for_rustbox_development() { + let config = test_config("development", Some("http://localhost:3000")); + assert_eq!(same_site_policy(&config), "Strict"); + + let cookie = browser_cookie( + "browser", + 3600, + false, + same_site_policy(&config), + cookie_path(&config), + ); + assert!(cookie.contains("SameSite=Strict")); + } + + #[test] + fn same_site_stays_strict_outside_development() { + let config = test_config("production", Some("https://app.example.com")); + assert_eq!(same_site_policy(&config), "Strict"); + + let cookie = browser_cookie( + "browser", + 3600, + true, + same_site_policy(&config), + cookie_path(&config), + ); + assert!(cookie.contains("SameSite=Strict")); + } +} diff --git a/src/routes/calendar.rs b/src/routes/calendar.rs new file mode 100644 index 0000000..8476157 --- /dev/null +++ b/src/routes/calendar.rs @@ -0,0 +1,452 @@ +use std::sync::Arc; + +use axum::extract::{Path, Query}; +use axum::http::{HeaderValue, StatusCode, header}; +use axum::response::{IntoResponse, Response}; +use axum::{Extension, Json}; +use serde::{Deserialize, Serialize}; + +use crate::auth::session::SessionState; +use crate::calendar::ics; +use crate::config::AppConfig; +use crate::db; +use crate::db::calendar::{CreateEvent, UpdateCalendarSettings, UpdateEvent}; +use crate::error::AppError; + +// --------------------------------------------------------------------------- +// Query / request types +// --------------------------------------------------------------------------- + +/// Query parameters for `GET /api/calendar/events`. +#[derive(Deserialize)] +pub struct ListEventsParams { + /// Start of the time range (ISO 8601). + pub start: String, + /// End of the time range (ISO 8601). + pub end: String, +} + +/// JSON body for `POST /api/calendar/meeting-templates`. +#[derive(Deserialize)] +pub struct CreateMeetingTemplateRequest { + pub name: String, + pub url_template: String, + #[serde(default)] + pub icon: String, +} + +/// Response for ICS import. +#[derive(Serialize)] +pub struct ImportIcsResponse { + pub created: usize, + pub skipped: usize, + pub events: Vec, +} + +// --------------------------------------------------------------------------- +// Event handlers +// --------------------------------------------------------------------------- + +fn ics_escape(value: &str) -> String { + value + .replace('\\', "\\\\") + .replace('\n', "\\n") + .replace('\r', "") + .replace(',', "\\,") + .replace(';', "\\;") +} + +fn ics_datetime(value: &str) -> String { + let mut digits = String::with_capacity(value.len()); + for ch in value.chars() { + if ch.is_ascii_digit() || ch == 'T' { + digits.push(ch); + } + } + if let Some(dot_pos) = digits.find('.') { + digits.truncate(dot_pos); + } + if digits.len() >= 15 { + digits.truncate(15); + } + if value.ends_with('Z') && !digits.ends_with('Z') { + digits.push('Z'); + } + digits +} + +fn ics_date(value: &str) -> String { + value + .chars() + .filter(|ch| ch.is_ascii_digit()) + .take(8) + .collect() +} + +fn append_ics_event(out: &mut String, event: &db::calendar::CalendarEvent) { + out.push_str("BEGIN:VEVENT\r\n"); + let uid = event.source_uid.as_deref().unwrap_or(&event.id); + out.push_str(&format!("UID:{}\r\n", ics_escape(uid))); + out.push_str(&format!("SUMMARY:{}\r\n", ics_escape(&event.title))); + if !event.description.is_empty() { + out.push_str(&format!( + "DESCRIPTION:{}\r\n", + ics_escape(&event.description) + )); + } + if !event.location.is_empty() { + out.push_str(&format!("LOCATION:{}\r\n", ics_escape(&event.location))); + } + if event.all_day { + out.push_str(&format!( + "DTSTART;VALUE=DATE:{}\r\n", + ics_date(&event.start_time) + )); + out.push_str(&format!( + "DTEND;VALUE=DATE:{}\r\n", + ics_date(&event.end_time) + )); + } else { + out.push_str(&format!("DTSTART:{}\r\n", ics_datetime(&event.start_time))); + out.push_str(&format!("DTEND:{}\r\n", ics_datetime(&event.end_time))); + } + if let Some(rule) = event + .recurrence_rule + .as_deref() + .filter(|value| !value.trim().is_empty()) + { + out.push_str(&format!("RRULE:{}\r\n", rule.trim())); + } + if !event.organizer.trim().is_empty() { + out.push_str(&format!( + "ORGANIZER:mailto:{}\r\n", + ics_escape(event.organizer.trim()) + )); + } + if let Ok(attendees) = serde_json::from_str::>(&event.attendees) { + for attendee in attendees + .into_iter() + .filter(|value| !value.trim().is_empty()) + { + out.push_str(&format!( + "ATTENDEE:mailto:{}\r\n", + ics_escape(attendee.trim()) + )); + } + } + if let Some(url) = event + .meeting_url + .as_deref() + .filter(|value| !value.trim().is_empty()) + { + out.push_str(&format!("URL:{}\r\n", ics_escape(url.trim()))); + } + if let Some(color) = event + .color + .as_deref() + .filter(|value| !value.trim().is_empty()) + { + out.push_str(&format!("COLOR:{}\r\n", ics_escape(color.trim()))); + } + if !event.status.trim().is_empty() { + out.push_str(&format!( + "STATUS:{}\r\n", + event.status.trim().to_ascii_uppercase() + )); + } + if let Some(minutes) = event.reminder_minutes.filter(|value| *value >= 0) { + out.push_str("BEGIN:VALARM\r\nACTION:DISPLAY\r\n"); + out.push_str(&format!("TRIGGER:-PT{}M\r\n", minutes)); + out.push_str("DESCRIPTION:Reminder\r\nEND:VALARM\r\n"); + } + out.push_str("END:VEVENT\r\n"); +} + +/// `GET /api/calendar/events?start=...&end=...` +/// +/// List events within a time range. +pub async fn list_events( + Extension(session): Extension, + Extension(config): Extension>, + Query(params): Query, +) -> Result { + let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + + let events = db::calendar::list_events(&conn, ¶ms.start, ¶ms.end) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + + Ok(Json(serde_json::json!({ "events": events })).into_response()) +} + +/// `GET /api/calendar/events/export-ics` +/// +/// Export all locally known calendar events as a standard iCalendar file. +pub async fn export_ics( + Extension(session): Extension, + Extension(config): Extension>, +) -> Result { + let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + let events = db::calendar::list_all_events(&conn) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + + let mut body = String::from( + "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//RustBox//Calendar 0.0.1//EN\r\nCALSCALE:GREGORIAN\r\n", + ); + for event in &events { + append_ics_event(&mut body, event); + } + body.push_str("END:VCALENDAR\r\n"); + + let mut response = (StatusCode::OK, body).into_response(); + response.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static("text/calendar; charset=utf-8"), + ); + response.headers_mut().insert( + header::CONTENT_DISPOSITION, + HeaderValue::from_static("attachment; filename=rustbox-calendar.ics"), + ); + Ok(response) +} + +/// `GET /api/calendar/events/{id}` +/// +/// Get a single event by ID. +pub async fn get_event( + Extension(session): Extension, + Extension(config): Extension>, + Path(id): Path, +) -> Result { + let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + + let event = db::calendar::get_event(&conn, &id) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + + match event { + Some(e) => Ok(Json(e).into_response()), + None => Err(AppError::NotFound(format!("Event '{id}' not found"))), + } +} + +/// `POST /api/calendar/events` +/// +/// Create a new calendar event. +pub async fn create_event( + Extension(session): Extension, + Extension(config): Extension>, + Json(body): Json, +) -> Result { + if body.title.trim().is_empty() { + return Err(AppError::BadRequest("Title is required".to_string())); + } + if body.start_time.trim().is_empty() { + return Err(AppError::BadRequest("Start time is required".to_string())); + } + if body.end_time.trim().is_empty() { + return Err(AppError::BadRequest("End time is required".to_string())); + } + + let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + + let event = db::calendar::create_event(&conn, &body) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + + Ok((axum::http::StatusCode::CREATED, Json(event)).into_response()) +} + +/// `PUT /api/calendar/events/{id}` +/// +/// Update an existing calendar event. +pub async fn update_event( + Extension(session): Extension, + Extension(config): Extension>, + Path(id): Path, + Json(body): Json, +) -> Result { + let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + + match db::calendar::update_event(&conn, &id, &body) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))? + { + Some(event) => Ok(Json(event).into_response()), + None => Err(AppError::NotFound(format!("Event '{id}' not found"))), + } +} + +/// `DELETE /api/calendar/events/{id}` +/// +/// Delete a calendar event. +pub async fn delete_event( + Extension(session): Extension, + Extension(config): Extension>, + Path(id): Path, +) -> Result { + let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + + let deleted = db::calendar::delete_event(&conn, &id) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + + if deleted { + Ok(Json(serde_json::json!({ "status": "deleted" })).into_response()) + } else { + Err(AppError::NotFound(format!("Event '{id}' not found"))) + } +} + +/// `POST /api/calendar/events/import-ics` +/// +/// Import events from ICS text. Deduplicates by source_uid. +pub async fn import_ics( + Extension(session): Extension, + Extension(config): Extension>, + body: String, +) -> Result { + if body.trim().is_empty() { + return Err(AppError::BadRequest("Empty ICS body".to_string())); + } + + let parsed = ics::parse_ics(&body) + .map_err(|e| AppError::BadRequest(format!("Failed to parse ICS: {e}")))?; + + let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + + let mut created_events = Vec::new(); + let mut skipped = 0usize; + + for event_data in parsed { + // Deduplicate by source_uid + if let Some(ref uid) = event_data.source_uid + && let Ok(Some(_)) = db::calendar::find_event_by_source_uid(&conn, uid) + { + skipped += 1; + continue; + } + + match db::calendar::create_event(&conn, &event_data) { + Ok(event) => created_events.push(event), + Err(e) => { + tracing::warn!("Failed to create imported event: {e}"); + skipped += 1; + } + } + } + + Ok(Json(ImportIcsResponse { + created: created_events.len(), + skipped, + events: created_events, + }) + .into_response()) +} + +// --------------------------------------------------------------------------- +// Settings handlers +// --------------------------------------------------------------------------- + +/// `GET /api/calendar/settings` +/// +/// Get calendar settings. +pub async fn get_calendar_settings( + Extension(session): Extension, + Extension(config): Extension>, +) -> Result { + let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + + let settings = db::calendar::get_calendar_settings(&conn) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + + Ok(Json(settings).into_response()) +} + +/// `PUT /api/calendar/settings` +/// +/// Update calendar settings. +pub async fn update_calendar_settings( + Extension(session): Extension, + Extension(config): Extension>, + Json(body): Json, +) -> Result { + let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + + let settings = db::calendar::update_calendar_settings(&conn, &body) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + + Ok(Json(settings).into_response()) +} + +// --------------------------------------------------------------------------- +// Meeting template handlers +// --------------------------------------------------------------------------- + +/// `GET /api/calendar/meeting-templates` +/// +/// List all meeting templates. +pub async fn list_meeting_templates( + Extension(session): Extension, + Extension(config): Extension>, +) -> Result { + let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + + let templates = db::calendar::list_meeting_templates(&conn) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + + Ok(Json(serde_json::json!({ "templates": templates })).into_response()) +} + +/// `POST /api/calendar/meeting-templates` +/// +/// Create a new meeting template. +pub async fn create_meeting_template( + Extension(session): Extension, + Extension(config): Extension>, + Json(body): Json, +) -> Result { + if body.name.trim().is_empty() { + return Err(AppError::BadRequest("Name is required".to_string())); + } + if body.url_template.trim().is_empty() { + return Err(AppError::BadRequest("URL template is required".to_string())); + } + + let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + + let template = + db::calendar::create_meeting_template(&conn, &body.name, &body.url_template, &body.icon) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + + Ok((axum::http::StatusCode::CREATED, Json(template)).into_response()) +} + +/// `DELETE /api/calendar/meeting-templates/{id}` +/// +/// Delete a meeting template. +pub async fn delete_meeting_template( + Extension(session): Extension, + Extension(config): Extension>, + Path(id): Path, +) -> Result { + let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + + let deleted = db::calendar::delete_meeting_template(&conn, id) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + + if deleted { + Ok(Json(serde_json::json!({ "status": "deleted" })).into_response()) + } else { + Err(AppError::NotFound(format!( + "Meeting template '{id}' not found" + ))) + } +} diff --git a/src/routes/contact_groups.rs b/src/routes/contact_groups.rs new file mode 100644 index 0000000..6e89c61 --- /dev/null +++ b/src/routes/contact_groups.rs @@ -0,0 +1,184 @@ +use std::sync::Arc; + +use axum::extract::Path; +use axum::response::{IntoResponse, Response}; +use axum::{Extension, Json}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::auth::session::SessionState; +use crate::config::AppConfig; +use crate::db; +use crate::db::contact_groups::ContactGroup; +use crate::db::contacts::Contact; +use crate::error::AppError; + +// --------------------------------------------------------------------------- +// Request / response types +// --------------------------------------------------------------------------- + +#[derive(Deserialize)] +pub struct CreateGroupBody { + pub name: String, +} + +#[derive(Deserialize)] +pub struct AddMemberBody { + pub contact_id: String, +} + +#[derive(Serialize)] +pub struct ListGroupsResponse { + pub groups: Vec, +} + +#[derive(Serialize)] +pub struct GroupMembersResponse { + pub members: Vec, +} + +// --------------------------------------------------------------------------- +// Handlers +// --------------------------------------------------------------------------- + +/// `GET /api/contact-groups` +pub async fn list_groups_handler( + Extension(session): Extension, + Extension(config): Extension>, +) -> Result { + let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + + let groups = db::contact_groups::list_groups(&conn) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + + Ok(Json(ListGroupsResponse { groups }).into_response()) +} + +/// `POST /api/contact-groups` +pub async fn create_group_handler( + Extension(session): Extension, + Extension(config): Extension>, + Json(body): Json, +) -> Result { + let name = body.name.trim(); + if name.is_empty() { + return Err(AppError::BadRequest("Group name is required".to_string())); + } + + let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + + let id = Uuid::new_v4().to_string(); + db::contact_groups::create_group(&conn, &id, name) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + + Ok(Json(serde_json::json!({ "id": id, "name": name })).into_response()) +} + +/// `PUT /api/contact-groups/{id}` +pub async fn update_group_handler( + Extension(session): Extension, + Extension(config): Extension>, + Path(id): Path, + Json(body): Json, +) -> Result { + let name = body.name.trim(); + if name.is_empty() { + return Err(AppError::BadRequest("Group name is required".to_string())); + } + + let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + + let updated = db::contact_groups::update_group(&conn, &id, name) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + + if updated { + Ok(Json(serde_json::json!({ "id": id, "name": name })).into_response()) + } else { + Err(AppError::NotFound(format!("Group '{id}' not found"))) + } +} + +/// `DELETE /api/contact-groups/{id}` +pub async fn delete_group_handler( + Extension(session): Extension, + Extension(config): Extension>, + Path(id): Path, +) -> Result { + let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + + let deleted = db::contact_groups::delete_group(&conn, &id) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + + if deleted { + Ok(Json(serde_json::json!({ "status": "deleted" })).into_response()) + } else { + Err(AppError::NotFound(format!("Group '{id}' not found"))) + } +} + +/// `GET /api/contact-groups/{id}/members` +pub async fn list_members_handler( + Extension(session): Extension, + Extension(config): Extension>, + Path(id): Path, +) -> Result { + let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + + let members = db::contact_groups::list_group_members(&conn, &id) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + + Ok(Json(GroupMembersResponse { members }).into_response()) +} + +/// `GET /api/contacts/{contact_id}/groups` +pub async fn list_contact_groups_handler( + Extension(session): Extension, + Extension(config): Extension>, + Path(contact_id): Path, +) -> Result { + let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + let groups = db::contact_groups::list_contact_groups(&conn, &contact_id) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + Ok(Json(ListGroupsResponse { groups }).into_response()) +} + +/// `POST /api/contact-groups/{id}/members` +pub async fn add_member_handler( + Extension(session): Extension, + Extension(config): Extension>, + Path(id): Path, + Json(body): Json, +) -> Result { + let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + + db::contact_groups::add_member(&conn, &id, &body.contact_id) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + + Ok(Json(serde_json::json!({ "status": "ok" })).into_response()) +} + +/// `DELETE /api/contact-groups/{id}/members/{contact_id}` +pub async fn remove_member_handler( + Extension(session): Extension, + Extension(config): Extension>, + Path((id, contact_id)): Path<(String, String)>, +) -> Result { + let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + + let removed = db::contact_groups::remove_member(&conn, &id, &contact_id) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + + if removed { + Ok(Json(serde_json::json!({ "status": "ok" })).into_response()) + } else { + Err(AppError::NotFound("Member not found in group".to_string())) + } +} diff --git a/src/routes/contacts.rs b/src/routes/contacts.rs new file mode 100644 index 0000000..faf5d6b --- /dev/null +++ b/src/routes/contacts.rs @@ -0,0 +1,1152 @@ +use std::sync::Arc; + +use axum::extract::{Multipart, Path, Query}; +use axum::response::{IntoResponse, Response}; +use axum::{Extension, Json}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::auth::session::SessionState; +use crate::config::AppConfig; +use crate::db; +use crate::db::contacts::Contact; +use crate::error::AppError; + +// --------------------------------------------------------------------------- +// Query / request types +// --------------------------------------------------------------------------- + +/// Query parameters for `GET /api/contacts`. +#[derive(Deserialize)] +pub struct ListContactsParams { + /// Optional search query to filter by name or email. + pub q: Option, + /// Maximum number of results (default 50). + #[serde(default = "default_limit")] + pub limit: u32, + /// Offset for pagination (default 0). + #[serde(default)] + pub offset: u32, +} + +/// Query parameters for `GET /api/contacts/autocomplete`. +#[derive(Deserialize)] +pub struct AutocompleteParams { + /// Search query (required for autocomplete). + pub q: Option, + /// Maximum number of suggestions (default 10). + #[serde(default = "default_autocomplete_limit")] + pub limit: u32, +} + +/// JSON body for `POST /api/contacts`. +#[derive(Deserialize)] +pub struct CreateContactBody { + pub id: Option, + pub email: String, + #[serde(default)] + pub emails: Vec, + #[serde(default)] + pub name: String, + #[serde(default)] + pub company: String, + #[serde(default)] + pub notes: String, + #[serde(default)] + pub phone: String, + #[serde(default)] + pub phones: Vec, + #[serde(default)] + pub job_title: String, + #[serde(default)] + pub address: String, + #[serde(default)] + pub addresses: Vec, + #[serde(default)] + pub website: String, + #[serde(default)] + pub birthday: String, + #[serde(default)] + pub is_favorite: bool, + pub last_contacted: Option, + #[serde(default)] + pub contact_count: i64, + #[serde(default = "default_source")] + pub source: String, + #[serde(default)] + pub created_at: String, + #[serde(default)] + pub updated_at: String, +} + +fn default_limit() -> u32 { + 50 +} + +fn default_autocomplete_limit() -> u32 { + 10 +} + +fn default_source() -> String { + "manual".to_string() +} + +// --------------------------------------------------------------------------- +// Response types +// --------------------------------------------------------------------------- + +/// Response envelope for `GET /api/contacts`. +#[derive(Serialize)] +pub struct ListContactsResponse { + pub contacts: Vec, + pub total_count: usize, +} + +/// A single autocomplete suggestion. +#[derive(Serialize)] +pub struct AutocompleteSuggestion { + pub email: String, + pub name: String, + pub source: Option, +} + +/// Response envelope for `GET /api/contacts/autocomplete`. +#[derive(Serialize)] +pub struct AutocompleteResponse { + pub suggestions: Vec, +} + +// --------------------------------------------------------------------------- +// Handlers +// --------------------------------------------------------------------------- + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DuplicateContactGroup { + pub email: String, + pub contacts: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct MergeContactsRequest { + pub target_id: String, + pub source_id: String, +} + +fn add_unique_contact_value(values: &mut Vec, value: &str, primary: &str) { + let value = value.trim(); + if value.is_empty() || value.eq_ignore_ascii_case(primary) { + return; + } + if !values.iter().any(|item| item.eq_ignore_ascii_case(value)) { + values.push(value.to_string()); + } +} + +fn normalize_contact_values(values: Vec, primary: &str) -> Vec { + let primary = primary.trim(); + let mut result = Vec::new(); + for value in values { + let value = value.trim().to_string(); + if value.is_empty() || value.eq_ignore_ascii_case(primary) { + continue; + } + if !result + .iter() + .any(|existing: &String| existing.eq_ignore_ascii_case(&value)) + { + result.push(value); + } + } + result +} + +/// `GET /api/contacts?q=&limit=50&offset=0` +/// +/// Lists contacts with optional search. If `q` is provided, uses +/// `search_contacts`; otherwise uses `list_contacts`. +pub async fn list_contacts_handler( + Extension(session): Extension, + Extension(config): Extension>, + Query(params): Query, +) -> Result { + let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + + let query = params + .q + .as_deref() + .map(|s| s.trim()) + .filter(|s| !s.is_empty()); + + let contacts = match query { + Some(q) => db::contacts::search_contacts(&conn, q, params.limit) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?, + None => db::contacts::list_contacts(&conn, None, params.limit, params.offset) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?, + }; + + let total_count = contacts.len(); + + Ok(Json(ListContactsResponse { + contacts, + total_count, + }) + .into_response()) +} + +/// `POST /api/contacts` +/// +/// Creates or updates a contact. Generates a UUID for `id` if not provided. +pub async fn create_contact_handler( + Extension(session): Extension, + Extension(config): Extension>, + Json(body): Json, +) -> Result { + if body.email.trim().is_empty() { + return Err(AppError::BadRequest("Email is required".to_string())); + } + + let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + + let now: String = conn + .query_row("SELECT datetime('now')", [], |row| row.get(0)) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + let id = body.id.unwrap_or_else(|| Uuid::new_v4().to_string()); + let email = body.email.trim().to_string(); + let phone = body.phone.trim().to_string(); + let address = body.address.trim().to_string(); + let emails = normalize_contact_values(body.emails, &email); + let phones = normalize_contact_values(body.phones, &phone); + let addresses = normalize_contact_values(body.addresses, &address); + + let contact = Contact { + id, + email, + name: body.name, + company: body.company, + notes: body.notes, + phone, + job_title: body.job_title, + address, + website: body.website, + birthday: body.birthday, + emails, + phones, + addresses, + is_favorite: body.is_favorite, + last_contacted: body.last_contacted, + contact_count: body.contact_count, + source: body.source, + created_at: if body.created_at.is_empty() { + now.clone() + } else { + body.created_at + }, + updated_at: if body.updated_at.is_empty() { + now + } else { + body.updated_at + }, + has_photo: false, + }; + + db::contacts::upsert_contact(&conn, &contact) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + + let saved = db::contacts::get_contact(&conn, &contact.id) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))? + .ok_or_else(|| AppError::InternalError("Contact disappeared after save".to_string()))?; + Ok(Json(saved).into_response()) +} + +/// `GET /api/contacts/duplicates` +/// +/// Detect duplicate contacts by primary or alternate e-mail addresses. +/// Only groups with at least two distinct contacts are returned. +pub async fn duplicate_contacts_handler( + Extension(session): Extension, + Extension(config): Extension>, +) -> Result { + use std::collections::{BTreeMap, BTreeSet}; + + let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + let contacts = db::contacts::list_contacts(&conn, None, 100_000, 0) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + + let mut by_email: BTreeMap> = BTreeMap::new(); + for contact in &contacts { + for email in std::iter::once(&contact.email).chain(contact.emails.iter()) { + let normalized = email.trim().to_ascii_lowercase(); + if !normalized.is_empty() { + by_email + .entry(normalized) + .or_default() + .insert(contact.id.clone()); + } + } + } + + let by_id: std::collections::HashMap<&str, &Contact> = contacts + .iter() + .map(|contact| (contact.id.as_str(), contact)) + .collect(); + let mut groups = Vec::new(); + for (email, ids) in by_email { + if ids.len() < 2 { + continue; + } + let matches: Vec = ids + .iter() + .filter_map(|id| by_id.get(id.as_str()).map(|contact| (**contact).clone())) + .collect(); + if matches.len() > 1 { + groups.push(DuplicateContactGroup { + email, + contacts: matches, + }); + } + } + + Ok(Json(serde_json::json!({ "duplicates": groups })).into_response()) +} + +/// `POST /api/contacts/merge` +/// +/// Merge source into target, preserving target values where both are present. +/// Alternate addresses, groups and the source photo are retained when useful. +pub async fn merge_contacts_handler( + Extension(session): Extension, + Extension(config): Extension>, + Json(body): Json, +) -> Result { + if body.target_id == body.source_id { + return Err(AppError::BadRequest( + "Target and source contact must differ".to_string(), + )); + } + + let mut conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + let tx = conn + .transaction() + .map_err(|e| AppError::InternalError(format!("Failed to start contact merge: {e}")))?; + + let mut target = db::contacts::get_contact(&tx, &body.target_id) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))? + .ok_or_else(|| AppError::NotFound("Target contact not found".to_string()))?; + let source = db::contacts::get_contact(&tx, &body.source_id) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))? + .ok_or_else(|| AppError::NotFound("Source contact not found".to_string()))?; + + if target.name.trim().is_empty() { + target.name = source.name.clone(); + } + if target.company.trim().is_empty() { + target.company = source.company.clone(); + } + if target.notes.trim().is_empty() { + target.notes = source.notes.clone(); + } + if target.phone.trim().is_empty() { + target.phone = source.phone.clone(); + } else { + let primary_phone = target.phone.clone(); + add_unique_contact_value(&mut target.phones, &source.phone, &primary_phone); + } + if target.job_title.trim().is_empty() { + target.job_title = source.job_title.clone(); + } + if target.address.trim().is_empty() { + target.address = source.address.clone(); + } else { + let primary_address = target.address.clone(); + add_unique_contact_value(&mut target.addresses, &source.address, &primary_address); + } + if target.website.trim().is_empty() { + target.website = source.website.clone(); + } + if target.birthday.trim().is_empty() { + target.birthday = source.birthday.clone(); + } + + let primary_email = target.email.clone(); + let primary_phone = target.phone.clone(); + let primary_address = target.address.clone(); + add_unique_contact_value(&mut target.emails, &source.email, &primary_email); + for email in &source.emails { + add_unique_contact_value(&mut target.emails, email, &primary_email); + } + for phone in &source.phones { + add_unique_contact_value(&mut target.phones, phone, &primary_phone); + } + for address in &source.addresses { + add_unique_contact_value(&mut target.addresses, address, &primary_address); + } + + target.is_favorite |= source.is_favorite; + target.contact_count = target.contact_count.saturating_add(source.contact_count); + if source.last_contacted.as_deref() > target.last_contacted.as_deref() { + target.last_contacted = source.last_contacted.clone(); + } + target.updated_at = tx + .query_row("SELECT datetime('now')", [], |row| row.get(0)) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + + for group in db::contact_groups::list_contact_groups(&tx, &source.id) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))? + { + db::contact_groups::add_member(&tx, &group.id, &target.id) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + } + + if !target.has_photo && source.has_photo { + if let Some((content_type, data)) = db::contacts::get_contact_photo(&tx, &source.id) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))? + { + db::contacts::set_contact_photo(&tx, &target.id, &content_type, &data) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + target.has_photo = true; + } + } + + db::contacts::upsert_contact(&tx, &target) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + db::contacts::delete_contact(&tx, &source.id) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + tx.commit() + .map_err(|e| AppError::InternalError(format!("Failed to commit contact merge: {e}")))?; + + let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + let merged = db::contacts::get_contact(&conn, &body.target_id) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))? + .ok_or_else(|| AppError::InternalError("Merged contact disappeared".to_string()))?; + Ok(Json(merged).into_response()) +} + +/// `GET /api/contacts/autocomplete?q=al&limit=10` +/// +/// Fast autocomplete endpoint. Returns matching contacts as lightweight +/// suggestions with only email, name, and source. +pub async fn autocomplete_handler( + Extension(session): Extension, + Extension(config): Extension>, + Query(params): Query, +) -> Result { + let query = params + .q + .as_deref() + .map(|s| s.trim()) + .filter(|s| !s.is_empty()); + + let query = match query { + Some(q) => q, + None => { + return Ok(Json(AutocompleteResponse { + suggestions: vec![], + }) + .into_response()); + } + }; + + let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + + // Search contacts first + let contacts = db::contacts::search_contacts(&conn, query, params.limit) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + + // Build suggestions from contacts with deduplication by email (lowercase) + let mut seen_emails = std::collections::HashSet::new(); + let mut suggestions: Vec = Vec::new(); + + for c in contacts { + let name = c.name.clone(); + let primary = c.email.clone(); + if seen_emails.insert(primary.to_lowercase()) { + suggestions.push(AutocompleteSuggestion { + email: primary, + name: name.clone(), + source: Some("contact".to_string()), + }); + } + for email in c.emails { + if seen_emails.insert(email.to_lowercase()) { + suggestions.push(AutocompleteSuggestion { + email, + name: name.clone(), + source: Some("contact".to_string()), + }); + } + } + } + + // If we haven't hit the limit, also search known addresses + if suggestions.len() < params.limit as usize { + let remaining = params.limit as usize - suggestions.len(); + #[allow(dead_code)] + let known = db::contacts::search_known_addresses(&conn, query, remaining as u32) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + + for k in known { + let email_lower = k.email.to_lowercase(); + if seen_emails.insert(email_lower) { + suggestions.push(AutocompleteSuggestion { + email: k.email, + name: k.name, + source: Some("known".to_string()), + }); + } + } + } + + // Truncate to limit in case we went over + suggestions.truncate(params.limit as usize); + + Ok(Json(AutocompleteResponse { suggestions }).into_response()) +} + +/// `GET /api/contacts/autocomplete/all` +/// +/// Returns all contacts and known addresses for client-side autocomplete. +/// Used for prefetching to enable instant fuzzy search without network latency. +pub async fn autocomplete_all_handler( + Extension(session): Extension, + Extension(config): Extension>, +) -> Result { + let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + + let contacts = db::contacts::list_contacts(&conn, None, 50000, 0) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + + let known = db::contacts::search_known_addresses(&conn, "", 50000) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + + let mut seen_emails = std::collections::HashSet::new(); + let mut suggestions: Vec = Vec::new(); + + for c in contacts { + let name = c.name.clone(); + let primary = c.email.clone(); + if seen_emails.insert(primary.to_lowercase()) { + suggestions.push(AutocompleteSuggestion { + email: primary, + name: name.clone(), + source: Some("contact".to_string()), + }); + } + for email in c.emails { + if seen_emails.insert(email.to_lowercase()) { + suggestions.push(AutocompleteSuggestion { + email, + name: name.clone(), + source: Some("contact".to_string()), + }); + } + } + } + + for k in known { + let email_lower = k.email.to_lowercase(); + if seen_emails.insert(email_lower) { + suggestions.push(AutocompleteSuggestion { + email: k.email, + name: k.name, + source: Some("known".to_string()), + }); + } + } + + Ok(Json(AutocompleteResponse { suggestions }).into_response()) +} + +/// `GET /api/contacts/:id` +/// +/// Returns a single contact by id. Returns 404 if not found. +pub async fn get_contact_handler( + Extension(session): Extension, + Extension(config): Extension>, + Path(id): Path, +) -> Result { + let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + + let contact = db::contacts::get_contact(&conn, &id) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + + match contact { + Some(c) => Ok(Json(c).into_response()), + None => Err(AppError::NotFound(format!("Contact '{id}' not found"))), + } +} + +/// `DELETE /api/contacts/:id` +/// +/// Deletes a contact by id. Returns 404 if the contact does not exist. +pub async fn delete_contact_handler( + Extension(session): Extension, + Extension(config): Extension>, + Path(id): Path, +) -> Result { + let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + + let deleted = db::contacts::delete_contact(&conn, &id) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + + if deleted { + Ok(Json(serde_json::json!({ "status": "deleted" })).into_response()) + } else { + Err(AppError::NotFound(format!("Contact '{id}' not found"))) + } +} + +const MAX_CONTACT_PHOTO_SIZE: usize = 2 * 1024 * 1024; + +fn contact_photo_type(data: &[u8]) -> Option<&'static str> { + if data.starts_with(b"\x89PNG\r\n\x1a\n") { + Some("image/png") + } else if data.starts_with(&[0xff, 0xd8, 0xff]) { + Some("image/jpeg") + } else if data.len() >= 12 && &data[..4] == b"RIFF" && &data[8..12] == b"WEBP" { + Some("image/webp") + } else { + None + } +} + +/// Upload or replace a contact photo. Only PNG, JPEG and WebP are accepted. +pub async fn upload_contact_photo_handler( + Extension(session): Extension, + Extension(config): Extension>, + Path(id): Path, + mut multipart: Multipart, +) -> Result { + let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + if db::contacts::get_contact(&conn, &id) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))? + .is_none() + { + return Err(AppError::NotFound(format!("Contact '{id}' not found"))); + } + + let field = multipart + .next_field() + .await + .map_err(|e| AppError::BadRequest(format!("Invalid multipart data: {e}")))? + .ok_or_else(|| AppError::BadRequest("No photo uploaded".to_string()))?; + let data = field + .bytes() + .await + .map_err(|e| AppError::BadRequest(format!("Failed to read photo: {e}")))?; + if data.is_empty() { + return Err(AppError::BadRequest("Photo is empty".to_string())); + } + if data.len() > MAX_CONTACT_PHOTO_SIZE { + return Err(AppError::BadRequest( + "Contact photo exceeds the 2 MB limit".to_string(), + )); + } + let content_type = contact_photo_type(data.as_ref()).ok_or_else(|| { + AppError::BadRequest("Contact photo must be PNG, JPEG or WebP".to_string()) + })?; + + db::contacts::set_contact_photo(&conn, &id, content_type, data.as_ref()) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + Ok(Json( + serde_json::json!({ "status": "ok", "content_type": content_type, "size": data.len() }), + ) + .into_response()) +} + +/// Return a contact photo without exposing its storage representation. +pub async fn get_contact_photo_handler( + Extension(session): Extension, + Extension(config): Extension>, + Path(id): Path, +) -> Result { + let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + let (content_type, data) = db::contacts::get_contact_photo(&conn, &id) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))? + .ok_or_else(|| AppError::NotFound("Contact photo not found".to_string()))?; + + Response::builder() + .header("content-type", content_type) + .header("cache-control", "private, max-age=3600") + .header("x-content-type-options", "nosniff") + .body(axum::body::Body::from(data)) + .map_err(|e| AppError::InternalError(format!("Failed to build photo response: {e}"))) +} + +/// Remove a contact photo while keeping the contact itself. +pub async fn delete_contact_photo_handler( + Extension(session): Extension, + Extension(config): Extension>, + Path(id): Path, +) -> Result { + let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + db::contacts::delete_contact_photo(&conn, &id) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + Ok(Json(serde_json::json!({ "status": "deleted" })).into_response()) +} + +// --------------------------------------------------------------------------- +// vCard import/export +// --------------------------------------------------------------------------- + +/// Escape special characters in a vCard field value. +fn vcard_escape(s: &str) -> String { + s.replace('\\', "\\\\") + .replace(',', "\\,") + .replace(';', "\\;") + .replace('\n', "\\n") +} + +/// Serialize a Contact to vCard 3.0 format. +fn contact_to_vcard(c: &Contact) -> String { + let mut lines = Vec::new(); + lines.push("BEGIN:VCARD".to_string()); + lines.push("VERSION:3.0".to_string()); + + let name = if c.name.is_empty() { &c.email } else { &c.name }; + lines.push(format!("FN:{}", vcard_escape(name))); + lines.push(format!("N:{};;;;", vcard_escape(name))); + lines.push(format!("EMAIL;TYPE=PREF:{}", vcard_escape(&c.email))); + for email in &c.emails { + if !email.trim().is_empty() { + lines.push(format!("EMAIL:{}", vcard_escape(email))); + } + } + + if !c.company.is_empty() { + lines.push(format!("ORG:{}", vcard_escape(&c.company))); + } + if !c.notes.is_empty() { + lines.push(format!("NOTE:{}", vcard_escape(&c.notes))); + } + if !c.phone.is_empty() { + lines.push(format!("TEL;TYPE=PREF:{}", vcard_escape(&c.phone))); + } + for phone in &c.phones { + if !phone.trim().is_empty() { + lines.push(format!("TEL:{}", vcard_escape(phone))); + } + } + if !c.job_title.is_empty() { + lines.push(format!("TITLE:{}", vcard_escape(&c.job_title))); + } + if !c.address.is_empty() { + lines.push(format!("ADR;TYPE=PREF:;;{};;;;", vcard_escape(&c.address))); + } + for address in &c.addresses { + if !address.trim().is_empty() { + lines.push(format!("ADR:;;{};;;;", vcard_escape(address))); + } + } + if !c.website.is_empty() { + lines.push(format!("URL:{}", vcard_escape(&c.website))); + } + if !c.birthday.is_empty() { + lines.push(format!("BDAY:{}", vcard_escape(&c.birthday))); + } + + lines.push("END:VCARD".to_string()); + lines.join("\r\n") +} + +/// A parsed vCard entry with optional fields. +struct ParsedVCard { + name: String, + email: String, + emails: Vec, + company: String, + notes: String, + phone: String, + phones: Vec, + job_title: String, + address: String, + addresses: Vec, + website: String, + birthday: String, +} + +/// Parse vCard data (potentially multiple entries) from a string. +fn parse_vcards(data: &str) -> Vec { + let mut results = Vec::new(); + + // Unfold continuation lines (lines starting with space or tab are continuations). + let unfolded = data + .replace("\r\n ", "") + .replace("\r\n\t", "") + .replace("\n ", "") + .replace("\n\t", ""); + + let mut in_vcard = false; + let mut name = String::new(); + let mut email = String::new(); + let mut emails: Vec = Vec::new(); + let mut company = String::new(); + let mut notes = String::new(); + let mut phone = String::new(); + let mut phones: Vec = Vec::new(); + let mut job_title = String::new(); + let mut address = String::new(); + let mut addresses: Vec = Vec::new(); + let mut website = String::new(); + let mut birthday = String::new(); + + for line in unfolded.lines() { + let line = line.trim(); + + if line.eq_ignore_ascii_case("BEGIN:VCARD") { + in_vcard = true; + name.clear(); + email.clear(); + emails.clear(); + company.clear(); + notes.clear(); + phone.clear(); + phones.clear(); + job_title.clear(); + address.clear(); + addresses.clear(); + website.clear(); + birthday.clear(); + continue; + } + + if line.eq_ignore_ascii_case("END:VCARD") { + if in_vcard && !email.is_empty() { + results.push(ParsedVCard { + name: name.clone(), + email: email.clone(), + emails: emails.clone(), + company: company.clone(), + notes: notes.clone(), + phone: phone.clone(), + phones: phones.clone(), + job_title: job_title.clone(), + address: address.clone(), + addresses: addresses.clone(), + website: website.clone(), + birthday: birthday.clone(), + }); + } + in_vcard = false; + continue; + } + + if !in_vcard { + continue; + } + + // Extract field name and value (handle parameters like EMAIL;TYPE=WORK:...) + let (field_name, value) = if let Some(colon_pos) = line.find(':') { + let raw_key = &line[..colon_pos]; + let value = &line[colon_pos + 1..]; + // Strip parameters (e.g., EMAIL;TYPE=WORK -> EMAIL) + let field = if let Some(semi_pos) = raw_key.find(';') { + &raw_key[..semi_pos] + } else { + raw_key + }; + (field.to_uppercase(), value.to_string()) + } else { + continue; + }; + + // Unescape vCard values + let value = value + .replace("\\n", "\n") + .replace("\\,", ",") + .replace("\\;", ";") + .replace("\\\\", "\\"); + + match field_name.as_str() { + "FN" + if name.is_empty() => { + name = value; + } + "N" + // N:Last;First;Middle;Prefix;Suffix — use FN if available, else reconstruct + if name.is_empty() => { + let parts: Vec<&str> = value.splitn(5, ';').collect(); + let last = parts.first().unwrap_or(&"").trim(); + let first = parts.get(1).unwrap_or(&"").trim(); + let combined = format!("{first} {last}").trim().to_string(); + if !combined.is_empty() { + name = combined; + } + } + "EMAIL" => { + if email.is_empty() { + email = value; + } else if !emails.iter().any(|item| item.eq_ignore_ascii_case(&value)) { + emails.push(value); + } + } + "ORG" + if company.is_empty() => { + company = value; + } + "NOTE" + if notes.is_empty() => { + notes = value; + } + "TEL" => { + if phone.is_empty() { phone = value; } + else if !phones.iter().any(|item| item == &value) { phones.push(value); } + } + "TITLE" if job_title.is_empty() => { job_title = value; } + "ADR" => { + let parts: Vec<&str> = value.split(';').filter(|part| !part.trim().is_empty()).collect(); + let parsed = parts.join(", "); + if address.is_empty() { address = parsed; } + else if !parsed.is_empty() && !addresses.iter().any(|item| item == &parsed) { addresses.push(parsed); } + } + "URL" if website.is_empty() => { website = value; } + "BDAY" if birthday.is_empty() => { birthday = value; } + _ => {} + } + } + + results +} + +/// `GET /api/contacts/export` +/// +/// Export all contacts as a .vcf file download. +pub async fn export_contacts_handler( + Extension(session): Extension, + Extension(config): Extension>, +) -> Result { + let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + + let contacts = db::contacts::list_contacts(&conn, None, 10000, 0) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + + let vcf: String = contacts + .iter() + .map(contact_to_vcard) + .collect::>() + .join("\r\n"); + + Ok(Response::builder() + .header("content-type", "text/vcard; charset=utf-8") + .header( + "content-disposition", + "attachment; filename=\"contacts.vcf\"", + ) + .body(axum::body::Body::from(vcf)) + .unwrap()) +} + +/// `GET /api/contacts/{id}/export` +/// +/// Export a single contact as a .vcf file download. +pub async fn export_single_contact_handler( + Extension(session): Extension, + Extension(config): Extension>, + Path(id): Path, +) -> Result { + let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + + let contact = db::contacts::get_contact(&conn, &id) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + + match contact { + Some(c) => { + let vcf = contact_to_vcard(&c); + // Strip ASCII control characters (U+0000-U+001F and U+007F) from + // the contact name before using it in the Content-Disposition + // filename. HeaderValue::from_str routes through is_valid + // (accepts 0x09, 0x20-0x7E, 0x80-0xFF; rejects 0x00-0x08, + // 0x0A-0x1F, 0x7F), so non-ASCII UTF-8 bytes are fine. + let safe_name: String = c.name.chars().filter(|c| !c.is_ascii_control()).collect(); + let filename = if safe_name.is_empty() { + "contact.vcf".to_string() + } else { + format!("{}.vcf", safe_name.replace(' ', "_")) + }; + + Ok(Response::builder() + .header("content-type", "text/vcard; charset=utf-8") + .header( + "content-disposition", + format!("attachment; filename=\"{filename}\""), + ) + .body(axum::body::Body::from(vcf)) + .map_err(|e| AppError::InternalError(format!("Failed to build response: {e}")))?) + } + None => Err(AppError::NotFound(format!("Contact '{id}' not found"))), + } +} + +/// Import response with counts. +#[derive(Serialize)] +pub struct ImportResponse { + pub created: usize, + pub updated: usize, + pub skipped: usize, +} + +/// `POST /api/contacts/import` +/// +/// Import contacts from an uploaded .vcf file (multipart form data). +pub async fn import_contacts_handler( + Extension(session): Extension, + Extension(config): Extension>, + mut multipart: Multipart, +) -> Result { + // Read the uploaded file content + let mut vcf_content = String::new(); + if let Some(field) = multipart + .next_field() + .await + .map_err(|e| AppError::BadRequest(format!("Invalid multipart data: {e}")))? + { + let data = field + .bytes() + .await + .map_err(|e| AppError::BadRequest(format!("Failed to read field: {e}")))?; + vcf_content = String::from_utf8(data.to_vec()) + .map_err(|_| AppError::BadRequest("Invalid UTF-8 in vCard file".to_string()))?; + } + + if vcf_content.is_empty() { + return Err(AppError::BadRequest("No file uploaded".to_string())); + } + + let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + + let now: String = conn + .query_row("SELECT datetime('now')", [], |row| row.get(0)) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + + let parsed = parse_vcards(&vcf_content); + + let mut created = 0usize; + let mut updated = 0usize; + let mut skipped = 0usize; + + for entry in &parsed { + if entry.email.is_empty() { + skipped += 1; + continue; + } + + let existing = db::contacts::get_contact_by_email(&conn, &entry.email) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + + match existing { + Some(mut c) => { + // Merge only empty fields (don't overwrite manual edits). + let mut changed = false; + if c.name.is_empty() && !entry.name.is_empty() { + c.name = entry.name.clone(); + changed = true; + } + if c.company.is_empty() && !entry.company.is_empty() { + c.company = entry.company.clone(); + changed = true; + } + if c.notes.is_empty() && !entry.notes.is_empty() { + c.notes = entry.notes.clone(); + changed = true; + } + if c.phone.is_empty() && !entry.phone.is_empty() { + c.phone = entry.phone.clone(); + changed = true; + } + if c.job_title.is_empty() && !entry.job_title.is_empty() { + c.job_title = entry.job_title.clone(); + changed = true; + } + if c.address.is_empty() && !entry.address.is_empty() { + c.address = entry.address.clone(); + changed = true; + } + if c.website.is_empty() && !entry.website.is_empty() { + c.website = entry.website.clone(); + changed = true; + } + if c.birthday.is_empty() && !entry.birthday.is_empty() { + c.birthday = entry.birthday.clone(); + changed = true; + } + for email in &entry.emails { + if !c.emails.iter().any(|item| item.eq_ignore_ascii_case(email)) + && !c.email.eq_ignore_ascii_case(email) + { + c.emails.push(email.clone()); + changed = true; + } + } + for phone in &entry.phones { + if !c.phones.contains(phone) && c.phone != *phone { + c.phones.push(phone.clone()); + changed = true; + } + } + for address in &entry.addresses { + if !c.addresses.contains(address) && c.address != *address { + c.addresses.push(address.clone()); + changed = true; + } + } + if changed { + c.updated_at = now.clone(); + db::contacts::upsert_contact(&conn, &c) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + updated += 1; + } else { + skipped += 1; + } + } + None => { + let contact = Contact { + id: Uuid::new_v4().to_string(), + email: entry.email.clone(), + name: entry.name.clone(), + company: entry.company.clone(), + notes: entry.notes.clone(), + phone: entry.phone.clone(), + job_title: entry.job_title.clone(), + address: entry.address.clone(), + website: entry.website.clone(), + birthday: entry.birthday.clone(), + emails: entry.emails.clone(), + phones: entry.phones.clone(), + addresses: entry.addresses.clone(), + is_favorite: false, + last_contacted: None, + contact_count: 0, + source: "imported".to_string(), + created_at: now.clone(), + updated_at: now.clone(), + has_photo: false, + }; + db::contacts::upsert_contact(&conn, &contact) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + created += 1; + } + } + } + + Ok(Json(ImportResponse { + created, + updated, + skipped, + }) + .into_response()) +} diff --git a/src/routes/diagnostics.rs b/src/routes/diagnostics.rs new file mode 100644 index 0000000..a5e1922 --- /dev/null +++ b/src/routes/diagnostics.rs @@ -0,0 +1,344 @@ +use std::sync::Arc; + +use axum::response::{IntoResponse, Response}; +use axum::{Extension, Json}; +use serde::Serialize; + +use crate::auth::session::SessionState; +use crate::config::AppConfig; +use crate::db; +use crate::error::AppError; +use crate::imap::client::{ImapClient, ImapCredentials}; +use crate::realtime::idle::IdleManager; +use crate::search::engine::{IndexableMessage, SearchEngine, UserIndex}; +use crate::smtp::client::{SmtpClient, SmtpCredentials}; + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ImapDiagnosticsResponse { + connected: bool, + capabilities: Vec, + idle: bool, + move_extension: bool, + uidplus: bool, + condstore: bool, + qresync: bool, + sort: bool, + thread: bool, + namespace: bool, + special_use: bool, + quota: bool, + acl: bool, + list_extended: bool, + utf8_accept: bool, + custom_keywords: bool, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SmtpDiagnosticsResponse { + connected: bool, + secure_transport: bool, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SearchDiagnosticsResponse { + indexed_documents: usize, + cached_messages: usize, + cached_bodies: usize, + excluded_messages: usize, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SyncDiagnosticsResponse { + cached_folders: usize, + cached_messages: usize, + unsynced_folders: usize, + latest_folder_list_sync: Option, + latest_message_sync: Option, + idle_tasks: usize, + idle_subscribers: usize, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SearchRebuildResponse { + status: &'static str, + indexed_documents: usize, + source_messages: usize, +} + +/// `GET /api/diagnostics/imap` +/// +/// Returns protocol capabilities for the authenticated account. Host names and +/// credentials are deliberately not exposed to the browser. +pub async fn imap_diagnostics( + Extension(session): Extension, + Extension(imap_client): Extension>, +) -> Result { + let creds = ImapCredentials { + host: session.imap_host.clone(), + port: session.imap_port, + tls: session.imap_tls, + email: session.email.clone(), + password: session.password.clone(), + }; + + let capabilities = imap_client.capabilities(&creds).await.map_err(|error| { + AppError::ServiceUnavailable(format!("IMAP capability check failed: {error}")) + })?; + + let has = |name: &str| { + capabilities + .iter() + .any(|cap| cap.eq_ignore_ascii_case(name)) + }; + let has_prefix = |prefix: &str| { + capabilities.iter().any(|cap| { + cap.to_ascii_uppercase() + .starts_with(&prefix.to_ascii_uppercase()) + }) + }; + let custom_keywords = imap_client + .folder_keyword_support(&creds, "INBOX") + .await + .unwrap_or(false); + + Ok(Json(ImapDiagnosticsResponse { + connected: true, + idle: has("IDLE"), + move_extension: has("MOVE"), + uidplus: has("UIDPLUS"), + condstore: has("CONDSTORE"), + qresync: has("QRESYNC"), + sort: has("SORT"), + thread: has_prefix("THREAD="), + namespace: has("NAMESPACE"), + special_use: has("SPECIAL-USE") || has("XLIST"), + quota: has("QUOTA"), + acl: has("ACL"), + list_extended: has("LIST-EXTENDED"), + utf8_accept: has("UTF8=ACCEPT"), + custom_keywords, + capabilities, + }) + .into_response()) +} + +/// `GET /api/diagnostics/smtp` +/// +/// Performs an authenticated SMTP connection check using NOOP. Server host +/// names and credentials are never returned to the browser. +pub async fn smtp_diagnostics( + Extension(session): Extension, + Extension(smtp_client): Extension>, +) -> Result { + let creds = SmtpCredentials { + host: session.smtp_host.clone(), + port: session.smtp_port, + tls: session.smtp_tls, + email: session.email.clone(), + password: session.password.clone(), + }; + + smtp_client + .check_connection(&creds) + .await + .map_err(|error| { + AppError::ServiceUnavailable(format!("SMTP connection check failed: {error}")) + })?; + + Ok(Json(SmtpDiagnosticsResponse { + connected: true, + secure_transport: session.smtp_tls, + }) + .into_response()) +} + +/// `POST /api/diagnostics/imap/reconnect` +/// +/// Drops reusable request-path IMAP connections for the current account and +/// verifies a fresh authenticated connection by reading capabilities. +pub async fn reconnect_imap( + Extension(session): Extension, + Extension(imap_client): Extension>, +) -> Result { + let creds = ImapCredentials { + host: session.imap_host.clone(), + port: session.imap_port, + tls: session.imap_tls, + email: session.email.clone(), + password: session.password.clone(), + }; + + imap_client + .reset_connections(&creds) + .await + .map_err(|error| { + AppError::ServiceUnavailable(format!("Failed to reset IMAP connections: {error}")) + })?; + let capabilities = imap_client.capabilities(&creds).await.map_err(|error| { + AppError::ServiceUnavailable(format!("Fresh IMAP connection failed: {error}")) + })?; + + Ok(Json(serde_json::json!({ + "status": "connected", + "capabilityCount": capabilities.len() + })) + .into_response()) +} + +fn index_sources_to_messages( + sources: Vec, +) -> Vec { + sources + .into_iter() + .filter(|message| !UserIndex::is_excluded_folder(&message.folder)) + .map(|message| IndexableMessage { + uid: message.uid, + folder: message.folder, + subject: message.subject, + from_address: message.from_address, + from_name: message.from_name, + to_addresses: message.to_addresses, + body_text: message.body_text, + date_epoch: message.date_epoch, + has_attachments: message.has_attachments, + }) + .collect() +} + +/// `GET /api/diagnostics/sync` +/// +/// Reports only local cache synchronization metadata. No server names, message +/// content, credentials or folder names are exposed. +pub async fn sync_diagnostics( + Extension(session): Extension, + Extension(config): Extension>, + Extension(idle_manager): Extension>, +) -> Result { + let data_dir = config.data_dir.clone(); + let user_hash = session.user_hash.clone(); + let idle_tasks = idle_manager.task_count(); + let idle_subscribers = idle_manager.subscriber_count(); + + let stats = tokio::task::spawn_blocking(move || -> Result { + let conn = db::pool::open_user_db(&data_dir, &user_hash) + .map_err(|e| format!("Database error: {e}"))?; + + let (cached_folders, unsynced_folders, latest_folder_list_sync, latest_message_sync): + (i64, i64, Option, Option) = conn + .query_row( + "SELECT COUNT(*), + COALESCE(SUM(CASE WHEN messages_updated_at IS NULL THEN 1 ELSE 0 END), 0), + CASE WHEN MAX(updated_at) IS NULL THEN NULL ELSE replace(MAX(updated_at), ' ', 'T') || 'Z' END, + CASE WHEN MAX(messages_updated_at) IS NULL THEN NULL ELSE replace(MAX(messages_updated_at), ' ', 'T') || 'Z' END + FROM folders", + [], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), + ) + .map_err(|e| format!("Failed to read folder sync metadata: {e}"))?; + + let cached_messages: i64 = conn + .query_row("SELECT COUNT(*) FROM messages", [], |row| row.get(0)) + .map_err(|e| format!("Failed to count cached messages: {e}"))?; + + Ok(SyncDiagnosticsResponse { + cached_folders: cached_folders.max(0) as usize, + cached_messages: cached_messages.max(0) as usize, + unsynced_folders: unsynced_folders.max(0) as usize, + latest_folder_list_sync, + latest_message_sync, + idle_tasks, + idle_subscribers, + }) + }) + .await + .map_err(|e| AppError::InternalError(format!("Sync diagnostics task failed: {e}")))? + .map_err(AppError::InternalError)?; + + Ok(Json(stats).into_response()) +} + +/// `GET /api/diagnostics/search` +/// +/// Reports the current Tantivy document count and the number of cached SQLite +/// messages available for a rebuild. This endpoint does not expose message text. +pub async fn search_diagnostics( + Extension(session): Extension, + Extension(config): Extension>, + Extension(search_engine): Extension>, +) -> Result { + let data_dir = config.data_dir.clone(); + let user_hash = session.user_hash.clone(); + let engine = search_engine.clone(); + + let stats = + tokio::task::spawn_blocking(move || -> Result { + let conn = db::pool::open_user_db(&data_dir, &user_hash) + .map_err(|e| format!("Database error: {e}"))?; + let sources = db::messages::list_index_source_messages(&conn)?; + let cached_messages = sources.len(); + let excluded_messages = sources + .iter() + .filter(|message| UserIndex::is_excluded_folder(&message.folder)) + .count(); + let cached_bodies: usize = conn + .query_row( + "SELECT COUNT(*) FROM messages WHERE body_cached = 1", + [], + |row| row.get(0), + ) + .map_err(|e| format!("Failed to count cached bodies: {e}"))?; + let indexed_documents = engine.open_user_index(&user_hash)?.document_count(); + + Ok(SearchDiagnosticsResponse { + indexed_documents, + cached_messages, + cached_bodies, + excluded_messages, + }) + }) + .await + .map_err(|e| AppError::InternalError(format!("Search diagnostics task failed: {e}")))? + .map_err(AppError::InternalError)?; + + Ok(Json(stats).into_response()) +} + +/// `POST /api/diagnostics/search/rebuild` +/// +/// Recreates the current user's Tantivy index exclusively from the local SQLite +/// cache. Spam/Junk/Trash folders remain excluded by the normal index policy. +pub async fn rebuild_search_index( + Extension(session): Extension, + Extension(config): Extension>, + Extension(search_engine): Extension>, +) -> Result { + let data_dir = config.data_dir.clone(); + let user_hash = session.user_hash.clone(); + let engine = search_engine.clone(); + + let result = tokio::task::spawn_blocking(move || -> Result { + let conn = db::pool::open_user_db(&data_dir, &user_hash) + .map_err(|e| format!("Database error: {e}"))?; + let sources = db::messages::list_index_source_messages(&conn)?; + let source_messages = sources.len(); + let messages = index_sources_to_messages(sources); + let index = engine.open_user_index(&user_hash)?; + let indexed_documents = index.rebuild_from_messages(&messages)?; + + Ok(SearchRebuildResponse { + status: "rebuilt", + indexed_documents, + source_messages, + }) + }) + .await + .map_err(|e| AppError::InternalError(format!("Search rebuild task failed: {e}")))? + .map_err(AppError::InternalError)?; + + Ok(Json(result).into_response()) +} diff --git a/src/routes/display_preferences.rs b/src/routes/display_preferences.rs new file mode 100644 index 0000000..8e95fc0 --- /dev/null +++ b/src/routes/display_preferences.rs @@ -0,0 +1,251 @@ +use std::sync::Arc; + +use axum::response::{IntoResponse, Response}; +use axum::{Extension, Json}; + +use crate::auth::session::SessionState; +use crate::config::AppConfig; +use crate::db; +use crate::db::display_preferences::UpdateDisplayPreferences; +use crate::error::AppError; + +fn map_update_error(message: String) -> AppError { + if message.starts_with("Invalid ") { + AppError::BadRequest(message) + } else { + AppError::InternalError(message) + } +} + +/// `GET /api/settings/display` +pub async fn get_display_preferences( + Extension(session): Extension, + Extension(config): Extension>, +) -> Result { + let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) + .map_err(|e| AppError::InternalError(format!("Failed to open database: {e}")))?; + + let prefs = db::display_preferences::get_preferences(&conn).map_err(AppError::InternalError)?; + + Ok(Json(prefs).into_response()) +} + +/// `PUT /api/settings/display` +pub async fn update_display_preferences( + Extension(session): Extension, + Extension(config): Extension>, + Json(data): Json, +) -> Result { + let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) + .map_err(|e| AppError::InternalError(format!("Failed to open database: {e}")))?; + + let prefs = + db::display_preferences::update_preferences(&conn, &data).map_err(map_update_error)?; + + Ok(Json(prefs).into_response()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Instant; + + use axum::Extension; + use axum::http::StatusCode; + use axum::response::IntoResponse; + use serde_json::json; + use tempfile::TempDir; + + fn test_config(data_dir: &str) -> Arc { + Arc::new(AppConfig { + host: "127.0.0.1".to_string(), + port: 3001, + imap_host: None, + imap_port: 993, + smtp_host: None, + smtp_port: 587, + tls_enabled: true, + data_dir: data_dir.to_string(), + session_timeout_hours: 24, + environment: "development".to_string(), + base_path: None, + sieve_host: None, + sieve_port: 4190, + sieve_tls_mode: "starttls".to_string(), + sieve_allow_insecure_auth: false, + sieve_timeout_secs: 10, + trusted_proxies: None, + outgoing_headers: Vec::new(), + }) + } + + fn test_session() -> SessionState { + SessionState { + account_id: "acc-1".to_string(), + email: "alice@example.com".to_string(), + password: "password".to_string(), + user_hash: crate::auth::user_data::hash_email("alice@example.com"), + imap_host: "imap.example.com".to_string(), + imap_port: 993, + imap_tls: true, + smtp_host: "smtp.example.com".to_string(), + smtp_port: 587, + smtp_tls: true, + last_accessed: Instant::now(), + timeout_override: None, + } + } + + #[tokio::test] + async fn test_invalid_preferences_map_to_bad_request() { + let tmp = TempDir::new().unwrap(); + let config = test_config(tmp.path().to_str().unwrap()); + let session = test_session(); + + let result = update_display_preferences( + Extension(session), + Extension(config), + Json(UpdateDisplayPreferences { + density: None, + theme: None, + language: None, + compose_format: None, + compose_autosave_ms: None, + default_priority: None, + request_mdn_default: None, + attachment_reminder: None, + deep_index: None, + animation_mode: Some(Some("ultra".to_string())), + preview_pane: None, + ui_scale: None, + color_preset: None, + accent_color: None, + background_color: None, + panel_color: None, + text_color: None, + font_family: None, + font_size: None, + row_height: None, + folder_pane_width: None, + message_pane_width: None, + border_radius: None, + contrast: None, + show_avatars: None, + show_attachment_icons: None, + show_flags: None, + reduce_motion: None, + date_format: None, + time_format: None, + ..Default::default() + }), + ) + .await; + + match result { + Err(AppError::BadRequest(message)) => { + assert!(message.contains("Invalid animation_mode")); + } + other => panic!("expected BadRequest, got {other:?}"), + } + } + + #[test] + fn test_map_update_error_invalid_animation_mode_returns_bad_request() { + let err = map_update_error("Invalid animation_mode: ultra".to_string()); + + match err { + AppError::BadRequest(message) => { + assert!(message.contains("Invalid animation_mode")); + } + other => panic!("expected BadRequest, got {other:?}"), + } + } + + #[tokio::test] + async fn test_invalid_preferences_map_to_http_400_response() { + let tmp = TempDir::new().unwrap(); + let config = test_config(tmp.path().to_str().unwrap()); + let session = test_session(); + + let result = update_display_preferences( + Extension(session), + Extension(config), + Json(UpdateDisplayPreferences { + density: None, + theme: None, + language: None, + compose_format: None, + compose_autosave_ms: None, + default_priority: None, + request_mdn_default: None, + attachment_reminder: None, + deep_index: None, + animation_mode: Some(Some("ultra".to_string())), + preview_pane: None, + ui_scale: None, + color_preset: None, + accent_color: None, + background_color: None, + panel_color: None, + text_color: None, + font_family: None, + font_size: None, + row_height: None, + folder_pane_width: None, + message_pane_width: None, + border_radius: None, + contrast: None, + show_avatars: None, + show_attachment_icons: None, + show_flags: None, + reduce_motion: None, + date_format: None, + time_format: None, + ..Default::default() + }), + ) + .await; + + let response = result + .expect_err("invalid preferences should return an error") + .into_response(); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + } + + #[test] + fn test_animation_mode_deserialize_omitted_is_none() { + let payload = json!({ + "theme": "dark" + }); + + let parsed: UpdateDisplayPreferences = + serde_json::from_value(payload).expect("payload should deserialize"); + + assert_eq!(parsed.animation_mode, None); + } + + #[test] + fn test_animation_mode_deserialize_null_is_some_none() { + let payload = json!({ + "animation_mode": null + }); + + let parsed: UpdateDisplayPreferences = + serde_json::from_value(payload).expect("payload should deserialize"); + + assert_eq!(parsed.animation_mode, Some(None)); + } + + #[test] + fn test_animation_mode_deserialize_value_is_some_some() { + let payload = json!({ + "animation_mode": "medium" + }); + + let parsed: UpdateDisplayPreferences = + serde_json::from_value(payload).expect("payload should deserialize"); + + assert_eq!(parsed.animation_mode, Some(Some("medium".to_string()))); + } +} diff --git a/src/routes/drafts.rs b/src/routes/drafts.rs new file mode 100644 index 0000000..6160d72 --- /dev/null +++ b/src/routes/drafts.rs @@ -0,0 +1,592 @@ +use std::path::Path; +use std::sync::Arc; + +use axum::extract::Path as AxumPath; +use axum::response::{IntoResponse, Response}; +use axum::{Extension, Json}; +use serde::{Deserialize, Serialize}; + +use crate::auth::session::SessionState; +use crate::config::AppConfig; +use crate::db; +use crate::error::AppError; +use crate::imap::client::{ImapClient, ImapCredentials}; +use crate::smtp::client::AttachmentData; + +// --------------------------------------------------------------------------- +// Request / Response types +// --------------------------------------------------------------------------- + +#[derive(Debug, Deserialize)] +pub struct UpsertDraftRequest { + pub id: String, + #[serde(default)] + pub to: String, + #[serde(default)] + pub cc: String, + #[serde(default)] + pub bcc: String, + #[serde(default)] + pub subject: String, + #[serde(default)] + pub text_body: String, + pub html_body: Option, + pub in_reply_to: Option, + pub references: Option, + #[serde(default)] + pub request_mdn: bool, + #[serde(default = "default_priority")] + pub priority: String, + #[serde(default = "default_compose_format")] + pub compose_format: String, + #[serde(default = "default_pgp_mode")] + pub pgp_mode: String, + #[serde(default)] + pub pgp_inline: bool, + #[serde(default)] + pub pgp_attach_public_key: bool, + #[serde(default)] + pub pgp_attach_recipient_keys: bool, + /// Sync this saved snapshot to the IMAP Drafts folder. Autosave requests + /// keep this false so typing does not append a new remote draft every time. + #[serde(default)] + pub sync_imap: bool, +} + +fn default_priority() -> String { + "normal".to_string() +} + +fn default_compose_format() -> String { + "html".to_string() +} +fn default_pgp_mode() -> String { + "none".to_string() +} + +#[derive(Debug, Serialize)] +struct DraftResponse { + id: String, + status: String, +} + +#[derive(Debug, Serialize)] +struct DraftDetail { + id: String, + to: String, + cc: String, + bcc: String, + subject: String, + text_body: String, + html_body: Option, + in_reply_to: Option, + references: Option, + request_mdn: bool, + priority: String, + compose_format: String, + pgp_mode: String, + pgp_inline: bool, + pgp_attach_public_key: bool, + pgp_attach_recipient_keys: bool, + remote_synced: bool, + created_at: String, + updated_at: String, + attachments: Vec, +} + +#[derive(Debug, Serialize)] +struct AttachmentInfo { + id: String, + filename: String, + content_type: String, + size: i64, +} + +#[derive(Debug, Serialize)] +struct DraftListItem { + id: String, + to: String, + subject: String, + updated_at: String, + remote_synced: bool, +} + +#[derive(Debug, Serialize)] +struct DeleteResponse { + status: String, +} + +// --------------------------------------------------------------------------- +// Handlers +// --------------------------------------------------------------------------- + +/// `POST /api/drafts` — Create or update a draft. +pub async fn upsert_draft_handler( + Extension(session): Extension, + Extension(config): Extension>, + Extension(imap_client): Extension>, + Json(req): Json, +) -> Result { + db::drafts::validate_draft_id(&req.id).map_err(AppError::BadRequest)?; + let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) + .map_err(|e| AppError::InternalError(format!("Failed to open database: {e}")))?; + + let priority = req.priority.trim().to_ascii_lowercase(); + if !matches!(priority.as_str(), "high" | "normal" | "low") { + return Err(AppError::BadRequest( + "Priority must be high, normal, or low".to_string(), + )); + } + let compose_format = req.compose_format.trim().to_ascii_lowercase(); + if !matches!(compose_format.as_str(), "html" | "text" | "markdown") { + return Err(AppError::BadRequest( + "Compose format must be html, text, or markdown".to_string(), + )); + } + let pgp_mode = req.pgp_mode.trim().to_ascii_lowercase(); + if !matches!( + pgp_mode.as_str(), + "none" | "sign" | "encrypt" | "sign-encrypt" + ) { + return Err(AppError::BadRequest( + "Invalid OpenPGP draft mode".to_string(), + )); + } + + db::drafts::upsert_draft( + &conn, + &req.id, + &req.to, + &req.cc, + &req.bcc, + &req.subject, + &req.text_body, + req.html_body.as_deref(), + req.in_reply_to.as_deref(), + req.references.as_deref(), + req.request_mdn, + &priority, + &compose_format, + &pgp_mode, + req.pgp_inline, + req.pgp_attach_public_key, + req.pgp_attach_recipient_keys, + ) + .map_err(AppError::InternalError)?; + + // Best-effort: sync deliberate snapshots to IMAP Drafts. Autosaves remain + // local so typing never produces a remote message per keystroke. The + // server-assigned UID is resolved from UIDNEXT + an application-owned + // identity header before an older snapshot is removed. + if req.sync_imap && !session.imap_host.trim().is_empty() { + let imap_creds = session_imap_credentials(&session); + let drafts_folder = db::folders::find_folder_by_attribute(&conn, "\\Drafts") + .ok() + .flatten() + .unwrap_or_else(|| "Drafts".to_string()); + let previous = db::drafts::get_draft(&conn, &req.id).map_err(AppError::InternalError)?; + + let snapshot_attachments = load_snapshot_attachments(&conn, &req.id); + match snapshot_attachments + .and_then(|attachments| build_draft_rfc822(&req, &session.email, &attachments)) + { + Ok(rfc822_bytes) => match imap_client + .append_message_resolved_uid( + &imap_creds, + &drafts_folder, + &rfc822_bytes, + &["\\Draft", "\\Seen"], + "X-RustBox-Draft-ID", + &req.id, + ) + .await + { + Ok((new_uid, uid_validity)) => { + db::drafts::set_remote_location( + &conn, + &req.id, + &drafts_folder, + new_uid, + uid_validity, + ) + .map_err(AppError::InternalError)?; + + if let Some(previous) = previous { + remove_previous_remote_snapshot( + imap_client.as_ref(), + &imap_creds, + &previous, + &drafts_folder, + new_uid, + uid_validity, + ) + .await; + } + } + Err(e) => { + tracing::warn!(error = %e, folder = %drafts_folder, draft_id = %req.id, "Failed to resolve IMAP UID for saved draft snapshot; keeping previous remote pointer"); + } + }, + Err(e) => { + tracing::warn!(error = %e, draft_id = %req.id, "Failed to build RFC822 draft snapshot") + } + } + } + + Ok(Json(DraftResponse { + id: req.id, + status: "saved".to_string(), + }) + .into_response()) +} + +fn session_imap_credentials(session: &SessionState) -> ImapCredentials { + ImapCredentials { + host: session.imap_host.clone(), + port: session.imap_port, + tls: session.imap_tls, + email: session.email.clone(), + password: session.password.clone(), + } +} + +async fn remove_previous_remote_snapshot( + imap_client: &dyn ImapClient, + creds: &ImapCredentials, + previous: &db::drafts::Draft, + new_folder: &str, + new_uid: u32, + new_uid_validity: u32, +) { + let (Some(folder), Some(uid), Some(uid_validity)) = ( + previous.remote_folder.as_deref(), + previous.remote_uid, + previous.remote_uid_validity, + ) else { + return; + }; + if folder == new_folder && uid == new_uid { + return; + } + + let current_validity = if folder == new_folder { + new_uid_validity + } else { + match imap_client.folder_status(creds, folder).await { + Ok(status) => status.uid_validity, + Err(e) => { + tracing::warn!(error = %e, folder, uid, "Could not verify old remote draft before cleanup"); + return; + } + } + }; + if current_validity != uid_validity { + tracing::info!( + folder, + uid, + stored_uid_validity = uid_validity, + current_uid_validity = current_validity, + "Skipping stale remote draft cleanup after UIDVALIDITY change" + ); + return; + } + if let Err(e) = imap_client.expunge_message(creds, folder, uid).await { + tracing::warn!(error = %e, folder, uid, "Failed to remove superseded remote draft snapshot"); + } +} + +pub(crate) async fn remove_remote_draft_snapshot( + imap_client: &dyn ImapClient, + creds: &ImapCredentials, + draft: &db::drafts::Draft, +) { + let (Some(folder), Some(uid), Some(uid_validity)) = ( + draft.remote_folder.as_deref(), + draft.remote_uid, + draft.remote_uid_validity, + ) else { + return; + }; + match imap_client.folder_status(creds, folder).await { + Ok(status) if status.uid_validity == uid_validity => { + if let Err(e) = imap_client.expunge_message(creds, folder, uid).await { + tracing::warn!(error = %e, folder, uid, "Failed to remove remote draft snapshot"); + } + } + Ok(status) => { + tracing::info!( + folder, + uid, + stored_uid_validity = uid_validity, + current_uid_validity = status.uid_validity, + "Remote draft pointer became stale after UIDVALIDITY change" + ); + } + Err(e) => { + tracing::warn!(error = %e, folder, uid, "Could not verify remote draft snapshot before cleanup") + } + } +} + +fn load_snapshot_attachments( + conn: &rusqlite::Connection, + draft_id: &str, +) -> Result, String> { + let records = db::drafts::get_draft_attachments(conn, draft_id)?; + records + .into_iter() + .map(|record| { + let data = std::fs::read(&record.file_path).map_err(|e| { + format!( + "Failed to read draft attachment '{}' for IMAP snapshot: {e}", + record.filename + ) + })?; + Ok(AttachmentData { + filename: record.filename, + content_type: record.content_type, + data, + content_id: Some(record.id), + }) + }) + .collect() +} + +/// Build a minimal RFC822 message from draft fields for IMAP APPEND. +fn build_draft_rfc822( + req: &UpsertDraftRequest, + from_email: &str, + attachments: &[AttachmentData], +) -> Result, String> { + use lettre::message::{ + Attachment, Mailbox, MultiPart, SinglePart, + header::{ContentType, HeaderName, HeaderValue}, + }; + + let from_mailbox: Mailbox = from_email + .parse() + .map_err(|e: lettre::address::AddressError| e.to_string())?; + + let draft_id = req.id.replace('\r', "").replace('\n', ""); + let mut builder = lettre::Message::builder() + .from(from_mailbox) + .subject(&req.subject) + .raw_header(HeaderValue::new( + HeaderName::new_from_ascii_str("X-RustBox-Draft-ID"), + draft_id, + )); + + // Parse comma-separated address lists. + for addr in req.to.split(',').map(str::trim).filter(|s| !s.is_empty()) { + if let Ok(mailbox) = addr.parse::() { + builder = builder.to(mailbox); + } + } + for addr in req.cc.split(',').map(str::trim).filter(|s| !s.is_empty()) { + if let Ok(mailbox) = addr.parse::() { + builder = builder.cc(mailbox); + } + } + for addr in req.bcc.split(',').map(str::trim).filter(|s| !s.is_empty()) { + if let Ok(mailbox) = addr.parse::() { + builder = builder.bcc(mailbox); + } + } + if let Some(ref irt) = req.in_reply_to { + builder = builder.in_reply_to(irt.clone()); + } + if let Some(ref refs) = req.references { + builder = builder.references(refs.clone()); + } + builder = crate::smtp::types::apply_optional_header_values( + builder, + req.request_mdn.then_some(from_email), + None, + req.priority.trim(), + ); + + let html_body = req.html_body.as_deref().unwrap_or(""); + let (inline_atts, file_atts): (Vec<_>, Vec<_>) = attachments.iter().partition(|att| { + att.content_id + .as_ref() + .is_some_and(|cid| html_body.contains(&format!("cid:{cid}"))) + }); + + let body_part = if let Some(ref html) = req.html_body { + if inline_atts.is_empty() { + MultiPart::alternative() + .singlepart( + SinglePart::builder() + .content_type(ContentType::TEXT_PLAIN) + .body(req.text_body.clone()), + ) + .singlepart( + SinglePart::builder() + .content_type(ContentType::TEXT_HTML) + .body(html.clone()), + ) + } else { + let mut related = MultiPart::related().singlepart( + SinglePart::builder() + .content_type(ContentType::TEXT_HTML) + .body(html.clone()), + ); + for att in &inline_atts { + let content_type: ContentType = + att.content_type.parse().unwrap_or(ContentType::TEXT_PLAIN); + let cid = att.content_id.as_deref().unwrap_or("unknown"); + related = related.singlepart( + Attachment::new_inline(cid.to_string()).body(att.data.clone(), content_type), + ); + } + MultiPart::alternative() + .singlepart( + SinglePart::builder() + .content_type(ContentType::TEXT_PLAIN) + .body(req.text_body.clone()), + ) + .multipart(related) + } + } else { + MultiPart::alternative().singlepart( + SinglePart::builder() + .content_type(ContentType::TEXT_PLAIN) + .body(req.text_body.clone()), + ) + }; + + let email = if file_atts.is_empty() { + builder.multipart(body_part).map_err(|e| e.to_string())? + } else { + let mut mixed = MultiPart::mixed().multipart(body_part); + for att in &file_atts { + let content_type: ContentType = + att.content_type.parse().unwrap_or(ContentType::TEXT_PLAIN); + mixed = mixed.singlepart( + Attachment::new(att.filename.clone()).body(att.data.clone(), content_type), + ); + } + builder.multipart(mixed).map_err(|e| e.to_string())? + }; + + Ok(email.formatted()) +} + +/// `GET /api/drafts` — List all drafts. +pub async fn list_drafts_handler( + Extension(session): Extension, + Extension(config): Extension>, +) -> Result { + let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) + .map_err(|e| AppError::InternalError(format!("Failed to open database: {e}")))?; + + let drafts = db::drafts::list_drafts(&conn).map_err(AppError::InternalError)?; + + let items: Vec = drafts + .into_iter() + .map(|d| DraftListItem { + id: d.id, + to: d.to_addresses, + subject: d.subject, + updated_at: d.updated_at, + remote_synced: d.remote_uid.is_some(), + }) + .collect(); + + Ok(Json(serde_json::json!({ "drafts": items })).into_response()) +} + +/// `GET /api/drafts/{id}` — Get a single draft with its attachments. +pub async fn get_draft_handler( + Extension(session): Extension, + Extension(config): Extension>, + AxumPath(id): AxumPath, +) -> Result { + db::drafts::validate_draft_id(&id).map_err(AppError::BadRequest)?; + let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) + .map_err(|e| AppError::InternalError(format!("Failed to open database: {e}")))?; + + let draft = db::drafts::get_draft(&conn, &id) + .map_err(AppError::InternalError)? + .ok_or_else(|| AppError::NotFound("Draft not found".to_string()))?; + + let attachments = + db::drafts::get_draft_attachments(&conn, &id).map_err(AppError::InternalError)?; + + let att_infos: Vec = attachments + .into_iter() + .map(|a| AttachmentInfo { + id: a.id, + filename: a.filename, + content_type: a.content_type, + size: a.size, + }) + .collect(); + + Ok(Json(DraftDetail { + id: draft.id, + to: draft.to_addresses, + cc: draft.cc_addresses, + bcc: draft.bcc_addresses, + subject: draft.subject, + text_body: draft.text_body, + html_body: draft.html_body, + in_reply_to: draft.in_reply_to, + references: draft.references_header, + request_mdn: draft.request_mdn, + priority: draft.priority, + compose_format: draft.compose_format, + pgp_mode: draft.pgp_mode, + pgp_inline: draft.pgp_inline, + pgp_attach_public_key: draft.pgp_attach_public_key, + pgp_attach_recipient_keys: draft.pgp_attach_recipient_keys, + remote_synced: draft.remote_uid.is_some(), + created_at: draft.created_at, + updated_at: draft.updated_at, + attachments: att_infos, + }) + .into_response()) +} + +/// `DELETE /api/drafts/{id}` — Delete a draft and its attachments. +pub async fn delete_draft_handler( + Extension(session): Extension, + Extension(config): Extension>, + Extension(imap_client): Extension>, + AxumPath(id): AxumPath, +) -> Result { + db::drafts::validate_draft_id(&id).map_err(AppError::BadRequest)?; + let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) + .map_err(|e| AppError::InternalError(format!("Failed to open database: {e}")))?; + + let existing = db::drafts::get_draft(&conn, &id).map_err(AppError::InternalError)?; + let Some(existing) = existing else { + return Err(AppError::NotFound("Draft not found".to_string())); + }; + if !session.imap_host.trim().is_empty() { + let creds = session_imap_credentials(&session); + remove_remote_draft_snapshot(imap_client.as_ref(), &creds, &existing).await; + } + + let deleted = db::drafts::delete_draft(&conn, &id).map_err(AppError::InternalError)?; + + if !deleted { + return Err(AppError::NotFound("Draft not found".to_string())); + } + + // Clean up attachment files from disk. + let att_dir = Path::new(&config.data_dir) + .join(&session.user_hash) + .join("attachments") + .join(&id); + if att_dir.exists() + && let Err(e) = tokio::fs::remove_dir_all(&att_dir).await + { + tracing::warn!(error = %e, path = %att_dir.display(), "Failed to clean up attachment directory"); + } + + Ok(Json(DeleteResponse { + status: "deleted".to_string(), + }) + .into_response()) +} diff --git a/src/routes/folder_mgmt.rs b/src/routes/folder_mgmt.rs new file mode 100644 index 0000000..257c6f6 --- /dev/null +++ b/src/routes/folder_mgmt.rs @@ -0,0 +1,337 @@ +use std::sync::Arc; + +use axum::extract::Path; +use axum::response::{IntoResponse, Response}; +use axum::{Extension, Json}; +use serde::{Deserialize, Serialize}; + +use crate::auth::session::SessionState; +use crate::config::AppConfig; +use crate::db; +use crate::error::AppError; +use crate::imap::client::{ImapClient, ImapCredentials}; +use crate::search::engine::SearchEngine; + +// --------------------------------------------------------------------------- +// Request / Response types +// --------------------------------------------------------------------------- + +#[derive(Deserialize)] +pub struct CreateFolderRequest { + pub name: String, +} + +#[derive(Deserialize)] +pub struct RenameFolderRequest { + pub new_name: String, +} + +#[derive(Deserialize)] +pub struct SubscribeRequest { + pub subscribed: bool, +} + +#[derive(Serialize)] +struct MessageResponse { + status: &'static str, +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// Folder names that must not be renamed or deleted. +const SYSTEM_FOLDERS: &[&str] = &["INBOX"]; + +/// Returns `true` if the folder is a system folder (case-insensitive). +fn is_system_folder(name: &str) -> bool { + SYSTEM_FOLDERS.iter().any(|s| s.eq_ignore_ascii_case(name)) +} + +/// Validates a folder name. Returns `Ok(())` if valid, or an `AppError::BadRequest`. +fn validate_folder_name(name: &str) -> Result<(), AppError> { + if name.trim().is_empty() { + return Err(AppError::BadRequest( + "Folder name cannot be empty".to_string(), + )); + } + if name.len() > 255 { + return Err(AppError::BadRequest( + "Folder name must be 255 characters or fewer".to_string(), + )); + } + if name.contains('{') || name.contains('}') || name.contains('*') || name.contains('%') { + return Err(AppError::BadRequest( + "Folder name contains invalid characters".to_string(), + )); + } + if name.chars().any(|c| c.is_control()) { + return Err(AppError::BadRequest( + "Folder name contains invalid characters".to_string(), + )); + } + Ok(()) +} + +/// Build IMAP credentials from the session and app configuration. +fn build_creds(session: &SessionState, _config: &AppConfig) -> Result { + Ok(ImapCredentials { + host: session.imap_host.clone(), + port: session.imap_port, + tls: session.imap_tls, + email: session.email.clone(), + password: session.password.clone(), + }) +} + +// --------------------------------------------------------------------------- +// Handlers +// --------------------------------------------------------------------------- + +/// `POST /api/folders` -- create a new IMAP folder. +pub async fn create_folder( + Extension(session): Extension, + Extension(config): Extension>, + Extension(imap_client): Extension>, + Json(body): Json, +) -> Result { + validate_folder_name(&body.name)?; + + let creds = build_creds(&session, &config)?; + + // Create the folder on the IMAP server (also subscribes). + imap_client + .create_folder(&creds, &body.name) + .await + .map_err(|e| AppError::InternalError(format!("IMAP create_folder failed: {e}")))?; + + // Cache the new folder locally. + let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + + db::folders::insert_folder_if_new(&conn, &body.name, None, "") + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + + Ok(Json(MessageResponse { status: "created" }).into_response()) +} + +/// `PATCH /api/folders/:name` -- rename an existing folder. +pub async fn rename_folder( + Extension(session): Extension, + Extension(config): Extension>, + Extension(imap_client): Extension>, + Path(name): Path, + Json(body): Json, +) -> Result { + if is_system_folder(&name) { + return Err(AppError::BadRequest(format!( + "Cannot rename system folder '{name}'" + ))); + } + + validate_folder_name(&body.new_name)?; + + let creds = build_creds(&session, &config)?; + + imap_client + .rename_folder(&creds, &name, &body.new_name) + .await + .map_err(|e| AppError::InternalError(format!("IMAP rename_folder failed: {e}")))?; + + // Update the local cache. + let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + + db::folders::rename_folder_in_cache(&conn, &name, &body.new_name) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + db::special_folders::rename_folder_reference(&conn, &name, &body.new_name) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + + Ok(Json(MessageResponse { status: "renamed" }).into_response()) +} + +/// `DELETE /api/folders/:name` -- delete a folder. +pub async fn delete_folder( + Extension(session): Extension, + Extension(config): Extension>, + Extension(imap_client): Extension>, + Path(name): Path, +) -> Result { + if is_system_folder(&name) { + return Err(AppError::BadRequest(format!( + "Cannot delete system folder '{name}'" + ))); + } + + let creds = build_creds(&session, &config)?; + + imap_client + .delete_folder(&creds, &name) + .await + .map_err(|e| AppError::InternalError(format!("IMAP delete_folder failed: {e}")))?; + + // Remove from local cache. + let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + + db::special_folders::clear_folder_reference(&conn, &name) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + db::folders::delete_folder_and_messages(&conn, &name) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + + Ok(Json(MessageResponse { status: "deleted" }).into_response()) +} + +/// `PATCH /api/folders/:name/subscribe` -- toggle folder subscription. +pub async fn subscribe_folder( + Extension(session): Extension, + Extension(config): Extension>, + Extension(imap_client): Extension>, + Path(name): Path, + Json(body): Json, +) -> Result { + let creds = build_creds(&session, &config)?; + + imap_client + .subscribe_folder(&creds, &name, body.subscribed) + .await + .map_err(|e| AppError::InternalError(format!("IMAP subscribe_folder failed: {e}")))?; + + Ok(Json(MessageResponse { + status: if body.subscribed { + "subscribed" + } else { + "unsubscribed" + }, + }) + .into_response()) +} + +/// `POST /api/folders/:name/mark-all-read` -- add \Seen to every message. +pub async fn mark_all_read( + Extension(session): Extension, + Extension(config): Extension>, + Extension(imap_client): Extension>, + Path(name): Path, +) -> Result { + let creds = build_creds(&session, &config)?; + imap_client + .mark_all_read(&creds, &name) + .await + .map_err(|e| AppError::ServiceUnavailable(format!("IMAP mark-all-read failed: {e}")))?; + + let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + conn.execute( + "UPDATE messages SET flags = CASE WHEN flags = '' THEN ?2 ELSE flags || ',' || ?2 END WHERE folder = ?1 AND instr(flags, ?2) = 0", + rusqlite::params![&name, "\\Seen"], + ) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + conn.execute( + "UPDATE folders SET unread_count = 0 WHERE name = ?1", + [&name], + ) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + + Ok(Json(MessageResponse { status: "read" }).into_response()) +} + +/// `POST /api/folders/:name/empty` -- permanently remove every message. +pub async fn empty_folder( + Extension(session): Extension, + Extension(config): Extension>, + Extension(imap_client): Extension>, + Extension(search_engine): Extension>, + Path(name): Path, +) -> Result { + let creds = build_creds(&session, &config)?; + imap_client + .empty_folder(&creds, &name) + .await + .map_err(|e| AppError::ServiceUnavailable(format!("IMAP empty-folder failed: {e}")))?; + + let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + let uids: Vec = { + let mut statement = conn + .prepare("SELECT uid FROM messages WHERE folder = ?1") + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + let rows = statement + .query_map([&name], |row| row.get(0)) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + rows.filter_map(Result::ok).collect() + }; + db::messages::delete_folder_messages(&conn, &name) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + conn.execute( + "UPDATE folders SET total_count = 0, unread_count = 0, messages_updated_at = datetime('now') WHERE name = ?1", + [&name], + ) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + + if let Ok(index) = search_engine.open_user_index(&session.user_hash) { + for uid in uids { + if let Err(error) = index.delete_message(uid, &name) { + tracing::warn!(uid, folder = %name, %error, "Failed to remove emptied message from search index"); + } + } + } + + Ok(Json(MessageResponse { status: "emptied" }).into_response()) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_is_system_folder() { + assert!(is_system_folder("INBOX")); + assert!(is_system_folder("inbox")); + assert!(is_system_folder("Sent")); + assert!(is_system_folder("Drafts")); + assert!(is_system_folder("Trash")); + assert!(is_system_folder("Junk")); + assert!(is_system_folder("Spam")); + assert!(!is_system_folder("Archive")); + assert!(!is_system_folder("MyFolder")); + } + + #[test] + fn test_validate_folder_name_ok() { + assert!(validate_folder_name("MyFolder").is_ok()); + assert!(validate_folder_name("Projects/2024").is_ok()); + } + + #[test] + fn test_validate_folder_name_empty() { + assert!(validate_folder_name("").is_err()); + assert!(validate_folder_name(" ").is_err()); + } + + #[test] + fn test_validate_folder_name_too_long() { + let long = "a".repeat(256); + assert!(validate_folder_name(&long).is_err()); + } + + #[test] + fn test_validate_folder_name_control_chars() { + for control in ['\0', '\t', '\r', '\n', '\u{7f}', '\u{85}'] { + assert!(validate_folder_name(&format!("test{control}folder")).is_err()); + } + assert!(validate_folder_name("Projects/café").is_ok()); + } + + #[test] + fn test_validate_folder_name_invalid_chars() { + assert!(validate_folder_name("test{folder").is_err()); + assert!(validate_folder_name("test}folder").is_err()); + assert!(validate_folder_name("test*folder").is_err()); + assert!(validate_folder_name("test%folder").is_err()); + } +} diff --git a/src/routes/folder_preferences.rs b/src/routes/folder_preferences.rs new file mode 100644 index 0000000..9b63c86 --- /dev/null +++ b/src/routes/folder_preferences.rs @@ -0,0 +1,47 @@ +use std::sync::Arc; + +use axum::extract::Path; +use axum::response::{IntoResponse, Response}; +use axum::{Extension, Json}; + +use crate::auth::session::SessionState; +use crate::config::AppConfig; +use crate::db; +use crate::db::folder_preferences::UpdateFolderPreferences; +use crate::error::AppError; + +pub async fn get_folder_preferences( + Path(folder): Path, + Extension(session): Extension, + Extension(config): Extension>, +) -> Result { + let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) + .map_err(|error| AppError::InternalError(format!("Database error: {error}")))?; + let prefs = db::folder_preferences::get_preferences(&conn, &folder).map_err(|message| { + if message.starts_with("Folder does not exist:") { + AppError::BadRequest(message) + } else { + AppError::InternalError(message) + } + })?; + Ok(Json(prefs).into_response()) +} + +pub async fn update_folder_preferences( + Path(folder): Path, + Extension(session): Extension, + Extension(config): Extension>, + Json(body): Json, +) -> Result { + let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) + .map_err(|error| AppError::InternalError(format!("Database error: {error}")))?; + let prefs = + db::folder_preferences::update_preferences(&conn, &folder, &body).map_err(|message| { + if message.starts_with("Folder does not exist:") { + AppError::BadRequest(message) + } else { + AppError::InternalError(message) + } + })?; + Ok(Json(prefs).into_response()) +} diff --git a/src/routes/folders.rs b/src/routes/folders.rs new file mode 100644 index 0000000..797c37d --- /dev/null +++ b/src/routes/folders.rs @@ -0,0 +1,130 @@ +use std::sync::Arc; + +use axum::response::{IntoResponse, Response}; +use axum::{Extension, Json}; +use serde::Serialize; + +use crate::auth::session::SessionState; +use crate::config::AppConfig; +use crate::db; +use crate::error::AppError; +use crate::imap::client::{ImapClient, ImapCredentials}; + +/// Response envelope for `GET /api/folders`. +#[derive(Serialize)] +struct FoldersResponse { + folders: Vec, +} + +/// A single folder in the response. +#[derive(Serialize)] +struct FolderEntry { + name: String, + delimiter: Option, + attributes: Vec, + is_subscribed: bool, + total_count: u32, + unread_count: u32, +} + +/// How many seconds the folder list cache is considered fresh. +const FOLDER_LIST_TTL_SECS: u32 = 30; + +/// `GET /api/folders` +/// +/// Lists all IMAP folders for the authenticated user, syncing the result +/// into the per-user SQLite cache. If the cache was refreshed within +/// `FOLDER_LIST_TTL_SECS` seconds, IMAP is skipped entirely. +pub async fn list_folders( + Extension(session): Extension, + Extension(config): Extension>, + Extension(imap_client): Extension>, +) -> Result { + // Open the per-user database. + let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + + // If the folder cache was updated recently, skip the IMAP round-trip. + let cache_fresh = db::folders::is_folder_cache_fresh(&conn, FOLDER_LIST_TTL_SECS) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + + if !cache_fresh { + let creds = ImapCredentials { + host: session.imap_host.clone(), + port: session.imap_port, + tls: session.imap_tls, + email: session.email.clone(), + password: session.password.clone(), + }; + + // Fetch folders from IMAP server. + let imap_folders = imap_client + .list_folders(&creds) + .await + .map_err(|e| AppError::ServiceUnavailable(format!("IMAP error: {e}")))?; + + // Sync each folder into SQLite cache. + // Use INSERT OR IGNORE to create new folders without triggering CASCADE on + // existing ones, then UPDATE the metadata fields separately. + for folder in &imap_folders { + let flags_csv = folder.attributes.join(","); + db::folders::insert_folder_if_new( + &conn, + &folder.name, + folder.delimiter.as_deref(), + &flags_csv, + ) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + } + + // Remove stale folders that no longer exist on the server. + let current_names: Vec = imap_folders.iter().map(|f| f.name.clone()).collect(); + db::folders::remove_stale_folders(&conn, ¤t_names) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + + // Touch updated_at on all folders so the cache TTL resets. + db::folders::touch_all_folders(&conn) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + } + + // Refresh unread counts from cached messages — but skip folders whose + // messages cache has been invalidated (messages_updated_at IS NULL). + // Those folders have a manually adjusted unread_count (via adjust_unread_count) + // that should be preserved until the folder's messages are resynced from IMAP. + let all_folders = db::folders::get_all_folders(&conn) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + for f in &all_folders { + let invalidated = db::folders::is_folder_messages_invalidated(&conn, &f.name) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + if !invalidated { + db::folders::refresh_unread_count(&conn, &f.name) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + } + } + + // Read back from cache to get the refreshed counts. + let cached = db::folders::get_all_folders(&conn) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + + // Build response from cached data (includes any stored counts). + let folders: Vec = cached + .into_iter() + .map(|f| { + let attributes: Vec = if f.flags.is_empty() { + vec![] + } else { + f.flags.split(',').map(|s| s.to_string()).collect() + }; + FolderEntry { + name: f.name, + delimiter: f.delimiter, + attributes, + is_subscribed: f.is_subscribed, + total_count: f.total_count, + unread_count: f.unread_count, + } + }) + .collect(); + + Ok(Json(FoldersResponse { folders }).into_response()) +} diff --git a/src/routes/health.rs b/src/routes/health.rs new file mode 100644 index 0000000..4d0dc58 --- /dev/null +++ b/src/routes/health.rs @@ -0,0 +1,149 @@ +use std::fs; +use std::sync::Arc; + +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use axum::{Extension, Json}; +use serde::Serialize; + +use crate::config::AppConfig; + +#[derive(Serialize)] +pub struct HealthResponse { + pub status: String, +} + +/// Handler for `GET /api/health`. +/// +/// Liveness only: if the process can answer HTTP, it is alive. +pub async fn health_check() -> Json { + Json(HealthResponse { + status: "ok".to_string(), + }) +} + +/// Handler for `GET /api/ready`. +/// +/// Readiness additionally verifies that RustBox can create/access its local +/// data directory. External IMAP/SMTP servers are intentionally not probed: +/// they are account-specific and a remote outage must not make the whole +/// RustBox process unready for every account. +pub async fn readiness_check(Extension(config): Extension>) -> Response { + let ready = fs::create_dir_all(&config.data_dir).is_ok() + && fs::metadata(&config.data_dir).is_ok_and(|metadata| metadata.is_dir()); + let status = if ready { "ready" } else { "not_ready" }; + ( + if ready { + StatusCode::OK + } else { + StatusCode::SERVICE_UNAVAILABLE + }, + Json(HealthResponse { + status: status.to_string(), + }), + ) + .into_response() +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + use std::time::Duration; + + use crate::auth::session::SessionStore; + use crate::config::AppConfig; + use crate::imap::client::ImapClient; + use crate::imap::client::mock::MockImapClient; + use crate::routes::create_router; + use crate::smtp::client::SmtpClient; + use crate::smtp::client::mock::MockSmtpClient; + use axum::body::Body; + use axum::http::{Request, StatusCode}; + use http_body_util::BodyExt; + use tower::ServiceExt; + + fn test_config(data_dir: String) -> Arc { + Arc::new(AppConfig { + host: "127.0.0.1".to_string(), + port: 3001, + imap_host: None, + imap_port: 993, + smtp_host: None, + smtp_port: 587, + tls_enabled: true, + data_dir, + session_timeout_hours: 24, + environment: "development".to_string(), + base_path: None, + sieve_host: None, + sieve_port: 4190, + sieve_tls_mode: "starttls".to_string(), + sieve_allow_insecure_auth: false, + sieve_timeout_secs: 10, + trusted_proxies: None, + outgoing_headers: Vec::new(), + }) + } + + fn test_router(config: Arc) -> axum::Router { + let store = Arc::new(SessionStore::new(Duration::from_secs(3600))); + let imap_client: Arc = Arc::new(MockImapClient::new()); + let smtp_client: Arc = Arc::new(MockSmtpClient::new()); + let search_engine = Arc::new(crate::search::engine::SearchEngine::new( + std::path::PathBuf::from(&config.data_dir), + )); + let event_bus = Arc::new(crate::realtime::events::EventBus::new()); + let idle_manager = Arc::new(crate::realtime::idle::IdleManager::new()); + create_router( + config, + store, + imap_client, + smtp_client, + search_engine, + event_bus, + idle_manager, + ) + } + + #[tokio::test] + async fn health_check_returns_ok() { + let temp = tempfile::tempdir().unwrap(); + let app = test_router(test_config(temp.path().to_string_lossy().into_owned())); + let response = app + .oneshot( + Request::builder() + .uri("/api/health") + .body(Body::empty()) + .expect("request should build"), + ) + .await + .expect("response should succeed"); + + assert_eq!(response.status(), StatusCode::OK); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(json["status"], "ok"); + } + + #[tokio::test] + async fn readiness_creates_data_directory_and_returns_ready() { + let temp = tempfile::tempdir().unwrap(); + let data_dir = temp.path().join("nested").join("rustbox"); + let app = test_router(test_config(data_dir.to_string_lossy().into_owned())); + let response = app + .oneshot( + Request::builder() + .uri("/api/ready") + .body(Body::empty()) + .expect("request should build"), + ) + .await + .expect("response should succeed"); + + assert_eq!(response.status(), StatusCode::OK); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(json["status"], "ready"); + assert!(data_dir.is_dir()); + } +} diff --git a/src/routes/identities.rs b/src/routes/identities.rs new file mode 100644 index 0000000..f1c227a --- /dev/null +++ b/src/routes/identities.rs @@ -0,0 +1,120 @@ +use std::sync::Arc; + +use axum::extract::Path; +use axum::response::{IntoResponse, Response}; +use axum::{Extension, Json}; + +use crate::auth::session::SessionState; +use crate::config::AppConfig; +use crate::db; +use crate::db::identities::{CreateIdentity, UpdateIdentity}; +use crate::error::AppError; + +/// `GET /api/identities` — list all sender identities. +pub async fn list_identities_handler( + Extension(session): Extension, + Extension(config): Extension>, +) -> Result { + let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) + .map_err(|e| AppError::InternalError(format!("Failed to open database: {e}")))?; + + let identities = db::identities::list_identities(&conn).map_err(AppError::InternalError)?; + + Ok(Json(serde_json::json!({ "identities": identities })).into_response()) +} + +/// `GET /api/identities/:id` — get a single identity. +pub async fn get_identity_handler( + Extension(session): Extension, + Extension(config): Extension>, + Path(id): Path, +) -> Result { + let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) + .map_err(|e| AppError::InternalError(format!("Failed to open database: {e}")))?; + + let identity = db::identities::get_identity(&conn, id).map_err(AppError::InternalError)?; + + match identity { + Some(i) => Ok(Json(i).into_response()), + None => Err(AppError::NotFound("Identity not found".to_string())), + } +} + +/// `POST /api/identities` — create a new identity. +pub async fn create_identity_handler( + Extension(session): Extension, + Extension(config): Extension>, + Json(mut data): Json, +) -> Result { + if data.email.trim().is_empty() { + return Err(AppError::BadRequest("Email is required".to_string())); + } + + let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) + .map_err(|e| AppError::InternalError(format!("Failed to open database: {e}")))?; + normalize_and_validate_pgp_key(&conn, &mut data.pgp_fingerprint)?; + + let identity = + db::identities::create_identity(&conn, &data).map_err(AppError::InternalError)?; + + Ok((axum::http::StatusCode::CREATED, Json(identity)).into_response()) +} + +/// `PUT /api/identities/:id` — update an identity. +pub async fn update_identity_handler( + Extension(session): Extension, + Extension(config): Extension>, + Path(id): Path, + Json(mut data): Json, +) -> Result { + let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) + .map_err(|e| AppError::InternalError(format!("Failed to open database: {e}")))?; + if let Some(value) = data.pgp_fingerprint.as_mut() { + normalize_and_validate_pgp_key(&conn, value)?; + } + + match db::identities::update_identity(&conn, id, &data).map_err(AppError::InternalError)? { + Some(identity) => Ok(Json(identity).into_response()), + None => Err(AppError::NotFound("Identity not found".to_string())), + } +} + +/// `DELETE /api/identities/:id` — delete an identity. +pub async fn delete_identity_handler( + Extension(session): Extension, + Extension(config): Extension>, + Path(id): Path, +) -> Result { + let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) + .map_err(|e| AppError::InternalError(format!("Failed to open database: {e}")))?; + + if db::identities::delete_identity(&conn, id).map_err(AppError::InternalError)? { + Ok(Json(serde_json::json!({ "status": "deleted" })).into_response()) + } else { + Err(AppError::NotFound("Identity not found".to_string())) + } +} + +fn normalize_and_validate_pgp_key( + conn: &rusqlite::Connection, + value: &mut Option, +) -> Result<(), AppError> { + let Some(raw) = value.as_ref() else { + return Ok(()); + }; + if raw.trim().is_empty() { + *value = None; + return Ok(()); + } + let normalized = db::pgp::normalize_fingerprint(raw); + let key = db::pgp::get_key(conn, &normalized) + .map_err(AppError::InternalError)? + .ok_or_else(|| AppError::BadRequest("Selected OpenPGP key does not exist".to_string()))?; + if !key.record.has_secret { + return Err(AppError::BadRequest( + "Selected OpenPGP identity key has no private material".to_string(), + )); + } + *value = Some(normalized); + Ok(()) +} diff --git a/src/routes/mail_preferences.rs b/src/routes/mail_preferences.rs new file mode 100644 index 0000000..38d9c5b --- /dev/null +++ b/src/routes/mail_preferences.rs @@ -0,0 +1,33 @@ +use std::sync::Arc; + +use axum::response::{IntoResponse, Response}; +use axum::{Extension, Json}; + +use crate::auth::session::SessionState; +use crate::config::AppConfig; +use crate::db; +use crate::db::mail_preferences::UpdateMailPreferences; +use crate::error::AppError; + +pub async fn get_mail_preferences( + Extension(session): Extension, + Extension(config): Extension>, +) -> Result { + let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) + .map_err(|error| AppError::InternalError(format!("Database error: {error}")))?; + let prefs = db::mail_preferences::get_preferences(&conn) + .map_err(|error| AppError::InternalError(format!("Database error: {error}")))?; + Ok(Json(prefs).into_response()) +} + +pub async fn update_mail_preferences( + Extension(session): Extension, + Extension(config): Extension>, + Json(body): Json, +) -> Result { + let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) + .map_err(|error| AppError::InternalError(format!("Database error: {error}")))?; + let prefs = + db::mail_preferences::update_preferences(&conn, &body).map_err(AppError::BadRequest)?; + Ok(Json(prefs).into_response()) +} diff --git a/src/routes/messages/mod.rs b/src/routes/messages/mod.rs new file mode 100644 index 0000000..6733fe6 --- /dev/null +++ b/src/routes/messages/mod.rs @@ -0,0 +1,1988 @@ +use std::sync::Arc; + +use axum::extract::Multipart; +use axum::extract::Path; +use axum::extract::Query; +use axum::response::{IntoResponse, Response}; +use axum::{Extension, Json}; + +pub mod types; +use types::*; + +use crate::auth::session::SessionState; +use crate::config::AppConfig; +use crate::db; +use crate::db::header_sync::HeaderSync; +use crate::email_theme; +use crate::error::AppError; +use crate::imap::client::{ImapClient, ImapCredentials}; +use crate::realtime::events::{EventBus, MailEvent}; +use crate::search::engine::{IndexableMessage, SearchEngine, UserIndex}; + +// --------------------------------------------------------------------------- +// Helper: build IMAP credentials from session + config +// --------------------------------------------------------------------------- + +fn build_creds(session: &SessionState, _config: &AppConfig) -> Result { + Ok(ImapCredentials { + host: session.imap_host.clone(), + port: session.imap_port, + tls: session.imap_tls, + email: session.email.clone(), + password: session.password.clone(), + }) +} + +// --------------------------------------------------------------------------- +// Helper: validate IMAP flags from client input +// --------------------------------------------------------------------------- + +/// Returns `true` if `flag` is a syntactically valid IMAP flag. +/// +/// Accepts RFC 3501 system flags (`\Seen`, `\Answered`, `\Flagged`, +/// `\Deleted`, `\Draft`, `\Recent`) and keyword atoms (`Junk`, +/// `$Forwarded`, `$MDNSent`, etc.). +/// +/// This is a restricted safe subset of the RFC 3501 §9 atom grammar: +/// an optional leading `\` followed by `[A-Za-z0-9$_\-.+]`, capped at +/// 64 characters. The cap is well above any flag seen in practice and +/// keeps the set narrow enough to exclude whitespace, parentheses, and +/// control characters without needing to enumerate every RFC exclusion. +fn is_valid_flag(flag: &str) -> bool { + if flag.is_empty() || flag.len() > 64 { + return false; + } + // Strip optional leading backslash (system flags like \Seen). + let atom = flag.strip_prefix('\\').unwrap_or(flag); + !atom.is_empty() + && atom + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'$' | b'_' | b'-' | b'+' | b'.')) +} + +fn validate_flags(flags: &[String]) -> Result<(), AppError> { + for flag in flags { + if !is_valid_flag(flag) { + return Err(AppError::BadRequest("invalid flag".to_string())); + } + } + Ok(()) +} + +// --------------------------------------------------------------------------- +// Handlers +// --------------------------------------------------------------------------- + +/// How many seconds a folder's message cache is considered fresh. +const FOLDER_MESSAGES_TTL_SECS: u32 = 30; + +/// `GET /api/folders/:folder/messages?page=0&per_page=50` +/// +/// Returns paginated messages using a cache-first strategy: +/// 1. If the folder was synced within `FOLDER_MESSAGES_TTL_SECS`, serve from cache. +/// 2. Otherwise do a lightweight IMAP SELECT to check for new messages and sync +/// only what's new. +pub async fn list_messages( + Extension(session): Extension, + Extension(config): Extension>, + Extension(imap_client): Extension>, + Extension(search_engine): Extension>, + Extension(event_bus): Extension>, + Path(folder): Path, + Query(query): Query, +) -> Result { + if !(1..=200).contains(&query.per_page) { + return Err(AppError::BadRequest( + "per_page must be between 1 and 200".to_string(), + )); + } + if !matches!( + query.sort.as_str(), + "date_desc" + | "date_asc" + | "sender_asc" + | "sender_desc" + | "subject_asc" + | "subject_desc" + | "size_asc" + | "size_desc" + ) { + return Err(AppError::BadRequest("unsupported message sort".to_string())); + } + + let mut syncing = false; + + let database = db::pool::UserDb::new(&config.data_dir, &session.user_hash); + let cached_folder_name = folder.clone(); + let (folder_fresh, cached_uid_validity, cached_count, folder_invalidated, mut header_sync) = + database + .run(move |conn| { + let header_sync = HeaderSync::capture(conn)?; + let fresh = db::folders::is_folder_fresh( + conn, + &cached_folder_name, + FOLDER_MESSAGES_TTL_SECS, + )?; + let cached = db::folders::get_folder(conn, &cached_folder_name)?; + let validity = cached.as_ref().map(|f| f.uid_validity).unwrap_or(0); + if cached.is_none() { + db::folders::upsert_folder( + conn, + &cached_folder_name, + None, + None, + "", + true, + 0, + 0, + 0, + 0, + )?; + } + Ok(( + fresh, + validity, + db::messages::count_messages(conn, &cached_folder_name)?, + db::folders::is_folder_messages_invalidated(conn, &cached_folder_name)?, + header_sync, + )) + }) + .await + .map_err(AppError::InternalError)?; + + if !folder_fresh { + let creds = build_creds(&session, &config)?; + + // Do a lightweight IMAP SELECT to get folder status. + let status = imap_client + .folder_status(&creds, &folder) + .await + .map_err(|e| AppError::ServiceUnavailable(format!("IMAP error: {e}")))?; + + tracing::info!( + folder = %folder, + cached_uid_validity = cached_uid_validity, + imap_uid_validity = status.uid_validity, + cached_count = cached_count, + imap_exists = status.exists, + "list_messages: folder status check" + ); + + let needs_full_sync = + cached_uid_validity != 0 && cached_uid_validity != status.uid_validity; + + if needs_full_sync { + tracing::info!(folder = %folder, "UIDVALIDITY changed, clearing cache"); + let name = folder.clone(); + header_sync = database + .run(move |conn| { + let transaction = header_sync.begin_write(conn)?; + db::messages::delete_folder_messages(conn, &name)?; + db::header_sync::invalidate(&transaction)?; + db::folders::update_folder_sync_status( + conn, + &name, + status.uid_validity, + status.exists, + 0, + )?; + let next = HeaderSync::capture(conn)?; + transaction.commit().map_err(|e| e.to_string())?; + Ok(next) + }) + .await + .map_err(AppError::InternalError)?; + } + + let needs_sync = needs_full_sync + || cached_count == 0 + || status.exists != cached_count + || folder_invalidated; + + if needs_sync { + // Cold start: no cached messages at all — fetch the ~100 most recent + // synchronously for a fast first paint, then background-sync the rest. + if (needs_full_sync || cached_count == 0) && status.exists > 0 { + let recent_start = status.uid_next.saturating_sub(100).max(1); + let recent_range = format!("{recent_start}:*"); + + tracing::info!(folder = %folder, uid_range = %recent_range, "Partial sync: fetching recent headers"); + + let headers = imap_client + .fetch_headers(&creds, &folder, &recent_range) + .await + .map_err(|e| AppError::ServiceUnavailable(format!("IMAP error: {e}")))?; + + tracing::info!(folder = %folder, fetched = headers.len(), "Partial sync: fetched recent headers"); + + let name = folder.clone(); + let engine = search_engine.clone(); + let hash = session.user_hash.clone(); + let validity = status.uid_validity; + let exists = status.exists; + database + .run(move |conn| { + let transaction = header_sync.begin_write(conn)?; + upsert_and_index_headers(&transaction, &name, &headers, &engine, &hash) + .map_err(|e| e.to_string())?; + db::folders::update_folder_status(conn, &name, validity, exists)?; + db::folders::refresh_unread_count(conn, &name)?; + transaction.commit().map_err(|e| e.to_string()) + }) + .await + .map_err(AppError::InternalError)?; + + // Spawn background task to fetch the remaining older headers. + if recent_start > 1 { + let remaining_range = (1, recent_start - 1); + tokio::spawn(sync_remaining_headers(BackgroundSyncParams { + creds, + folder: folder.clone(), + imap_client: imap_client.clone(), + config: config.clone(), + user_hash: session.user_hash.clone(), + search_engine: search_engine.clone(), + event_bus: event_bus.clone(), + uid_range: remaining_range, + header_sync, + uid_validity: status.uid_validity, + exists: status.exists, + })); + syncing = true; + } + } else { + // An interrupted initial sync can leave gaps below the highest + // cached UID. Reconcile all server UIDs, fetching only missing ones. + let uid_range = (1, status.uid_next.saturating_sub(1)); + + tokio::spawn(sync_remaining_headers(BackgroundSyncParams { + creds, + folder: folder.clone(), + imap_client: imap_client.clone(), + config: config.clone(), + user_hash: session.user_hash.clone(), + search_engine: search_engine.clone(), + event_bus: event_bus.clone(), + uid_range, + header_sync, + uid_validity: status.uid_validity, + exists: status.exists, + })); + syncing = true; + } + } else { + let name = folder.clone(); + database + .run(move |conn| { + let transaction = header_sync.begin_write(conn)?; + db::folders::update_folder_status( + conn, + &name, + status.uid_validity, + status.exists, + )?; + transaction.commit().map_err(|e| e.to_string()) + }) + .await + .map_err(AppError::InternalError)?; + } + } + + let name = folder.clone(); + let (messages, total_count, tags_map, flagged_on_top) = database + .run(move |conn| { + let flagged_on_top = db::folder_preferences::effective_flagged_on_top(conn, &name)?; + let messages = db::messages::get_messages_sorted( + conn, + &name, + query.page, + query.per_page, + &query.sort, + flagged_on_top, + )?; + let total = db::messages::count_messages(conn, &name)?; + let refs: Vec<_> = messages + .iter() + .map(|m| (m.uid, m.folder.as_str())) + .collect(); + let tags = db::tags::get_tags_for_messages(conn, &refs)?; + Ok((messages, total, tags, flagged_on_top)) + }) + .await + .map_err(AppError::InternalError)?; + + tracing::info!( + folder = %folder, + total_count = total_count, + page_messages = messages.len(), + page = query.page, + per_page = query.per_page, + "list_messages: returning results" + ); + + let summaries: Vec = messages + .into_iter() + .map(|m| { + let msg_tags = tags_map + .get(&(m.uid, m.folder.clone())) + .cloned() + .unwrap_or_default(); + MessageSummary { + uid: m.uid, + folder: m.folder, + subject: m.subject, + from_address: m.from_address, + from_name: m.from_name, + to_addresses: m.to_addresses, + date: m.date, + flags: m.flags, + size: m.size, + has_attachments: m.has_attachments, + snippet: m.snippet, + reaction: m.reaction, + tags: msg_tags, + } + }) + .collect(); + + Ok(Json(ListMessagesResponse { + messages: summaries, + total_count, + page: query.page, + per_page: query.per_page, + syncing, + flagged_on_top, + }) + .into_response()) +} + +// --------------------------------------------------------------------------- +// Helpers for eager loading +// --------------------------------------------------------------------------- + +/// Upsert fetched headers into the database and index them for search. +fn upsert_and_index_headers( + conn: &rusqlite::Transaction<'_>, + folder: &str, + headers: &[crate::imap::types::ImapMessageHeader], + search_engine: &Arc, + user_hash: &str, +) -> Result<(), AppError> { + // Caller holds a checked HeaderSync write transaction through indexing. + let sync_keywords = db::mail_preferences::get_preferences(conn) + .map(|prefs| prefs.sync_imap_keywords) + .unwrap_or(false); + for header in headers { + let from_address = header + .from + .first() + .map(|a| a.address.as_str()) + .unwrap_or(""); + let from_name = header + .from + .first() + .and_then(|a| a.name.as_deref()) + .unwrap_or(""); + let to_json = serde_json::to_string(&header.to).unwrap_or_else(|_| "[]".to_string()); + let cc_json = serde_json::to_string(&header.cc).unwrap_or_else(|_| "[]".to_string()); + let subject = header.subject.as_deref().unwrap_or(""); + let date = header.date.as_deref().unwrap_or(""); + let flags_csv = header.flags.join(","); + + db::messages::upsert_message( + conn, + folder, + header.uid, + header.message_id.as_deref(), + header.in_reply_to.as_deref(), + header.references.as_deref(), + subject, + from_address, + from_name, + &to_json, + &cc_json, + date, + &flags_csv, + header.size, + header.has_attachments, + "", + header.reaction.as_deref(), + ) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + + // Populate denormalized known_addresses table. + db::contacts::populate_known_addresses(conn, from_address, from_name, &to_json, &cc_json) + .map_err(|e| AppError::InternalError(format!("Known addresses error: {e}")))?; + if sync_keywords { + db::tags::import_imap_keywords(conn, header.uid, folder, &header.flags) + .map_err(|e| AppError::InternalError(format!("IMAP keyword import error: {e}")))?; + } + } + + // Index for search (skip Spam/Junk/Trash). + if !headers.is_empty() + && !UserIndex::is_excluded_folder(folder) + && let Ok(user_index) = search_engine.open_user_index(user_hash) + { + let indexable: Vec = headers + .iter() + .map(|h| { + let from_address = h.from.first().map(|a| a.address.as_str()).unwrap_or(""); + let from_name = h.from.first().and_then(|a| a.name.as_deref()).unwrap_or(""); + let subject = h.subject.as_deref().unwrap_or(""); + let date = h.date.as_deref().unwrap_or(""); + let to_json = serde_json::to_string(&h.to).unwrap_or_else(|_| "[]".to_string()); + IndexableMessage { + uid: h.uid, + folder: folder.to_string(), + subject: subject.to_string(), + from_address: from_address.to_string(), + from_name: from_name.to_string(), + to_addresses: to_json, + body_text: String::new(), + date_epoch: crate::db::messages::parse_date_to_epoch_public(date), + has_attachments: h.has_attachments, + } + }) + .collect(); + let _ = user_index.index_messages_batch(&indexable); + } + + Ok(()) +} + +/// Bound each FETCH by existing messages, not by the size of gaps between UIDs. +fn header_chunks(uids: &[u32]) -> impl Iterator + '_ { + uids.chunks(250).map(|chunk| { + chunk + .iter() + .map(u32::to_string) + .collect::>() + .join(",") + }) +} + +/// Parameters for a background sync task, bundled to avoid too-many-arguments. +struct BackgroundSyncParams { + creds: ImapCredentials, + folder: String, + imap_client: Arc, + config: Arc, + user_hash: String, + search_engine: Arc, + event_bus: Arc, + uid_range: (u32, u32), + header_sync: HeaderSync, + uid_validity: u32, + exists: u32, +} + +/// Background task: fetch remaining headers for a folder and notify the +/// frontend via EventBus when done. All errors are logged but non-fatal. +async fn sync_remaining_headers(params: BackgroundSyncParams) { + let BackgroundSyncParams { + creds, + folder, + imap_client, + config, + user_hash, + search_engine, + event_bus, + uid_range, + header_sync, + uid_validity, + exists, + } = params; + let result: Result<(), String> = crate::imap::pool::background(async { + let database = db::pool::UserDb::new(&config.data_dir, &user_hash); + let mut uids = if uid_range.0 <= uid_range.1 { + imap_client + .fetch_uids_and_flags(&creds, &folder) + .await + .map_err(|e| format!("IMAP error: {e}"))? + .into_iter() + .map(|(uid, _)| uid) + .filter(|uid| (uid_range.0..=uid_range.1).contains(uid)) + .collect::>() + } else { + Vec::new() + }; + let name = folder.clone(); + let cached_uids = database + .run(move |conn| { + Ok(db::messages::get_all_uids_and_flags(conn, &name)? + .into_iter() + .map(|(uid, _)| uid) + .collect::>()) + }) + .await?; + uids.retain(|uid| !cached_uids.contains(uid)); + uids.sort_unstable(); + uids.dedup(); + for uid_set in header_chunks(&uids) { + let headers = imap_client + .fetch_headers(&creds, &folder, &uid_set) + .await + .map_err(|e| format!("IMAP error: {e}"))?; + let name = folder.clone(); + let engine = search_engine.clone(); + let hash = user_hash.clone(); + database + .run(move |conn| { + let transaction = header_sync.begin_write(conn)?; + upsert_and_index_headers(&transaction, &name, &headers, &engine, &hash) + .map_err(|e| e.to_string())?; + transaction.commit().map_err(|e| e.to_string()) + }) + .await?; + } + let name = folder.clone(); + database + .run(move |conn| { + let transaction = header_sync.begin_write(conn)?; + db::folders::update_folder_status(conn, &name, uid_validity, exists)?; + db::folders::refresh_unread_count(conn, &name)?; + transaction.commit().map_err(|e| e.to_string()) + }) + .await + }) + .await; + + match result { + Ok(()) => { + tracing::info!(folder = %folder, "Background sync: complete"); + event_bus + .publish(&user_hash, MailEvent::FolderUpdated) + .await; + } + Err(e) => { + tracing::warn!(folder = %folder, error = %e, "Background sync: failed (will retry on next request)"); + let database = db::pool::UserDb::new(&config.data_dir, &user_hash); + let name = folder.clone(); + let superseded = database + .run(move |conn| { + let superseded = HeaderSync::capture(conn)? != header_sync; + db::folders::invalidate_folder_freshness(conn, &name)?; + Ok(superseded) + }) + .await; + // A completed mutation may invalidate a backfill in another folder. + // Wake the client to retry missing UIDs with fresh IMAP reads, not + // the rejected headers. Network errors wait for the next request. + if matches!(superseded, Ok(true)) { + event_bus + .publish(&user_hash, MailEvent::FolderUpdated) + .await; + } + } + } +} + +/// `GET /api/messages/:folder/:uid` +/// +/// Returns the full message detail including body and attachment metadata. +/// Fetches from IMAP and caches if not already cached. +pub async fn get_message( + Extension(session): Extension, + Extension(config): Extension>, + Extension(imap_client): Extension>, + Extension(search_engine): Extension>, + Path((folder, uid)): Path<(String, u32)>, +) -> Result { + let creds = build_creds(&session, &config)?; + + // Open the per-user database. + let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + + // Check SQLite cache first. + let cached_body = db::messages::get_cached_body(&conn, &folder, uid) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + + // Treat a cache hit with missing attachments_json as stale (pre-V006 cache). + // Re-fetch from IMAP so attachments and inline images are properly resolved. + let usable_cache = cached_body.filter(|c| c.attachments_json.is_some()); + let fetched_body = usable_cache.is_none(); + + let (body_html, body_text, attachments, raw_headers, email_theme) = if let Some(cached) = + usable_cache + { + let attachments: Vec = cached + .attachments_json + .as_deref() + .and_then(|j| serde_json::from_str(j).ok()) + .unwrap_or_default(); + let theme = cached.email_theme; + if theme.is_none() + && let Some(ref html) = cached.html + { + let detected = email_theme::detect_email_theme(html).map(|t| t.as_i32()); + if let Some(t) = detected { + let _ = db::messages::update_email_theme(&conn, &folder, uid, t); + } + ( + cached.html, + cached.text, + attachments, + cached.raw_headers.unwrap_or_default(), + detected, + ) + } else { + ( + cached.html, + cached.text, + attachments, + cached.raw_headers.unwrap_or_default(), + theme, + ) + } + } else { + // Fetch from IMAP. + let body = imap_client + .fetch_body(&creds, &folder, uid) + .await + .map_err(|e| match e { + crate::imap::client::ImapError::MessageNotFound { .. } => { + // Message exists in local DB but not on IMAP server — + // remove the stale cache entry, search index entry, and + // invalidate the folder so the list refreshes. + let _ = db::messages::delete_message(&conn, &folder, uid); + let _ = db::folders::invalidate_folder_freshness(&conn, &folder); + if let Ok(user_index) = search_engine.open_user_index(&session.user_hash) { + let _ = user_index.delete_message(uid, &folder); + } + AppError::NotFound(format!("Message UID {uid} not found in folder {folder}")) + } + other => { + // No cached body and IMAP is unreachable — this message + // can't be served. Remove the stale DB entry so it + // disappears from the message list. + let _ = db::messages::delete_message(&conn, &folder, uid); + let _ = db::folders::invalidate_folder_freshness(&conn, &folder); + if let Ok(user_index) = search_engine.open_user_index(&session.user_hash) { + let _ = user_index.delete_message(uid, &folder); + } + AppError::ServiceUnavailable(format!("IMAP error: {other}")) + } + })?; + + // Use HTML directly (frontend sandbox handles security). + let sanitized_html = body.text_html.clone(); + + let attachment_meta: Vec = body + .attachments + .iter() + .enumerate() + .map(|(i, a)| AttachmentMeta { + id: i.to_string(), + filename: a.filename.clone(), + content_type: preview_content_type(&a.content_type, a.filename.as_deref(), &a.data), + size: a.size, + content_id: a.content_id.clone(), + }) + .collect(); + + // Rewrite cid: URLs in the HTML to inline data URIs so the + // sandboxed iframe can display embedded images without needing + // network access. + let resolved_html = sanitized_html.map(|mut html| { + for att in &body.attachments { + if let Some(ref cid) = att.content_id { + let cid_url = format!("cid:{cid}"); + if html.contains(&cid_url) { + use base64::Engine; + let b64 = base64::engine::general_purpose::STANDARD.encode(&att.data); + let data_uri = format!("data:{};base64,{}", att.content_type, b64); + html = html.replace(&cid_url, &data_uri); + } + } + } + html + }); + + // Serialize attachment metadata for caching. + let att_json = serde_json::to_string(&attachment_meta).ok(); + + let detected_theme = resolved_html + .as_ref() + .and_then(|h| email_theme::detect_email_theme(h)) + .map(|t| t.as_i32()); + + db::messages::cache_message_body( + &conn, + &folder, + uid, + resolved_html.as_deref(), + body.text_plain.as_deref(), + att_json.as_deref(), + Some(&body.raw_headers), + detected_theme, + ) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + + ( + resolved_html, + body.text_plain, + attachment_meta, + body.raw_headers, + detected_theme, + ) + }; + + // Get the message header from cache (use efficient single-message lookup). + // If the header hasn't been synced yet (e.g. DB was cleared and sync is + // still running), fall back to parsing the raw headers we already fetched. + let msg = db::messages::get_single_message(&conn, &folder, uid) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + + let msg = match msg { + Some(m) => m, + None => { + // Parse header fields from raw_headers so we can still return a + // useful response even when the message list hasn't been synced. + let parsed = mail_parser::MessageParser::default().parse(raw_headers.as_bytes()); + let subject = parsed + .as_ref() + .and_then(|p| p.subject().map(|s| s.to_string())) + .unwrap_or_default(); + let (from_address, from_name) = parsed + .as_ref() + .and_then(|p| p.from()) + .and_then(|addr| match addr { + mail_parser::Address::List(addrs) => addrs.first().map(|a| { + ( + a.address + .as_ref() + .map(|s| s.to_string()) + .unwrap_or_default(), + a.name.as_ref().map(|s| s.to_string()).unwrap_or_default(), + ) + }), + _ => None, + }) + .unwrap_or_default(); + let date = parsed + .as_ref() + .and_then(|p| p.date()) + .map(|d| { + let sign = if d.tz_before_gmt { '-' } else { '+' }; + format!( + "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}{}{:02}:{:02}", + d.year, + d.month, + d.day, + d.hour, + d.minute, + d.second, + sign, + d.tz_hour, + d.tz_minute + ) + }) + .unwrap_or_default(); + + db::messages::CachedMessage { + uid, + folder: folder.clone(), + message_id: parsed + .as_ref() + .and_then(|p| p.message_id().map(|s| format!("<{s}>"))), + in_reply_to: parsed + .as_ref() + .and_then(|p| p.in_reply_to().as_text().map(|s| format!("<{s}>"))), + references_header: parsed.as_ref().and_then(|p| { + let val = p.references(); + val.as_text_list() + .map(|list| { + list.iter() + .map(|s| format!("<{s}>")) + .collect::>() + .join(" ") + }) + .or_else(|| val.as_text().map(|s| format!("<{s}>"))) + }), + subject, + from_address, + from_name, + to_addresses: String::from("[]"), + cc_addresses: String::from("[]"), + date, + flags: String::new(), + size: 0, + has_attachments: !attachments.is_empty(), + snippet: String::new(), + reaction: None, + } + } + }; + + // Re-index message with full body text for search. + // Skip indexing for Spam/Junk/Trash folders. + if fetched_body + && let Some(ref text) = body_text + && !UserIndex::is_excluded_folder(&folder) + { + let indexable = IndexableMessage { + uid: msg.uid, + folder: msg.folder.clone(), + subject: msg.subject.clone(), + from_address: msg.from_address.clone(), + from_name: msg.from_name.clone(), + to_addresses: msg.to_addresses.clone(), + body_text: text.clone(), + date_epoch: crate::db::messages::parse_date_to_epoch_public(&msg.date), + has_attachments: msg.has_attachments, + }; + let search_engine = Arc::clone(&search_engine); + let user_hash = session.user_hash.clone(); + tokio::task::spawn_blocking(move || { + if let Ok(user_index) = search_engine.open_user_index(&user_hash) { + let _ = user_index.index_message(&indexable); + } + }); + } + + // Build thread using full References chain. + let thread_messages = if let Some(ref message_id) = msg.message_id { + db::messages::get_full_thread(&conn, message_id, msg.references_header.as_deref()) + .unwrap_or_default() + } else { + vec![] + }; + + let thread: Vec = thread_messages + .into_iter() + .map(|m| ThreadMessage { + uid: m.uid, + folder: m.folder, + message_id: m.message_id, + in_reply_to: m.in_reply_to, + subject: m.subject, + from_address: m.from_address, + from_name: m.from_name, + to_addresses: m.to_addresses, + cc_addresses: m.cc_addresses, + date: m.date, + flags: m.flags, + size: m.size, + has_attachments: m.has_attachments, + snippet: m.snippet, + }) + .collect(); + + Ok(Json(MessageDetailResponse { + uid: msg.uid, + folder: msg.folder.clone(), + message_id: msg.message_id.clone(), + in_reply_to: msg.in_reply_to.clone(), + references: msg.references_header.clone(), + subject: msg.subject, + from_address: msg.from_address, + from_name: msg.from_name, + to_addresses: parse_address_list(&msg.to_addresses), + cc_addresses: parse_address_list(&msg.cc_addresses), + date: msg.date, + flags: parse_flags(&msg.flags), + has_attachments: msg.has_attachments, + html: body_html, + text: body_text, + raw_headers, + attachments, + thread, + email_theme: email_theme.map(|t| match t { + 0 => EmailTheme::Light, + 1 => EmailTheme::Dark, + 2 => EmailTheme::Transparent, + 3 => EmailTheme::Adaptive, + _ => EmailTheme::Light, + }), + }) + .into_response()) +} + +/// `PATCH /api/messages/:folder/:uid/flags` +/// +/// Updates message flags on the IMAP server and in the SQLite cache. +pub async fn update_flags( + Extension(session): Extension, + Extension(config): Extension>, + Extension(imap_client): Extension>, + Path((folder, uid)): Path<(String, u32)>, + Json(body): Json, +) -> Result { + validate_flags(&body.flags)?; + + let creds = build_creds(&session, &config)?; + + // Convert flags to &str slices for the IMAP client. + let flag_refs: Vec<&str> = body.flags.iter().map(|s| s.as_str()).collect(); + + if body.add { + imap_client + .add_flags(&creds, &folder, uid, &flag_refs) + .await + .map_err(|e| AppError::ServiceUnavailable(format!("IMAP error: {e}")))?; + } else { + imap_client + .remove_flags(&creds, &folder, uid, &flag_refs) + .await + .map_err(|e| AppError::ServiceUnavailable(format!("IMAP error: {e}")))?; + } + + // Update SQLite cache: read current flags, add/remove, write back. + let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + + let current_flags_csv: String = conn + .query_row( + "SELECT flags FROM messages WHERE folder = ?1 AND uid = ?2", + rusqlite::params![&folder, uid], + |row| row.get(0), + ) + .unwrap_or_default(); + + let mut current_flags: Vec = if current_flags_csv.is_empty() { + vec![] + } else { + current_flags_csv + .split(',') + .map(|s| s.to_string()) + .collect() + }; + + if body.add { + for flag in &body.flags { + if !current_flags.contains(flag) { + current_flags.push(flag.clone()); + } + } + } else { + current_flags.retain(|f| !body.flags.contains(f)); + } + + let new_flags_csv = current_flags.join(","); + db::messages::update_message_flags(&conn, &folder, uid, &new_flags_csv) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + + // Refresh unread count after flag change. + db::folders::refresh_unread_count(&conn, &folder) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + + Ok(Json(serde_json::json!({ "status": "ok" })).into_response()) +} + +/// `POST /api/messages/move` +/// +/// Moves a message from one folder to another on the IMAP server and +/// removes it from the source folder in SQLite cache. +pub async fn move_message_handler( + Extension(session): Extension, + Extension(config): Extension>, + Extension(imap_client): Extension>, + Extension(search_engine): Extension>, + Json(body): Json, +) -> Result { + if body.from_folder == body.to_folder { + return Err(AppError::BadRequest( + "Source and destination folders must differ".to_string(), + )); + } + + let creds = build_creds(&session, &config)?; + + // Snapshot the cached source metadata before the IMAP MOVE removes the source UID. + // Do not keep a rusqlite connection alive across an await. + let source_snapshot = { + let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + db::messages::get_single_message(&conn, &body.from_folder, body.uid) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))? + }; + let was_unread = source_snapshot + .as_ref() + .map(|message| !message.flags.contains("\\Seen")) + .unwrap_or(false); + + // async-imap currently discards COPYUID from UID MOVE. Capture UIDNEXT before + // the move, then resolve the new UID from the moved message's exact Message-ID. + // If the server cannot provide enough information, the move still succeeds but + // the UI will intentionally not offer an unsafe Undo action. + let destination_uid_floor = if source_snapshot + .as_ref() + .and_then(|message| message.message_id.as_deref()) + .is_some_and(|message_id| !message_id.trim().is_empty()) + { + imap_client + .folder_status(&creds, &body.to_folder) + .await + .ok() + .map(|status| status.uid_next.max(1)) + } else { + None + }; + + // Move on IMAP server. + imap_client + .move_message(&creds, &body.from_folder, body.uid, &body.to_folder) + .await + .map_err(|e| AppError::ServiceUnavailable(format!("IMAP error: {e}")))?; + + let undo_destination_uid = if let (Some(uid_floor), Some(message_id)) = ( + destination_uid_floor, + source_snapshot + .as_ref() + .and_then(|message| message.message_id.as_deref()) + .map(str::trim) + .filter(|value| !value.is_empty()), + ) { + let uid_range = format!("{uid_floor}:*"); + match imap_client + .fetch_headers(&creds, &body.to_folder, &uid_range) + .await + { + Ok(headers) => { + let mut matches = headers + .into_iter() + .filter(|header| header.uid >= uid_floor) + .filter(|header| { + header.message_id.as_deref().map(str::trim) == Some(message_id) + }) + .map(|header| header.uid); + let first = matches.next(); + if first.is_some() && matches.next().is_none() { + first + } else { + None + } + } + Err(error) => { + tracing::warn!(?error, folder = %body.to_folder, uid_floor, "Moved message but could not resolve destination UID for Undo"); + None + } + } + } else { + None + }; + + let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + + // Delete from source folder cache. We don't keep the row in the destination + // because the UID changes after an IMAP MOVE, and a stale UID would cause + // 404s when trying to fetch the message body. + db::messages::remove_after_mutation(&conn, &body.from_folder, body.uid) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + + // Remove the old folder/UID pair from search too. Destination UIDs are + // discovered during folder sync; Spam/Junk intentionally remain unindexed. + let user_hash = session.user_hash.clone(); + let from_folder = body.from_folder.clone(); + let uid = body.uid; + let search_result = tokio::task::spawn_blocking(move || { + let index = search_engine.open_user_index(&user_hash)?; + index.delete_message(uid, &from_folder) + }) + .await; + if !matches!(search_result, Ok(Ok(()))) { + // The IMAP move already succeeded: do not trigger a false client rollback. + tracing::warn!( + ?search_result, + "Failed to remove moved message from search index" + ); + } + + // Refresh source folder unread count (now accurate since the row is gone). + db::folders::refresh_unread_count(&conn, &body.from_folder) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + + // Bump destination folder unread count if the moved message was unread. + if was_unread { + db::folders::adjust_unread_count(&conn, &body.to_folder, 1) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + } + + // Invalidate destination folder cache so the next list request forces an + // IMAP resync and picks up the moved message with its new UID. + db::folders::invalidate_folder_freshness(&conn, &body.to_folder) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + + let undo_move = undo_destination_uid.map(|destination_uid| { + serde_json::json!({ + "from_folder": body.to_folder, + "to_folder": body.from_folder, + "uid": destination_uid, + }) + }); + + Ok(Json(serde_json::json!({ "status": "ok", "undo_move": undo_move })).into_response()) +} + +/// `POST /api/messages/copy` +/// +/// Copies a message to another IMAP folder and invalidates the destination cache. +pub async fn copy_message_handler( + Extension(session): Extension, + Extension(config): Extension>, + Extension(imap_client): Extension>, + Json(body): Json, +) -> Result { + if body.from_folder == body.to_folder { + return Err(AppError::BadRequest( + "Source and destination folders must differ".to_string(), + )); + } + let creds = build_creds(&session, &config)?; + imap_client + .copy_message(&creds, &body.from_folder, body.uid, &body.to_folder) + .await + .map_err(|e| AppError::ServiceUnavailable(format!("IMAP error: {e}")))?; + + let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + db::folders::invalidate_folder_freshness(&conn, &body.to_folder) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + + Ok(Json(serde_json::json!({ "status": "ok" })).into_response()) +} + +/// `GET /api/messages/:folder/:uid/attachments/:attachment_id` +/// +/// Downloads an attachment by its index from the message. +pub async fn download_attachment( + Extension(session): Extension, + Extension(config): Extension>, + Extension(imap_client): Extension>, + Path((folder, uid, attachment_id)): Path<(String, u32, String)>, +) -> Result { + let creds = build_creds(&session, &config)?; + + // Parse the attachment index. + let index: usize = attachment_id + .parse() + .map_err(|_| AppError::BadRequest(format!("Invalid attachment id: {attachment_id}")))?; + + // Fetch the full message body from IMAP. + let body = imap_client + .fetch_body(&creds, &folder, uid) + .await + .map_err(|e| match e { + crate::imap::client::ImapError::MessageNotFound { .. } => { + AppError::NotFound(format!("Message UID {uid} not found in folder {folder}")) + } + other => AppError::ServiceUnavailable(format!("IMAP error: {other}")), + })?; + + // Find the attachment by index. + let attachment = body.attachments.into_iter().nth(index).ok_or_else(|| { + AppError::NotFound(format!( + "Attachment {attachment_id} not found on message UID {uid}" + )) + })?; + + // Build the response with appropriate headers. + let raw_filename = attachment + .filename + .unwrap_or_else(|| format!("attachment_{index}")); + // Strip ASCII control characters (U+0000-U+001F and U+007F). MIME + // decoders can surface them via quoted-pair escapes in malformed emails, + // and HeaderValue rejects those bytes. HeaderValue::from_str routes + // through is_valid (accepts 0x09, 0x20-0x7E, 0x80-0xFF; rejects + // 0x00-0x08, 0x0A-0x1F, 0x7F), so non-ASCII UTF-8 bytes are fine. + let filename: String = raw_filename + .chars() + .filter(|c| !c.is_ascii_control()) + .collect(); + // Fall back to a safe default if stripping left nothing (e.g. the + // original filename was composed entirely of control characters). + let filename = if filename.is_empty() { + format!("attachment_{index}") + } else { + filename + }; + let content_type = + preview_content_type(&attachment.content_type, Some(&filename), &attachment.data); + + // Use inline disposition for types the browser can display natively + // (PDF, images) so the preview works; use attachment for everything else. + let is_inline = content_type == "application/pdf" + || content_type.starts_with("image/") + || content_type.starts_with("text/"); + let disposition = if is_inline { + format!("inline; filename=\"{}\"", filename.replace('"', "\\\"")) + } else { + format!("attachment; filename=\"{}\"", filename.replace('"', "\\\"")) + }; + + Response::builder() + .header("content-type", &content_type) + .header("content-disposition", &disposition) + .body(axum::body::Body::from(attachment.data)) + .map_err(|e| AppError::InternalError(format!("Failed to build response: {e}"))) +} + +fn preview_content_type(content_type: &str, filename: Option<&str>, data: &[u8]) -> String { + let media_type = content_type.split(';').next().unwrap_or("").trim(); + if media_type.eq_ignore_ascii_case("application/pdf") + || (media_type.eq_ignore_ascii_case("application/octet-stream") + && filename.is_some_and(|name| name.to_ascii_lowercase().ends_with(".pdf")) + && data.starts_with(b"%PDF-")) + { + "application/pdf".to_string() + } else { + content_type.to_string() + } +} + +/// Maximum size accepted for importing a single RFC822/.eml message. +const MAX_EML_IMPORT_SIZE: usize = 50 * 1024 * 1024; + +/// `POST /api/folders/:folder/import-eml` +/// +/// Imports one raw RFC822 message into the selected IMAP folder using APPEND. +/// The server remains the source of truth: after APPEND the local folder cache +/// is invalidated so the next list request discovers the server-assigned UID. +pub async fn import_eml( + Extension(session): Extension, + Extension(config): Extension>, + Extension(imap_client): Extension>, + Extension(event_bus): Extension>, + Path(folder): Path, + mut multipart: Multipart, +) -> Result { + let field = multipart + .next_field() + .await + .map_err(|e| AppError::BadRequest(format!("Invalid multipart data: {e}")))? + .ok_or_else(|| AppError::BadRequest("No .eml file uploaded".to_string()))?; + + let filename = field.file_name().unwrap_or("message.eml").to_string(); + if !filename.to_ascii_lowercase().ends_with(".eml") { + return Err(AppError::BadRequest( + "Uploaded file must use the .eml extension".to_string(), + )); + } + + let bytes = field + .bytes() + .await + .map_err(|e| AppError::BadRequest(format!("Failed to read uploaded message: {e}")))?; + + if bytes.is_empty() { + return Err(AppError::BadRequest( + "Uploaded .eml file is empty".to_string(), + )); + } + if bytes.len() > MAX_EML_IMPORT_SIZE { + return Err(AppError::BadRequest( + ".eml file exceeds the 50 MB import limit".to_string(), + )); + } + + // Reject arbitrary blobs early. We do not rewrite or normalize the source: the + // exact uploaded RFC822 bytes are appended to IMAP after this structural check. + if mail_parser::MessageParser::default() + .parse(bytes.as_ref()) + .is_none() + { + return Err(AppError::BadRequest( + "Uploaded file is not a parseable RFC822 message".to_string(), + )); + } + + let creds = build_creds(&session, &config)?; + imap_client + .append_message(&creds, &folder, &bytes, &[]) + .await + .map_err(|e| AppError::ServiceUnavailable(format!("IMAP error: {e}")))?; + + let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + db::folders::invalidate_folder_freshness(&conn, &folder) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + + event_bus + .publish(&session.user_hash, MailEvent::FolderUpdated) + .await; + + Ok(Json(serde_json::json!({ + "status": "imported", + "folder": folder, + "filename": filename, + "size": bytes.len(), + })) + .into_response()) +} + +/// Download the complete RFC822 source as an .eml file. +pub async fn download_eml_source( + Extension(session): Extension, + Extension(config): Extension>, + Extension(imap_client): Extension>, + Path((folder, uid)): Path<(String, u32)>, +) -> Result { + let creds = build_creds(&session, &config)?; + let raw = imap_client + .fetch_raw_message(&creds, &folder, uid) + .await + .map_err(|e| AppError::ServiceUnavailable(format!("IMAP error: {e}")))?; + let filename = format!("message-{uid}.eml"); + Ok(Response::builder() + .header("content-type", "message/rfc822") + .header( + "content-disposition", + format!("attachment; filename=\"{filename}\""), + ) + .header("cache-control", "private, no-store") + .body(axum::body::Body::from(raw)) + .map_err(|e| AppError::InternalError(format!("Failed to build response: {e}")))?) +} + +#[cfg(test)] +mod preview_tests { + use super::{is_valid_flag, preview_content_type, validate_flags}; + + #[test] + fn accepts_common_system_flags_and_keywords() { + for flag in [ + "\\Seen", + "\\Answered", + "\\Flagged", + "\\Deleted", + "\\Draft", + "Junk", + "$Forwarded", + "$MDNSent", + "custom_tag-1.0+", + ] { + assert!(is_valid_flag(flag), "rejected {flag:?}"); + } + assert!(validate_flags(&[]).is_ok()); + assert!(is_valid_flag(&"a".repeat(64))); + } + + #[test] + fn rejects_unsafe_flags_and_enforces_length_limit() { + for flag in [ + "", + "\\", + "two flags", + "tag\r\nSTORE", + "tag\0", + "tag\t", + "(tag)", + "tag{1}", + "tag\\Seen", + "étiquette", + ] { + assert!(!is_valid_flag(flag), "accepted {flag:?}"); + } + assert!(!is_valid_flag(&"a".repeat(65))); + assert!(validate_flags(&["\\Seen".to_string(), "two flags".to_string()]).is_err()); + } + + #[test] + fn normalizes_pdf_media_types() { + assert_eq!( + preview_content_type("Application/PDF; name=test.pdf", None, b""), + "application/pdf" + ); + assert_eq!( + preview_content_type("application/octet-stream", Some("TEST.PDF"), b"%PDF-1.7"), + "application/pdf" + ); + } + + #[test] + fn does_not_trust_filename_alone() { + assert_eq!( + preview_content_type("application/octet-stream", Some("test.pdf"), b"not a pdf"), + "application/octet-stream" + ); + } +} + +/// `DELETE /api/messages/:folder/:uid` +/// +/// Permanently removes a message from the IMAP server and SQLite cache. +pub async fn delete_message_handler( + Extension(session): Extension, + Extension(config): Extension>, + Extension(imap_client): Extension>, + Path((folder, uid)): Path<(String, u32)>, +) -> Result { + let creds = build_creds(&session, &config)?; + + // Expunge on IMAP server. + imap_client + .expunge_message(&creds, &folder, uid) + .await + .map_err(|e| AppError::ServiceUnavailable(format!("IMAP error: {e}")))?; + + // Delete from SQLite cache. + let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + db::messages::remove_after_mutation(&conn, &folder, uid) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + + // Refresh unread count for folder. + db::folders::refresh_unread_count(&conn, &folder) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + + Ok(Json(serde_json::json!({ "status": "ok" })).into_response()) +} + +#[cfg(test)] +mod sync_tests { + use super::*; + use crate::imap::client::mock::MockImapClient; + + fn sync_header(uid: u32) -> crate::imap::types::ImapMessageHeader { + crate::imap::types::ImapMessageHeader { + uid, + subject: Some(format!("Message {uid}")), + from: Vec::new(), + to: Vec::new(), + cc: Vec::new(), + date: None, + flags: Vec::new(), + has_attachments: false, + size: 42, + message_id: None, + in_reply_to: None, + references: None, + reaction: None, + } + } + + #[tokio::test] + async fn fetched_background_headers_cannot_resurrect_completed_move() { + fetched_headers_after_removal(true).await; + } + + #[tokio::test] + async fn fetched_background_headers_cannot_resurrect_completed_delete() { + fetched_headers_after_removal(false).await; + } + + async fn fetched_headers_after_removal(move_message: bool) { + let data_dir = tempfile::tempdir().unwrap(); + let path = data_dir.path().to_str().unwrap(); + let user_hash = "header-race"; + let conn = db::pool::open_user_db(path, user_hash).unwrap(); + for folder in ["INBOX", "Archive"] { + db::folders::upsert_folder(&conn, folder, None, None, "", true, 1, 1, 1, 0).unwrap(); + } + let search_engine = Arc::new(SearchEngine::new(data_dir.path().to_path_buf())); + let header_sync = HeaderSync::capture(&conn).unwrap(); + let transaction = header_sync.begin_write(&conn).unwrap(); + upsert_and_index_headers( + &transaction, + "INBOX", + &[sync_header(7)], + &search_engine, + user_hash, + ) + .unwrap(); + transaction.commit().unwrap(); + drop(conn); + + let creds = ImapCredentials { + host: "localhost".into(), + port: 143, + tls: false, + email: "test@example.com".into(), + password: "test".into(), + }; + let config = Arc::new(AppConfig { + imap_host: Some(creds.host.clone()), + data_dir: path.to_owned(), + ..serde_json::from_str::("{}").unwrap() + }); + let session = SessionState { + account_id: "test".into(), + email: creds.email.clone(), + password: creds.password.clone(), + user_hash: user_hash.into(), + imap_host: creds.host.clone(), + imap_port: creds.port, + imap_tls: false, + smtp_host: "localhost".into(), + smtp_port: 587, + smtp_tls: false, + last_accessed: std::time::Instant::now(), + timeout_override: None, + }; + let mut client = MockImapClient::new().with_headers(vec![sync_header(7), sync_header(8)]); + client.header_fetch_gate = Some((tokio::sync::Notify::new(), tokio::sync::Notify::new())); + let client = Arc::new(client); + let gate = client.header_fetch_gate.as_ref().unwrap(); + let event_bus = Arc::new(EventBus::new()); + let mut events = event_bus.subscribe(user_hash).await; + let sync = tokio::spawn(sync_remaining_headers(BackgroundSyncParams { + creds, + folder: "INBOX".into(), + imap_client: client.clone(), + config: config.clone(), + user_hash: user_hash.into(), + search_engine: search_engine.clone(), + event_bus, + uid_range: (1, 100), + header_sync, + uid_validity: 1, + exists: 1, + })); + + // The IMAP batch has already been snapshotted. Complete the real handler + // before allowing the background writer to see those stale headers. + gate.0.notified().await; + let imap_client: Arc = client.clone(); + let response = if move_message { + move_message_handler( + Extension(session), + Extension(config), + Extension(imap_client), + Extension(search_engine.clone()), + Json(MoveMessageRequest { + uid: 7, + from_folder: "INBOX".into(), + to_folder: "Archive".into(), + }), + ) + .await + .unwrap() + } else { + delete_message_handler( + Extension(session), + Extension(config), + Extension(imap_client), + Path(("INBOX".into(), 7)), + ) + .await + .unwrap() + }; + assert_eq!(response.status(), axum::http::StatusCode::OK); + gate.1.notify_one(); + sync.await.unwrap(); + + let conn = db::pool::open_user_db(path, user_hash).unwrap(); + assert!( + db::messages::get_single_message(&conn, "INBOX", 7) + .unwrap() + .is_none() + ); + assert!(db::folders::is_folder_messages_invalidated(&conn, "INBOX").unwrap()); + assert!( + matches!(events.try_recv().unwrap(), MailEvent::FolderUpdated), + "superseded sync must request fresh missing-UID reconciliation" + ); + assert!(events.try_recv().is_err()); + // A move's index deletion also must not be undone by rejected headers. + if move_message { + let index = search_engine.open_user_index(user_hash).unwrap(); + assert!( + index + .search(&crate::search::engine::SearchQuery { + text: "Message".into(), + limit: 10, + ..Default::default() + }) + .unwrap() + .0 + .is_empty() + ); + } + + // New destination UIDs (even the same numeric UID in another folder) + // remain valid, as do future arrivals in the source folder. + let next_sync = HeaderSync::capture(&conn).unwrap(); + let transaction = next_sync.begin_write(&conn).unwrap(); + upsert_and_index_headers( + &transaction, + "Archive", + &[sync_header(7), sync_header(42)], + &search_engine, + user_hash, + ) + .unwrap(); + upsert_and_index_headers( + &transaction, + "INBOX", + &[sync_header(8)], + &search_engine, + user_hash, + ) + .unwrap(); + transaction.commit().unwrap(); + assert_eq!(db::messages::count_messages(&conn, "Archive").unwrap(), 2); + assert!( + db::messages::get_single_message(&conn, "INBOX", 8) + .unwrap() + .is_some() + ); + } + + #[tokio::test] + async fn interrupted_initial_backfill_recovers_after_same_folder_delete() { + interrupted_backfill_recovers(false).await; + } + + #[tokio::test] + async fn interrupted_initial_backfill_recovers_after_unrelated_folder_delete() { + interrupted_backfill_recovers(true).await; + } + + async fn interrupted_backfill_recovers(other_folder: bool) { + let data_dir = tempfile::tempdir().unwrap(); + let path = data_dir.path().to_str().unwrap(); + let user_hash = "backfill-recovery"; + let conn = db::pool::open_user_db(path, user_hash).unwrap(); + for folder in ["INBOX", "Archive"] { + db::folders::upsert_folder(&conn, folder, None, None, "", true, 1000, 0, 1, 0).unwrap(); + } + let engine = Arc::new(SearchEngine::new(data_dir.path().to_path_buf())); + let header_sync = HeaderSync::capture(&conn).unwrap(); + let transaction = header_sync.begin_write(&conn).unwrap(); + upsert_and_index_headers( + &transaction, + "INBOX", + &(901..=1000).map(sync_header).collect::>(), + &engine, + user_hash, + ) + .unwrap(); + upsert_and_index_headers( + &transaction, + "Archive", + &[sync_header(950)], + &engine, + user_hash, + ) + .unwrap(); + db::folders::update_folder_status(&conn, "INBOX", 1, 1000).unwrap(); + transaction.commit().unwrap(); + drop(conn); + let creds = ImapCredentials { + host: "localhost".into(), + port: 143, + tls: false, + email: "test@example.com".into(), + password: "test".into(), + }; + let config = Arc::new(AppConfig { + imap_host: Some(creds.host.clone()), + data_dir: path.to_owned(), + ..serde_json::from_str::("{}").unwrap() + }); + let session = SessionState { + account_id: "test".into(), + email: creds.email.clone(), + password: creds.password.clone(), + user_hash: user_hash.into(), + imap_host: creds.host.clone(), + imap_port: 143, + imap_tls: false, + smtp_host: "localhost".into(), + smtp_port: 587, + smtp_tls: false, + last_accessed: std::time::Instant::now(), + timeout_override: None, + }; + let mut client = MockImapClient::new().with_headers((1..=900).map(sync_header).collect()); + client.header_fetch_gate = Some((tokio::sync::Notify::new(), tokio::sync::Notify::new())); + let client = Arc::new(client); + let gate = client.header_fetch_gate.as_ref().unwrap(); + let bus = Arc::new(EventBus::new()); + let mut events = bus.subscribe(user_hash).await; + let sync = tokio::spawn(sync_remaining_headers(BackgroundSyncParams { + creds, + folder: "INBOX".into(), + imap_client: client.clone(), + config: config.clone(), + user_hash: user_hash.into(), + search_engine: engine.clone(), + event_bus: bus.clone(), + uid_range: (1, 900), + header_sync, + uid_validity: 1, + exists: 1000, + })); + gate.0.notified().await; + let folder = if other_folder { "Archive" } else { "INBOX" }; + delete_message_handler( + Extension(session.clone()), + Extension(config.clone()), + Extension(client.clone() as Arc), + Path((folder.into(), 950)), + ) + .await + .unwrap(); + gate.1.notify_one(); + sync.await.unwrap(); + let conn = db::pool::open_user_db(path, user_hash).unwrap(); + assert!(db::folders::is_folder_messages_invalidated(&conn, "INBOX").unwrap()); + assert!( + db::messages::get_single_message(&conn, "INBOX", 1) + .unwrap() + .is_none() + ); + drop(conn); + assert!(matches!( + events.recv().await.unwrap(), + MailEvent::FolderUpdated + )); + + // The next actual list request must recover UIDs below max_cached_uid. + // A fresh server snapshot omits only the UID deleted in this folder. + let remaining = (1..=1000) + .filter(|uid| other_folder || *uid != 950) + .map(sync_header) + .collect::>(); + let fresh_client = Arc::new(MockImapClient::new().with_headers(remaining)); + let response = list_messages( + Extension(session), + Extension(config), + Extension(fresh_client.clone() as Arc), + Extension(engine.clone()), + Extension(bus), + Path("INBOX".into()), + Query(ListMessagesQuery { + page: 0, + per_page: 50, + sort: "date_desc".to_string(), + }), + ) + .await + .unwrap(); + assert_eq!(response.status(), axum::http::StatusCode::OK); + events.recv().await.unwrap(); + let conn = db::pool::open_user_db(path, user_hash).unwrap(); + assert_eq!( + db::messages::count_messages(&conn, "INBOX").unwrap(), + if other_folder { 1000 } else { 999 } + ); + assert!( + db::messages::get_single_message(&conn, "INBOX", 1) + .unwrap() + .is_some() + ); + assert!( + db::messages::get_single_message(&conn, "INBOX", 900) + .unwrap() + .is_some() + ); + assert!( + db::messages::get_single_message(&conn, folder, 950) + .unwrap() + .is_none() + ); + assert!(db::folders::is_folder_fresh(&conn, "INBOX", 30).unwrap()); + let requests = fresh_client.header_requests.lock().unwrap(); + assert_eq!(requests.len(), 4); + assert!(requests.iter().all(|(_, uids)| { + uids.split(',') + .all(|uid| uid.parse::().unwrap() <= 900) + })); + let results = engine + .open_user_index(user_hash) + .unwrap() + .search(&crate::search::engine::SearchQuery { + text: "Message".into(), + folder: Some("INBOX".into()), + limit: 1100, + ..Default::default() + }) + .unwrap() + .0; + assert!(results.iter().any(|message| message.uid == 1)); + } + + #[test] + fn uidvalidity_reset_rejects_old_batch_but_allows_reused_uid() { + let data_dir = tempfile::tempdir().unwrap(); + let conn = db::pool::open_test_db(); + db::folders::upsert_folder(&conn, "INBOX", None, None, "", true, 1, 1, 1, 0).unwrap(); + let engine = Arc::new(SearchEngine::new(data_dir.path().to_path_buf())); + let stale = HeaderSync::capture(&conn).unwrap(); + db::messages::remove_after_mutation(&conn, "INBOX", 7).unwrap(); + let before_reset = HeaderSync::capture(&conn).unwrap(); + let transaction = before_reset.begin_write(&conn).unwrap(); + db::messages::delete_folder_messages(&conn, "INBOX").unwrap(); + db::header_sync::invalidate(&transaction).unwrap(); + db::folders::update_folder_sync_status(&conn, "INBOX", 2, 1, 0).unwrap(); + let new_namespace = HeaderSync::capture(&conn).unwrap(); + transaction.commit().unwrap(); + assert!(stale.begin_write(&conn).is_err()); + assert!(before_reset.begin_write(&conn).is_err()); + let transaction = new_namespace.begin_write(&conn).unwrap(); + upsert_and_index_headers(&transaction, "INBOX", &[sync_header(7)], &engine, "test") + .unwrap(); + transaction.commit().unwrap(); + assert!( + db::messages::get_single_message(&conn, "INBOX", 7) + .unwrap() + .is_some() + ); + assert_eq!( + db::folders::get_folder(&conn, "INBOX") + .unwrap() + .unwrap() + .uid_validity, + 2 + ); + } + + #[test] + fn chunks_bound_existing_messages_even_with_sparse_uids() { + assert_eq!(header_chunks(&[]).count(), 0); + let sparse = [1, 10_000_000, u32::MAX]; + assert_eq!( + header_chunks(&sparse).collect::>(), + vec!["1,10000000,4294967295"] + ); + + let uids: Vec<_> = (1..=501).map(|uid| uid * 1_000_000).collect(); + let chunks: Vec<_> = header_chunks(&uids).collect(); + assert_eq!(chunks.len(), 3); + assert_eq!(chunks[0].split(',').count(), 250); + assert_eq!(chunks[1].split(',').count(), 250); + assert_eq!(chunks[2].split(',').count(), 1); + let fetched: Vec = chunks + .iter() + .flat_map(|chunk| chunk.split(',').map(|uid| uid.parse().unwrap())) + .collect(); + assert_eq!(fetched, uids); + } + + #[tokio::test] + async fn empty_background_sync_records_completion() { + // Both an exhausted range and an empty server listing are successful syncs. + for uid_range in [(1, 0), (1, 100)] { + let data_dir = tempfile::tempdir().unwrap(); + let path = data_dir.path().to_str().unwrap(); + let user_hash = "empty-sync"; + let conn = db::pool::open_user_db(path, user_hash).unwrap(); + db::folders::upsert_folder(&conn, "Sent", None, None, "", true, 99, 7, 1, 0).unwrap(); + db::folders::invalidate_folder_freshness(&conn, "Sent").unwrap(); + assert!(!db::folders::is_folder_fresh(&conn, "Sent", 30).unwrap()); + let header_sync = HeaderSync::capture(&conn).unwrap(); + drop(conn); + + let client = Arc::new(MockImapClient::new()); + let config = Arc::new(AppConfig { + data_dir: path.to_owned(), + ..serde_json::from_str::("{}").unwrap() + }); + let event_bus = Arc::new(EventBus::new()); + let mut events = event_bus.subscribe(user_hash).await; + + sync_remaining_headers(BackgroundSyncParams { + creds: ImapCredentials { + host: "localhost".into(), + port: 143, + tls: false, + email: "test@example.com".into(), + password: "test".into(), + }, + folder: "Sent".into(), + imap_client: client.clone(), + config, + user_hash: user_hash.into(), + search_engine: Arc::new(SearchEngine::new(data_dir.path().to_path_buf())), + event_bus, + uid_range, + header_sync, + uid_validity: 42, + exists: 0, + }) + .await; + + let conn = db::pool::open_user_db(path, user_hash).unwrap(); + let folder = db::folders::get_folder(&conn, "Sent").unwrap().unwrap(); + assert_eq!(folder.uid_validity, 42); + assert_eq!(folder.total_count, 0); + assert_eq!(folder.unread_count, 0); + assert!(db::folders::is_folder_fresh(&conn, "Sent", 30).unwrap()); + assert!(!db::folders::is_folder_messages_invalidated(&conn, "Sent").unwrap()); + assert!(client.header_requests.lock().unwrap().is_empty()); + assert!(matches!( + events.try_recv().unwrap(), + MailEvent::FolderUpdated + )); + assert!(events.try_recv().is_err()); + } + } + + #[tokio::test] + async fn sparse_background_sync_fetches_existing_uids_in_one_batch() { + let data_dir = tempfile::tempdir().unwrap(); + let path = data_dir.path().to_str().unwrap(); + let user_hash = "sparse-sync"; + let conn = db::pool::open_user_db(path, user_hash).unwrap(); + db::folders::upsert_folder(&conn, "INBOX", None, None, "", true, 0, 0, 1, 0).unwrap(); + let header_sync = HeaderSync::capture(&conn).unwrap(); + drop(conn); + let headers = [1, 9_999_899, 10_000_000].map(|uid| crate::imap::types::ImapMessageHeader { + uid, + subject: Some(format!("Message {uid}")), + from: Vec::new(), + to: Vec::new(), + cc: Vec::new(), + date: None, + flags: Vec::new(), + has_attachments: false, + size: 42, + message_id: None, + in_reply_to: None, + references: None, + reaction: None, + }); + let client = Arc::new(MockImapClient::new().with_headers(headers.to_vec())); + let config = Arc::new(AppConfig { + data_dir: path.to_owned(), + ..serde_json::from_str::("{}").unwrap() + }); + let event_bus = Arc::new(EventBus::new()); + let mut events = event_bus.subscribe(user_hash).await; + + sync_remaining_headers(BackgroundSyncParams { + creds: ImapCredentials { + host: "localhost".into(), + port: 143, + tls: false, + email: "test@example.com".into(), + password: "test".into(), + }, + folder: "INBOX".into(), + imap_client: client.clone(), + config, + user_hash: user_hash.into(), + search_engine: Arc::new(SearchEngine::new(data_dir.path().to_path_buf())), + event_bus, + uid_range: (1, 9_999_900), + header_sync, + uid_validity: 1, + exists: 3, + }) + .await; + + assert_eq!( + *client.header_requests.lock().unwrap(), + vec![("INBOX".into(), "1,9999899".into())] + ); + assert!(matches!( + events.try_recv().unwrap(), + MailEvent::FolderUpdated + )); + let conn = db::pool::open_user_db(path, user_hash).unwrap(); + assert!( + db::messages::get_single_message(&conn, "INBOX", 1) + .unwrap() + .is_some() + ); + assert!( + db::messages::get_single_message(&conn, "INBOX", 9_999_899) + .unwrap() + .is_some() + ); + } +} diff --git a/src/routes/messages/types.rs b/src/routes/messages/types.rs new file mode 100644 index 0000000..b8828c2 --- /dev/null +++ b/src/routes/messages/types.rs @@ -0,0 +1,152 @@ +use serde::{Deserialize, Serialize}; + +pub use crate::email_theme::EmailTheme; + +/// Default page size for paginated list queries. +pub fn default_per_page() -> u32 { + 50 +} + +pub fn default_sort() -> String { + "date_desc".to_string() +} + +/// Query parameters for `GET /api/folders/:folder/messages`. +#[derive(Deserialize)] +pub(crate) struct ListMessagesQuery { + #[serde(default)] + pub(crate) page: u32, + #[serde(default = "default_per_page")] + pub(crate) per_page: u32, + #[serde(default = "default_sort")] + pub(crate) sort: String, +} + +/// Request body for `PATCH /api/messages/:folder/:uid/flags`. +#[derive(Deserialize)] +pub(crate) struct UpdateFlagsRequest { + pub(crate) flags: Vec, + pub(crate) add: bool, +} + +/// Request body for `POST /api/messages/move`. +#[derive(Deserialize)] +pub(crate) struct MoveMessageRequest { + pub(crate) from_folder: String, + pub(crate) to_folder: String, + pub(crate) uid: u32, +} + +#[derive(Deserialize)] +pub(crate) struct CopyMessageRequest { + pub(crate) from_folder: String, + pub(crate) to_folder: String, + pub(crate) uid: u32, +} + +/// Response envelope for `GET /api/folders/:folder/messages`. +#[derive(Serialize)] +pub(crate) struct ListMessagesResponse { + pub(crate) messages: Vec, + pub(crate) total_count: u32, + pub(crate) page: u32, + pub(crate) per_page: u32, + pub(crate) syncing: bool, + pub(crate) flagged_on_top: bool, +} + +/// A message summary in the list response. +#[derive(Serialize, Clone)] +pub struct MessageSummary { + pub uid: u32, + pub folder: String, + pub subject: String, + pub from_address: String, + pub from_name: String, + pub to_addresses: String, + pub date: String, + pub flags: String, + pub size: u32, + pub has_attachments: bool, + pub snippet: String, + pub reaction: Option, + pub tags: Vec, +} + +/// An email address entry for the detail response. +#[derive(Serialize, Deserialize, Clone)] +pub(crate) struct AddressEntry { + pub(crate) name: Option, + pub(crate) address: String, +} + +/// Parse a JSON-encoded address list string (e.g. from the SQLite cache) into +/// a `Vec`. Returns an empty vec on parse failure. +pub(crate) fn parse_address_list(json_str: &str) -> Vec { + serde_json::from_str(json_str).unwrap_or_default() +} + +/// Split a comma-separated flags string into a `Vec`. +pub(crate) fn parse_flags(flags_csv: &str) -> Vec { + if flags_csv.is_empty() { + return vec![]; + } + flags_csv + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect() +} + +/// Response for `GET /api/messages/:folder/:uid`. +#[derive(Serialize)] +pub(crate) struct MessageDetailResponse { + pub(crate) uid: u32, + pub(crate) folder: String, + pub(crate) message_id: Option, + pub(crate) in_reply_to: Option, + pub(crate) references: Option, + pub(crate) subject: String, + pub(crate) from_address: String, + pub(crate) from_name: String, + pub(crate) to_addresses: Vec, + pub(crate) cc_addresses: Vec, + pub(crate) date: String, + pub(crate) flags: Vec, + pub(crate) has_attachments: bool, + pub(crate) html: Option, + pub(crate) text: Option, + pub(crate) raw_headers: String, + pub(crate) attachments: Vec, + pub(crate) thread: Vec, + pub(crate) email_theme: Option, +} + +/// A message summary within a thread. +#[derive(Serialize)] +pub(crate) struct ThreadMessage { + pub(crate) uid: u32, + pub(crate) folder: String, + pub(crate) message_id: Option, + pub(crate) in_reply_to: Option, + pub(crate) subject: String, + pub(crate) from_address: String, + pub(crate) from_name: String, + pub(crate) to_addresses: String, + pub(crate) cc_addresses: String, + pub(crate) date: String, + pub(crate) flags: String, + pub(crate) size: u32, + pub(crate) has_attachments: bool, + pub(crate) snippet: String, +} + +/// Attachment metadata (without the binary data). +#[derive(Serialize, Deserialize)] +pub(crate) struct AttachmentMeta { + pub(crate) id: String, + pub(crate) filename: Option, + pub(crate) content_type: String, + pub(crate) size: usize, + pub(crate) content_id: Option, +} diff --git a/src/routes/mod.rs b/src/routes/mod.rs new file mode 100644 index 0000000..7095c27 --- /dev/null +++ b/src/routes/mod.rs @@ -0,0 +1,501 @@ +pub mod attachments; +pub mod auth; +pub mod calendar; +pub mod contact_groups; +pub mod contacts; +pub mod diagnostics; +pub mod display_preferences; +pub mod drafts; +pub mod folder_mgmt; +pub mod folder_preferences; +pub mod folders; +pub mod health; +pub mod identities; +pub mod mail_preferences; +pub mod messages; +pub mod notification_preferences; +pub mod pgp; +pub mod quota; +pub mod search; +pub mod send; +pub mod sieve; +pub mod special_folders; +pub mod tags; +pub mod unsubscribe; + +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; +use std::sync::Arc; +use std::time::Duration; + +use axum::extract::{ConnectInfo, DefaultBodyLimit}; +use axum::http::Request; +use axum::routing::{delete, get, patch, post, put}; +use axum::{Extension, Router, middleware}; +use tower_governor::GovernorError; +use tower_governor::GovernorLayer; +use tower_governor::governor::GovernorConfigBuilder; +use tower_governor::key_extractor::KeyExtractor; +use tower_http::trace::TraceLayer; + +use crate::auth::csrf::csrf_protection; +use crate::auth::middleware::auth_guard; +use crate::auth::session::SessionStore; +use crate::config::{AppConfig, TrustedProxy}; +use crate::imap::client::ImapClient; +use crate::realtime::events::EventBus; +use crate::realtime::idle::IdleManager; +use crate::smtp::client::SmtpClient; + +/// Per-IP key extractor that supports trusted reverse proxies. +/// +/// When the direct peer IP is in the configured `trusted_proxies` list, +/// `X-Forwarded-For` is walked from right to left. Trusted proxy hops on the +/// right are skipped and the nearest untrusted address becomes the client IP. +/// If the header is missing or malformed, the direct peer IP is used. Falls +/// back to loopback when `ConnectInfo` is unavailable (e.g. in +/// unit tests). +#[derive(Debug, Clone)] +struct ProxyAwareIpExtractor { + trusted_proxies: Vec, +} + +impl ProxyAwareIpExtractor { + fn is_trusted(&self, ip: &IpAddr) -> bool { + self.trusted_proxies + .iter() + .any(|trusted| trusted.contains(ip)) + } + + fn forwarded_client_ip( + &self, + headers: &axum::http::HeaderMap, + peer_ip: IpAddr, + ) -> Option { + let mut current_hop = peer_ip; + + for value in headers.get_all("x-forwarded-for").iter().rev() { + for entry in value.to_str().ok()?.split(',').rev() { + if !self.is_trusted(¤t_hop) { + return Some(current_hop); + } + + current_hop = entry.trim().parse::().ok()?; + } + } + + Some(current_hop) + } +} + +impl KeyExtractor for ProxyAwareIpExtractor { + type Key = IpAddr; + + fn extract(&self, req: &Request) -> Result { + let peer_ip = req + .extensions() + .get::>() + .map(|ci| ci.0.ip()) + .unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST)); + + if self.is_trusted(&peer_ip) + && let Some(client_ip) = self.forwarded_client_ip(req.headers(), peer_ip) + { + return Ok(client_ip); + } + + Ok(peer_ip) + } +} + +/// Assembles all application routes into an Axum Router. +/// +/// Route layout: +/// - `GET /api/health` / `/api/ready` — liveness/readiness (public) +/// - `POST /api/auth/login` — login (public, CSRF only) +/// - `GET /api/auth/session` — get session (auth_guard + CSRF) +/// - `POST /api/auth/logout` — logout (auth_guard + CSRF) +/// - `GET /api/folders` — list folders (auth_guard + CSRF) +/// - `GET /api/folders/:folder/messages` — list messages (auth_guard + CSRF) +/// - `GET /api/messages/:folder/:uid` — get message detail (auth_guard + CSRF) +/// - `PATCH /api/messages/:folder/:uid/flags` — update flags (auth_guard + CSRF) +/// - `GET /api/messages/:folder/:uid/attachments/:attachment_id` — download attachment (auth_guard + CSRF) +/// - `POST /api/messages/move` — move message (auth_guard + CSRF) +/// - `DELETE /api/messages/:folder/:uid` — delete message (auth_guard + CSRF) +/// +/// All non-API paths are served by the embedded RustBox UI. +/// The UI assets are compiled into the same executable; there is no frontend runtime. +/// +/// Middleware layers: +/// - tower-http tracing +/// - CSRF protection on auth routes +/// - auth_guard on protected routes +pub fn create_router( + config: Arc, + store: Arc, + imap_client: Arc, + smtp_client: Arc, + search_engine: Arc, + event_bus: Arc, + idle_manager: Arc, +) -> Router { + // Rate-limit login: replenish 1 token every 12 s, burst of 5. + let governor_conf = GovernorConfigBuilder::default() + .key_extractor(ProxyAwareIpExtractor { + trusted_proxies: config.parsed_trusted_proxies(), + }) + .period(Duration::from_secs(12)) + .burst_size(5) + .finish() + .expect("valid governor config"); + + // Public auth route: GovernorLayer (outermost) -> CSRF -> handler. + let public_auth = Router::new() + .route("/login", post(auth::login)) + .route("/policy", get(auth::auth_policy)) + .layer(middleware::from_fn(csrf_protection)) + .layer(GovernorLayer::new(governor_conf)); + + // Browser-bound routes (no auth_guard, no rate limit). + // These routes only need the browser cookie, not full auth. + let browser_routes = Router::new() + .route("/accounts", get(auth::list_accounts)) + .route( + "/accounts/{id}", + delete(auth::remove_account).layer(middleware::from_fn(csrf_protection)), + ); + + // Protected auth routes (auth_guard + CSRF). + let protected_auth = Router::new() + .route("/session", get(auth::get_session)) + .route("/sessions", get(auth::list_active_sessions)) + .route("/sessions/revoke-others", post(auth::revoke_other_sessions)) + .route("/sessions/{id}", delete(auth::revoke_active_session)) + .route("/logout", post(auth::logout)) + .layer(middleware::from_fn(auth_guard)) + .layer(middleware::from_fn(csrf_protection)); + + let auth_router = Router::new() + .merge(public_auth) + .merge(browser_routes) + .merge(protected_auth); + + // Protected data routes (auth_guard + CSRF). + let protected_data = Router::new() + .route( + "/folders", + get(folders::list_folders).post(folder_mgmt::create_folder), + ) + .route( + "/folders/{name}", + patch(folder_mgmt::rename_folder).delete(folder_mgmt::delete_folder), + ) + .route( + "/folders/{name}/subscribe", + patch(folder_mgmt::subscribe_folder), + ) + .route( + "/folders/{name}/mark-all-read", + post(folder_mgmt::mark_all_read), + ) + .route("/folders/{name}/empty", post(folder_mgmt::empty_folder)) + .route( + "/folders/{folder}/import-eml", + post(messages::import_eml).layer(DefaultBodyLimit::max(52 * 1024 * 1024)), + ) + .route("/folders/{folder}/messages", get(messages::list_messages)) + .route( + "/messages/{folder}/{uid}", + get(messages::get_message).delete(messages::delete_message_handler), + ) + .route( + "/messages/{folder}/{uid}/flags", + patch(messages::update_flags), + ) + .route( + "/messages/{folder}/{uid}/attachments/{attachment_id}", + get(messages::download_attachment), + ) + .route( + "/messages/{folder}/{uid}/source", + get(messages::download_eml_source), + ) + .route("/messages/move", post(messages::move_message_handler)) + .route("/messages/copy", post(messages::copy_message_handler)) + .route("/messages/send", post(send::send_message_handler)) + .route( + "/messages/{folder}/{uid}/unsubscribe", + post(unsubscribe::unsubscribe_message), + ) + .route( + "/drafts", + get(drafts::list_drafts_handler).post(drafts::upsert_draft_handler), + ) + .route( + "/drafts/{id}", + get(drafts::get_draft_handler).delete(drafts::delete_draft_handler), + ) + .route( + "/drafts/{draft_id}/attachments", + post(attachments::upload_attachment).layer(DefaultBodyLimit::max(30 * 1024 * 1024)), + ) + .route( + "/drafts/{draft_id}/attachments/{attachment_id}", + delete(attachments::delete_attachment), + ) + .route( + "/drafts/{draft_id}/attachments/{attachment_id}/content", + get(attachments::get_attachment_content), + ) + .route("/search", get(search::search_messages)) + .route( + "/search/saved", + get(search::list_saved_searches).post(search::create_saved_search), + ) + .route( + "/search/saved/{id}", + axum::routing::put(search::update_saved_search).delete(search::delete_saved_search), + ) + .route( + "/contact-groups", + get(contact_groups::list_groups_handler).post(contact_groups::create_group_handler), + ) + .route( + "/contact-groups/{id}", + put(contact_groups::update_group_handler).delete(contact_groups::delete_group_handler), + ) + .route( + "/contact-groups/{id}/members", + get(contact_groups::list_members_handler).post(contact_groups::add_member_handler), + ) + .route( + "/contact-groups/{id}/members/{contact_id}", + delete(contact_groups::remove_member_handler), + ) + .route( + "/tags", + get(tags::list_tags_handler).post(tags::create_tag_handler), + ) + .route("/tags/reconcile", post(tags::reconcile_tags_handler)) + .route( + "/tags/{id}", + put(tags::update_tag_handler).delete(tags::delete_tag_handler), + ) + .route( + "/tags/{id}/messages", + post(tags::tag_message_handler).get(tags::list_tag_messages_handler), + ) + .route("/tags/{id}/messages/bulk", post(tags::bulk_tag_handler)) + .route( + "/tags/{id}/messages/{folder}/{uid}", + delete(tags::untag_message_handler), + ) + .route( + "/messages/{folder}/{uid}/tags", + get(tags::get_message_tags_handler), + ) + .route( + "/contacts", + get(contacts::list_contacts_handler).post(contacts::create_contact_handler), + ) + .route( + "/contacts/{id}/photo", + get(contacts::get_contact_photo_handler) + .post(contacts::upload_contact_photo_handler) + .delete(contacts::delete_contact_photo_handler) + .layer(DefaultBodyLimit::max(3 * 1024 * 1024)), + ) + .route( + "/contacts/duplicates", + get(contacts::duplicate_contacts_handler), + ) + .route("/contacts/merge", post(contacts::merge_contacts_handler)) + .route("/contacts/export", get(contacts::export_contacts_handler)) + .route( + "/contacts/import", + post(contacts::import_contacts_handler).layer(DefaultBodyLimit::max(11 * 1024 * 1024)), + ) + .route( + "/contacts/autocomplete/all", + get(contacts::autocomplete_all_handler), + ) + .route( + "/contacts/autocomplete", + get(contacts::autocomplete_handler), + ) + .route( + "/contacts/{id}", + get(contacts::get_contact_handler).delete(contacts::delete_contact_handler), + ) + .route( + "/contacts/{contact_id}/groups", + get(contact_groups::list_contact_groups_handler), + ) + .route( + "/contacts/{id}/export", + get(contacts::export_single_contact_handler), + ) + .route( + "/identities", + get(identities::list_identities_handler) + .post(identities::create_identity_handler) + .layer(DefaultBodyLimit::max(4 * 1024 * 1024)), + ) + .route( + "/identities/{id}", + get(identities::get_identity_handler) + .put(identities::update_identity_handler) + .delete(identities::delete_identity_handler) + .layer(DefaultBodyLimit::max(4 * 1024 * 1024)), + ) + .route( + "/pgp/keys", + get(pgp::list_keys) + .post(pgp::import_key) + .layer(DefaultBodyLimit::max(3 * 1024 * 1024)), + ) + .route("/pgp/keys/generate", post(pgp::generate_key)) + .route("/pgp/keys/{fingerprint}/export", get(pgp::export_key)) + .route( + "/pgp/keys/{fingerprint}/test-passphrase", + post(pgp::test_passphrase), + ) + .route( + "/pgp/keys/{fingerprint}/change-passphrase", + post(pgp::change_passphrase), + ) + .route("/pgp/keys/{fingerprint}/trust", patch(pgp::set_trust)) + .route("/pgp/keys/{fingerprint}", delete(pgp::delete_key)) + .route( + "/pgp/preferences", + get(pgp::get_preferences).put(pgp::update_preferences), + ) + .route("/pgp/discover", post(pgp::discover_key)) + .route("/pgp/recipients", post(pgp::recipient_status)) + .route("/pgp/verify", post(pgp::verify)) + .route( + "/pgp/decrypt", + post(pgp::decrypt).layer(DefaultBodyLimit::max(12 * 1024 * 1024)), + ) + .route( + "/pgp/messages/{folder}/{uid}/process", + post(pgp::process_message), + ) + .route( + "/settings/display", + get(display_preferences::get_display_preferences) + .put(display_preferences::update_display_preferences), + ) + .route( + "/settings/special-folders", + get(special_folders::get_special_folders).put(special_folders::update_special_folders), + ) + .route( + "/settings/notifications", + get(notification_preferences::get_notification_preferences) + .put(notification_preferences::update_notification_preferences), + ) + .route( + "/settings/mailbox", + get(mail_preferences::get_mail_preferences) + .put(mail_preferences::update_mail_preferences), + ) + .route( + "/folders/{folder}/preferences", + get(folder_preferences::get_folder_preferences) + .put(folder_preferences::update_folder_preferences), + ) + .route( + "/calendar/events", + get(calendar::list_events).post(calendar::create_event), + ) + .route( + "/calendar/events/import-ics", + post(calendar::import_ics).layer(DefaultBodyLimit::max(11 * 1024 * 1024)), + ) + .route("/calendar/events/export-ics", get(calendar::export_ics)) + .route( + "/calendar/events/{id}", + get(calendar::get_event) + .put(calendar::update_event) + .delete(calendar::delete_event), + ) + .route( + "/calendar/settings", + get(calendar::get_calendar_settings).put(calendar::update_calendar_settings), + ) + .route( + "/calendar/meeting-templates", + get(calendar::list_meeting_templates).post(calendar::create_meeting_template), + ) + .route( + "/calendar/meeting-templates/{id}", + delete(calendar::delete_meeting_template), + ) + .route("/quota", get(quota::get_quota)) + .route("/diagnostics/imap", get(diagnostics::imap_diagnostics)) + .route("/diagnostics/smtp", get(diagnostics::smtp_diagnostics)) + .route("/diagnostics/sync", get(diagnostics::sync_diagnostics)) + .route( + "/diagnostics/imap/reconnect", + post(diagnostics::reconnect_imap), + ) + .route("/diagnostics/search", get(diagnostics::search_diagnostics)) + .route( + "/diagnostics/search/rebuild", + post(diagnostics::rebuild_search_index), + ) + .route("/sieve/status", get(sieve::status)) + .route("/sieve/scripts", get(sieve::list_scripts)) + .route( + "/sieve/scripts/{name}", + get(sieve::get_script) + .put(sieve::put_script) + .delete(sieve::delete_script), + ) + .route( + "/sieve/scripts/{name}/activate", + post(sieve::activate_script), + ) + .route("/sieve/scripts/{name}/rename", post(sieve::rename_script)) + .route("/sieve/deactivate", post(sieve::deactivate_script)) + .route("/sieve/check", post(sieve::check_script)) + .layer(middleware::from_fn(auth_guard)) + .layer(middleware::from_fn(csrf_protection)); + + // WebSocket route — auth is handled inside the handler via cookie, + // so it bypasses CSRF and auth_guard middleware. + let ws_route = Router::new().route("/ws", get(crate::realtime::ws::ws_handler)); + + let api_router = Router::new() + .route("/health", get(health::health_check)) + .route("/ready", get(health::readiness_check)) + .nest("/auth", auth_router) + .merge(ws_route) + .merge(protected_data); + + // RustBox serves API and UI from the same Rust process. + let inner = Router::new() + .nest("/api", api_router) + .fallback(crate::ui::serve_ui); + + // Use the same base path for routing and authentication cookies. + let router = match config.normalized_base_path() { + "/" => inner, + base_path => Router::new().nest(base_path, inner), + }; + + let router = router + .layer(Extension(idle_manager)) + .layer(Extension(event_bus)) + .layer(Extension(smtp_client)) + .layer(Extension(search_engine)) + .layer(Extension(imap_client)) + .layer(Extension(store)) + .layer(Extension(config.clone())) + .layer(TraceLayer::new_for_http()); + + router +} + +#[cfg(test)] +mod tests; diff --git a/src/routes/notification_preferences.rs b/src/routes/notification_preferences.rs new file mode 100644 index 0000000..7515fd0 --- /dev/null +++ b/src/routes/notification_preferences.rs @@ -0,0 +1,39 @@ +use std::sync::Arc; + +use axum::response::{IntoResponse, Response}; +use axum::{Extension, Json}; + +use crate::auth::session::SessionState; +use crate::config::AppConfig; +use crate::db; +use crate::db::notification_preferences::UpdateNotificationPreferences; +use crate::error::AppError; + +/// `GET /api/settings/notifications` +pub async fn get_notification_preferences( + Extension(session): Extension, + Extension(config): Extension>, +) -> Result { + let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) + .map_err(|e| AppError::InternalError(format!("Failed to open database: {e}")))?; + + let prefs = + db::notification_preferences::get_preferences(&conn).map_err(AppError::InternalError)?; + + Ok(Json(prefs).into_response()) +} + +/// `PUT /api/settings/notifications` +pub async fn update_notification_preferences( + Extension(session): Extension, + Extension(config): Extension>, + Json(data): Json, +) -> Result { + let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) + .map_err(|e| AppError::InternalError(format!("Failed to open database: {e}")))?; + + let prefs = db::notification_preferences::update_preferences(&conn, &data) + .map_err(AppError::InternalError)?; + + Ok(Json(prefs).into_response()) +} diff --git a/src/routes/pgp.rs b/src/routes/pgp.rs new file mode 100644 index 0000000..bbafef1 --- /dev/null +++ b/src/routes/pgp.rs @@ -0,0 +1,884 @@ +use std::sync::Arc; + +use crate::imap::client::{ImapClient, ImapCredentials}; +use axum::extract::{Path, Query}; +use axum::http::{HeaderMap, HeaderValue}; +use axum::response::{IntoResponse, Response}; +use axum::{Extension, Json}; +use base64::Engine; +use serde::{Deserialize, Serialize}; + +use crate::auth::session::SessionState; +use crate::config::AppConfig; +use crate::db; +use crate::db::pgp::{PgpKeyRecord, PgpPreferences}; +use crate::error::AppError; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GenerateRequest { + #[serde(default)] + pub name: String, + pub email: String, + #[serde(default)] + pub passphrase: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ImportRequest { + #[serde(default)] + pub key_data: Option, + #[serde(default)] + pub key_data_base64: Option, + #[serde(default = "default_trust")] + pub trust: String, +} + +#[derive(Debug, Deserialize)] +pub struct ExportQuery { + #[serde(default)] + pub secret: bool, +} + +#[derive(Debug, Deserialize)] +pub struct TrustRequest { + pub trust: String, +} + +#[derive(Debug, Deserialize)] +pub struct DiscoverRequest { + pub email: String, +} + +#[derive(Debug, Deserialize)] +pub struct RecipientsRequest { + pub emails: Vec, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RecipientStatus { + pub email: String, + pub has_key: bool, + pub fingerprint: Option, + pub trust: Option, + pub source: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct VerifyRequest { + pub data: String, + pub signature: Option, + pub message: Option, + pub fingerprint: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct VerifyResponse { + pub verified: bool, + pub plaintext: Option, + pub fingerprint: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DecryptRequest { + pub message: String, + #[serde(default)] + pub passphrase: String, + pub fingerprint: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DiscoverResponse { + pub found: bool, + pub source: Option, + pub key: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProcessMessageRequest { + #[serde(default)] + pub passphrase: String, + pub fingerprint: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TestPassphraseRequest { + #[serde(default)] + pub passphrase: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ChangePassphraseRequest { + #[serde(default)] + pub current_passphrase: String, + #[serde(default)] + pub new_passphrase: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TestPassphraseResponse { + pub ok: bool, +} + +pub async fn process_message( + Extension(session): Extension, + Extension(config): Extension>, + Extension(imap_client): Extension>, + Path((folder, uid)): Path<(String, u32)>, + Json(request): Json, +) -> Result, AppError> { + if request.passphrase.len() > 1024 { + return Err(AppError::BadRequest( + "OpenPGP passphrase is too long".to_string(), + )); + } + let creds = ImapCredentials { + host: session.imap_host.clone(), + port: session.imap_port, + tls: session.imap_tls, + email: session.email.clone(), + password: session.password.clone(), + }; + let raw = imap_client + .fetch_raw_message(&creds, &folder, uid) + .await + .map_err(|e| AppError::ServiceUnavailable(format!("IMAP error: {e}")))?; + if raw.len() > 50 * 1024 * 1024 { + return Err(AppError::BadRequest( + "OpenPGP message is too large to process".to_string(), + )); + } + if !crate::pgp::looks_like_openpgp(&raw) { + return Ok(Json(crate::pgp::ProcessedMessage { + pgp: false, + encrypted: false, + decrypted: false, + signed: false, + verified: false, + signer_fingerprint: None, + signer_email: None, + signer_trust: None, + text: None, + html: None, + attachments: vec![], + })); + } + + let database = db::pool::UserDb::new(&config.data_dir, &session.user_hash); + let requested = request + .fingerprint + .map(|value| db::pgp::normalize_fingerprint(&value)); + let secret_rows = database + .run(move |conn| { + if let Some(fp) = requested { + Ok(db::pgp::get_key(conn, &fp)? + .into_iter() + .filter(|key| key.secret_key_path.is_some()) + .collect::>()) + } else { + db::pgp::list_secret_keys(conn) + } + }) + .await + .map_err(AppError::InternalError)?; + let public_keys = database + .run(|conn| { + Ok(db::pgp::list_keys(conn)? + .into_iter() + .map(|key| (key.fingerprint, key.public_key_armor)) + .collect::>()) + }) + .await + .map_err(AppError::InternalError)?; + let passphrase = request.passphrase; + let mut result = tokio::task::spawn_blocking(move || { + let mut secrets = Vec::new(); + for key in secret_rows { + if let Some(path) = key.secret_key_path { + if let Ok(armor) = crate::pgp::read_secret_key(&path) { + secrets.push(armor); + } + } + } + crate::pgp::process_message(&raw, &secrets, &passphrase, &public_keys) + }) + .await + .map_err(|e| AppError::InternalError(format!("PGP message processing task failed: {e}")))? + .map_err(AppError::BadRequest)?; + if let Some(fingerprint) = result.signer_fingerprint.clone() { + let lookup = db::pgp::normalize_fingerprint(&fingerprint); + if let Some(key) = database + .run(move |conn| db::pgp::get_key(conn, &lookup)) + .await + .map_err(AppError::InternalError)? + { + if !key.record.email.trim().is_empty() { + result.signer_email = Some(key.record.email.clone()); + } + result.signer_trust = Some(key.record.trust.clone()); + } + } + Ok(Json(result)) +} + +fn default_trust() -> String { + "unknown".to_string() +} + +pub async fn list_keys( + Extension(session): Extension, + Extension(config): Extension>, +) -> Result>, AppError> { + let database = db::pool::UserDb::new(&config.data_dir, &session.user_hash); + let keys = database + .run(db::pgp::list_keys) + .await + .map_err(AppError::InternalError)?; + Ok(Json(keys)) +} + +pub async fn generate_key( + Extension(session): Extension, + Extension(config): Extension>, + Json(request): Json, +) -> Result, AppError> { + validate_email(&request.email)?; + if request.passphrase.len() > 1024 { + return Err(AppError::BadRequest( + "OpenPGP passphrase is too long".to_string(), + )); + } + let name = request.name; + let email = request.email.trim().to_ascii_lowercase(); + let passphrase = request.passphrase; + let material = + tokio::task::spawn_blocking(move || crate::pgp::generate_key(&name, &email, &passphrase)) + .await + .map_err(|e| AppError::InternalError(format!("PGP key generation task failed: {e}")))? + .map_err(AppError::BadRequest)?; + store_material(&session, &config, material, "generated", "verified").await +} + +pub async fn import_key( + Extension(session): Extension, + Extension(config): Extension>, + Json(request): Json, +) -> Result, AppError> { + validate_trust(&request.trust)?; + let input = match (request.key_data, request.key_data_base64) { + (Some(text), None) => { + if text.len() > config.pgp_max_key_bytes.saturating_mul(2) { + return Err(AppError::BadRequest( + "OpenPGP key input is too large".to_string(), + )); + } + text.into_bytes() + } + (None, Some(encoded)) => { + let max_encoded = config + .pgp_max_key_bytes + .saturating_mul(4) + .saturating_div(3) + .saturating_add(16); + if encoded.len() > max_encoded { + return Err(AppError::BadRequest( + "OpenPGP key input is too large".to_string(), + )); + } + let decoded = base64::engine::general_purpose::STANDARD + .decode(encoded.as_bytes()) + .map_err(|_| AppError::BadRequest("Invalid base64 OpenPGP key data".to_string()))?; + if decoded.len() > config.pgp_max_key_bytes { + return Err(AppError::BadRequest( + "OpenPGP key input is too large".to_string(), + )); + } + decoded + } + (Some(_), Some(_)) => { + return Err(AppError::BadRequest( + "Provide either keyData or keyDataBase64, not both".to_string(), + )); + } + (None, None) => { + return Err(AppError::BadRequest( + "OpenPGP key data is required".to_string(), + )); + } + }; + let material = tokio::task::spawn_blocking(move || crate::pgp::import_key(&input)) + .await + .map_err(|e| AppError::InternalError(format!("PGP import task failed: {e}")))? + .map_err(AppError::BadRequest)?; + store_material(&session, &config, material, "import", &request.trust).await +} + +pub async fn export_key( + Extension(session): Extension, + Extension(config): Extension>, + Path(fingerprint): Path, + Query(query): Query, +) -> Result { + let database = db::pool::UserDb::new(&config.data_dir, &session.user_hash); + let normalized = db::pgp::normalize_fingerprint(&fingerprint); + let key = database + .run(move |conn| db::pgp::get_key(conn, &normalized)) + .await + .map_err(AppError::InternalError)? + .ok_or_else(|| AppError::NotFound("OpenPGP key not found".to_string()))?; + + let (body, suffix) = if query.secret { + if !config.pgp_allow_private_export { + return Err(AppError::Forbidden( + "Private OpenPGP key export is disabled by administrator policy".to_string(), + )); + } + let path = key + .secret_key_path + .ok_or_else(|| AppError::BadRequest("This key has no private material".to_string()))?; + let body = tokio::task::spawn_blocking(move || crate::pgp::read_secret_key(&path)) + .await + .map_err(|e| AppError::InternalError(format!("PGP export task failed: {e}")))? + .map_err(AppError::InternalError)?; + (body, "private") + } else { + (key.record.public_key_armor, "public") + }; + + let filename = format!("openpgp-{}-{suffix}.asc", key.record.key_id); + let mut headers = HeaderMap::new(); + headers.insert( + "content-type", + HeaderValue::from_static("application/pgp-keys; charset=utf-8"), + ); + headers.insert("cache-control", HeaderValue::from_static("no-store")); + headers.insert( + "x-content-type-options", + HeaderValue::from_static("nosniff"), + ); + headers.insert( + "content-disposition", + HeaderValue::from_str(&format!("attachment; filename=\"{filename}\"")) + .map_err(|e| AppError::InternalError(format!("Invalid export filename: {e}")))?, + ); + Ok((headers, body).into_response()) +} + +pub async fn test_passphrase( + Extension(session): Extension, + Extension(config): Extension>, + Path(fingerprint): Path, + Json(request): Json, +) -> Result, AppError> { + if request.passphrase.len() > 1024 { + return Err(AppError::BadRequest( + "OpenPGP passphrase is too long".to_string(), + )); + } + let database = db::pool::UserDb::new(&config.data_dir, &session.user_hash); + let normalized = db::pgp::normalize_fingerprint(&fingerprint); + let key = database + .run(move |conn| db::pgp::get_key(conn, &normalized)) + .await + .map_err(AppError::InternalError)? + .ok_or_else(|| AppError::NotFound("OpenPGP key not found".to_string()))?; + let path = key + .secret_key_path + .ok_or_else(|| AppError::BadRequest("This key has no private material".to_string()))?; + let passphrase = request.passphrase; + tokio::task::spawn_blocking(move || { + let secret = crate::pgp::read_secret_key(&path)?; + crate::pgp::test_secret_key_passphrase(&secret, &passphrase) + }) + .await + .map_err(|e| AppError::InternalError(format!("PGP passphrase test task failed: {e}")))? + .map_err(|_| { + AppError::BadRequest( + "OpenPGP private key could not be unlocked with that passphrase".to_string(), + ) + })?; + Ok(Json(TestPassphraseResponse { ok: true })) +} + +pub async fn change_passphrase( + Extension(session): Extension, + Extension(config): Extension>, + Path(fingerprint): Path, + Json(request): Json, +) -> Result, AppError> { + if request.current_passphrase.len() > 1024 || request.new_passphrase.len() > 1024 { + return Err(AppError::BadRequest( + "OpenPGP passphrase is too long".to_string(), + )); + } + let database = db::pool::UserDb::new(&config.data_dir, &session.user_hash); + let normalized = db::pgp::normalize_fingerprint(&fingerprint); + let lookup = normalized.clone(); + let key = database + .run(move |conn| db::pgp::get_key(conn, &lookup)) + .await + .map_err(AppError::InternalError)? + .ok_or_else(|| AppError::NotFound("OpenPGP key not found".to_string()))?; + let path = key + .secret_key_path + .ok_or_else(|| AppError::BadRequest("This key has no private material".to_string()))?; + let data_dir = config.data_dir.clone(); + let user_hash = session.user_hash.clone(); + let gpg_binary = std::env::var("RUSTBOX_PGP_GPG_BINARY").unwrap_or_else(|_| "gpg".to_string()); + let current = request.current_passphrase; + let new_passphrase = request.new_passphrase; + tokio::task::spawn_blocking(move || { + crate::pgp::change_secret_key_passphrase( + &data_dir, + &user_hash, + &normalized, + &path, + ¤t, + &new_passphrase, + &gpg_binary, + ) + }) + .await + .map_err(|e| AppError::InternalError(format!("PGP passphrase-change task failed: {e}")))? + .map_err(|error| { + if error.contains("requires GnuPG") || error.contains("start GnuPG") { + AppError::ServiceUnavailable(error) + } else { + AppError::BadRequest(error) + } + })?; + Ok(Json(TestPassphraseResponse { ok: true })) +} + +pub async fn delete_key( + Extension(session): Extension, + Extension(config): Extension>, + Path(fingerprint): Path, +) -> Result { + let database = db::pool::UserDb::new(&config.data_dir, &session.user_hash); + let normalized = db::pgp::normalize_fingerprint(&fingerprint); + let lookup = normalized.clone(); + let key = database + .run(move |conn| db::pgp::get_key(conn, &lookup)) + .await + .map_err(AppError::InternalError)?; + let Some(key) = key else { + return Err(AppError::NotFound("OpenPGP key not found".to_string())); + }; + + let delete_fp = normalized.clone(); + database.run(move |conn| { + conn.execute("UPDATE identities SET pgp_fingerprint=NULL,pgp_sign_default=0,pgp_encrypt_default=0,pgp_inline_default=0 WHERE replace(upper(COALESCE(pgp_fingerprint,'')),' ','')=?1", rusqlite::params![delete_fp]) + .map_err(|e| format!("Failed to detach OpenPGP key from identities: {e}"))?; + db::pgp::delete_key(conn, &normalized) + }).await.map_err(AppError::InternalError)?; + + if let Some(path) = key.secret_key_path { + tokio::task::spawn_blocking(move || crate::pgp::delete_secret_key(&path)) + .await + .map_err(|e| AppError::InternalError(format!("PGP delete task failed: {e}")))? + .map_err(AppError::InternalError)?; + } + Ok(axum::http::StatusCode::NO_CONTENT.into_response()) +} + +pub async fn set_trust( + Extension(session): Extension, + Extension(config): Extension>, + Path(fingerprint): Path, + Json(request): Json, +) -> Result, AppError> { + validate_trust(&request.trust)?; + let database = db::pool::UserDb::new(&config.data_dir, &session.user_hash); + let normalized = db::pgp::normalize_fingerprint(&fingerprint); + let trust = request.trust; + let update_fp = normalized.clone(); + let changed = database + .run(move |conn| db::pgp::set_trust(conn, &update_fp, &trust)) + .await + .map_err(AppError::InternalError)?; + if !changed { + return Err(AppError::NotFound("OpenPGP key not found".to_string())); + } + let key = database + .run(move |conn| db::pgp::get_key(conn, &normalized)) + .await + .map_err(AppError::InternalError)? + .ok_or_else(|| AppError::NotFound("OpenPGP key not found".to_string()))?; + Ok(Json(key.record)) +} + +pub async fn get_preferences( + Extension(session): Extension, + Extension(config): Extension>, +) -> Result, AppError> { + let database = db::pool::UserDb::new(&config.data_dir, &session.user_hash); + let prefs = database + .run(db::pgp::get_preferences) + .await + .map_err(AppError::InternalError)?; + Ok(Json(prefs)) +} + +pub async fn update_preferences( + Extension(session): Extension, + Extension(config): Extension>, + Json(mut prefs): Json, +) -> Result, AppError> { + if !config.pgp_wkd_enabled { + prefs.wkd_enabled = false; + } + if !config.pgp_hkp_enabled { + prefs.hkp_enabled = false; + } + let database = db::pool::UserDb::new(&config.data_dir, &session.user_hash); + let updated = database + .run(move |conn| db::pgp::update_preferences(conn, &prefs)) + .await + .map_err(AppError::InternalError)?; + Ok(Json(updated)) +} + +pub async fn discover_key( + Extension(session): Extension, + Extension(config): Extension>, + Json(request): Json, +) -> Result, AppError> { + validate_email(&request.email)?; + let email = request.email.trim().to_ascii_lowercase(); + let database = db::pool::UserDb::new(&config.data_dir, &session.user_hash); + let prefs = database + .run(db::pgp::get_preferences) + .await + .map_err(AppError::InternalError)?; + + let mut discovered = None; + let mut source = None; + if prefs.wkd_enabled && config.pgp_wkd_enabled { + match crate::pgp::discovery::discover_wkd( + &email, + config.pgp_network_timeout_secs, + config.pgp_max_key_bytes, + ) + .await + { + Ok(Some(bytes)) => { + discovered = Some(bytes); + source = Some("wkd".to_string()); + } + Ok(None) => {} + Err(error) => tracing::debug!(email, error = %error, "WKD key lookup failed"), + } + } + if discovered.is_none() && prefs.hkp_enabled && config.pgp_hkp_enabled { + match crate::pgp::discovery::discover_hkp( + &config.pgp_hkp_url, + &email, + config.pgp_network_timeout_secs, + config.pgp_max_key_bytes, + ) + .await + { + Ok(Some(bytes)) => { + discovered = Some(bytes); + source = Some("hkp".to_string()); + } + Ok(None) => {} + Err(error) => tracing::debug!(email, error = %error, "HKP key lookup failed"), + } + } + + let Some(bytes) = discovered else { + return Ok(Json(DiscoverResponse { + found: false, + source: None, + key: None, + })); + }; + let material = tokio::task::spawn_blocking(move || crate::pgp::import_key(&bytes)) + .await + .map_err(|e| AppError::InternalError(format!("PGP discovery import task failed: {e}")))? + .map_err(AppError::BadRequest)?; + if !material.email.is_empty() && !material.email.eq_ignore_ascii_case(&email) { + return Err(AppError::BadRequest( + "Discovered OpenPGP key does not match the requested email address".to_string(), + )); + } + let source_value = source.clone().unwrap_or_else(|| "discovery".to_string()); + let stored = store_material_raw(&session, &config, material, &source_value, "unknown").await?; + Ok(Json(DiscoverResponse { + found: true, + source, + key: Some(stored), + })) +} + +pub async fn recipient_status( + Extension(session): Extension, + Extension(config): Extension>, + Json(request): Json, +) -> Result>, AppError> { + if request.emails.len() > 200 { + return Err(AppError::BadRequest( + "Too many OpenPGP recipients".to_string(), + )); + } + let emails: Vec = request + .emails + .into_iter() + .map(|value| mailbox_email(&value)) + .collect::>()?; + let database = db::pool::UserDb::new(&config.data_dir, &session.user_hash); + let result = database + .run(move |conn| { + let mut statuses = Vec::with_capacity(emails.len()); + for email in emails { + let key = db::pgp::find_keys_by_email(conn, &email)? + .into_iter() + .next(); + statuses.push(match key { + Some(key) => RecipientStatus { + email, + has_key: true, + fingerprint: Some(key.fingerprint), + trust: Some(key.trust), + source: Some(key.source), + }, + None => RecipientStatus { + email, + has_key: false, + fingerprint: None, + trust: None, + source: None, + }, + }); + } + Ok(statuses) + }) + .await + .map_err(AppError::InternalError)?; + Ok(Json(result)) +} + +pub async fn verify( + Extension(session): Extension, + Extension(config): Extension>, + Json(request): Json, +) -> Result, AppError> { + let database = db::pool::UserDb::new(&config.data_dir, &session.user_hash); + let fingerprint = db::pgp::normalize_fingerprint(&request.fingerprint); + let lookup = fingerprint.clone(); + let key = database + .run(move |conn| db::pgp::get_key(conn, &lookup)) + .await + .map_err(AppError::InternalError)? + .ok_or_else(|| AppError::NotFound("OpenPGP key not found".to_string()))?; + let public = key.record.public_key_armor; + let data = request.data; + let signature = request.signature; + let message = request.message; + let output = tokio::task::spawn_blocking(move || { + if let Some(signature) = signature { + crate::pgp::verify_detached(data.as_bytes(), &signature, &public)?; + Ok::, String>(None) + } else if let Some(message) = message { + crate::pgp::verify_inline(&message, &public).map(Some) + } else { + Err("Either signature or inline message is required".to_string()) + } + }) + .await + .map_err(|e| AppError::InternalError(format!("PGP verification task failed: {e}")))? + .map_err(AppError::BadRequest)?; + Ok(Json(VerifyResponse { + verified: true, + plaintext: output, + fingerprint, + })) +} + +pub async fn decrypt( + Extension(session): Extension, + Extension(config): Extension>, + Json(request): Json, +) -> Result, AppError> { + if request.passphrase.len() > 1024 { + return Err(AppError::BadRequest( + "OpenPGP passphrase is too long".to_string(), + )); + } + let database = db::pool::UserDb::new(&config.data_dir, &session.user_hash); + let requested = request + .fingerprint + .map(|value| db::pgp::normalize_fingerprint(&value)); + let candidates = database + .run(move |conn| { + if let Some(fingerprint) = requested { + Ok(db::pgp::get_key(conn, &fingerprint)? + .into_iter() + .collect::>()) + } else { + db::pgp::list_secret_keys(conn) + } + }) + .await + .map_err(AppError::InternalError)?; + if candidates.is_empty() { + return Err(AppError::BadRequest( + "No OpenPGP private key is available".to_string(), + )); + } + + let public_armors = database + .run(|conn| { + Ok(db::pgp::list_keys(conn)? + .into_iter() + .map(|key| key.public_key_armor) + .collect::>()) + }) + .await + .map_err(AppError::InternalError)?; + let message = request.message; + let passphrase = request.passphrase; + let result = tokio::task::spawn_blocking(move || { + let mut last_error = "No OpenPGP private key could decrypt this message".to_string(); + for candidate in candidates { + let Some(path) = candidate.secret_key_path else { + continue; + }; + let secret = match crate::pgp::read_secret_key(&path) { + Ok(value) => value, + Err(error) => { + last_error = error; + continue; + } + }; + match crate::pgp::decrypt_inline(&message, &secret, &passphrase, &public_armors) { + Ok(value) => return Ok(value), + Err(error) => last_error = error, + } + } + Err(last_error) + }) + .await + .map_err(|e| AppError::InternalError(format!("PGP decryption task failed: {e}")))? + .map_err(AppError::BadRequest)?; + Ok(Json(result)) +} + +async fn store_material( + session: &SessionState, + config: &AppConfig, + material: crate::pgp::ImportedKey, + source: &str, + trust: &str, +) -> Result, AppError> { + Ok(Json( + store_material_raw(session, config, material, source, trust).await?, + )) +} + +async fn store_material_raw( + session: &SessionState, + config: &AppConfig, + material: crate::pgp::ImportedKey, + source: &str, + trust: &str, +) -> Result { + validate_trust(trust)?; + let secret_store = if let Some(secret_armor) = material.secret_armor.clone() { + let data_dir = config.data_dir.clone(); + let user_hash = session.user_hash.clone(); + let fingerprint = material.fingerprint.clone(); + Some( + tokio::task::spawn_blocking(move || { + let target = crate::pgp::private_key_dir(&data_dir, &user_hash) + .join(format!("{fingerprint}.asc")); + let existed = target.exists(); + crate::pgp::store_secret_key(&data_dir, &user_hash, &fingerprint, &secret_armor) + .map(|path| (path, existed)) + }) + .await + .map_err(|e| AppError::InternalError(format!("PGP key storage task failed: {e}")))? + .map_err(AppError::InternalError)?, + ) + } else { + None + }; + let secret_path = secret_store.as_ref().map(|(path, _)| path.clone()); + + let record = PgpKeyRecord { + fingerprint: material.fingerprint, + key_id: material.key_id, + email: material.email, + user_id: material.user_id, + public_key_armor: material.public_armor, + has_secret: secret_path.is_some(), + trust: trust.to_string(), + source: source.to_string(), + created_at: String::new(), + updated_at: String::new(), + }; + let database = db::pool::UserDb::new(&config.data_dir, &session.user_hash); + let stored = record.clone(); + let path = secret_path.clone(); + if let Err(error) = database + .run(move |conn| db::pgp::upsert_key(conn, &stored, path.as_deref())) + .await + { + if let Some((path, existed)) = secret_store { + if !existed { + let _ = + tokio::task::spawn_blocking(move || crate::pgp::delete_secret_key(&path)).await; + } + } + return Err(AppError::InternalError(error)); + } + let lookup = record.fingerprint.clone(); + let key = database + .run(move |conn| db::pgp::get_key(conn, &lookup)) + .await + .map_err(AppError::InternalError)? + .ok_or_else(|| AppError::InternalError("Stored OpenPGP key disappeared".to_string()))?; + Ok(key.record) +} + +fn validate_trust(value: &str) -> Result<(), AppError> { + if matches!(value, "unknown" | "untrusted" | "trusted" | "verified") { + Ok(()) + } else { + Err(AppError::BadRequest( + "Trust must be unknown, untrusted, trusted, or verified".to_string(), + )) + } +} + +fn validate_email(value: &str) -> Result<(), AppError> { + let email = value.trim(); + if email.len() > 320 + || !email.contains('@') + || email + .chars() + .any(|ch| matches!(ch, '\r' | '\n' | '<' | '>')) + { + return Err(AppError::BadRequest("Invalid email address".to_string())); + } + Ok(()) +} + +fn mailbox_email(value: &str) -> Result { + let mailbox: lettre::message::Mailbox = value + .parse() + .map_err(|_| AppError::BadRequest(format!("Invalid email address: {value}")))?; + Ok(mailbox.email.to_string().to_ascii_lowercase()) +} diff --git a/src/routes/quota.rs b/src/routes/quota.rs new file mode 100644 index 0000000..8fc1514 --- /dev/null +++ b/src/routes/quota.rs @@ -0,0 +1,111 @@ +use std::sync::Arc; +use std::time::Duration; + +use axum::response::{IntoResponse, Response}; +use axum::{Extension, Json}; +use serde::Serialize; + +use crate::auth::session::SessionState; +use crate::config::AppConfig; +use crate::db; +use crate::error::AppError; +use crate::imap::client::{ImapClient, ImapCredentials}; + +#[derive(Serialize)] +struct QuotaResponse { + /// Storage used in bytes, or null if unavailable. + usage_bytes: Option, + /// Storage limit in bytes, or null if unlimited/unavailable. + limit_bytes: Option, +} + +/// `GET /api/quota` +/// +/// Returns the mailbox storage quota for the authenticated user. +/// Tries IMAP GETQUOTAROOT first; if no quota is configured, falls back to +/// the cached message sizes (an estimate during sync). An empty cache uses IMAP. +pub async fn get_quota( + Extension(session): Extension, + Extension(config): Extension>, + Extension(imap_client): Extension>, +) -> Result { + let creds = ImapCredentials { + host: session.imap_host.clone(), + port: session.imap_port, + tls: session.imap_tls, + email: session.email.clone(), + password: session.password.clone(), + }; + + // Try IMAP GETQUOTAROOT first. + let quota = imap_client.get_quota(&creds).await.unwrap_or(None); + + let resp = match quota { + Some(q) => QuotaResponse { + usage_bytes: Some(q.usage_bytes), + limit_bytes: if q.limit_bytes > 0 { + Some(q.limit_bytes) + } else { + None + }, + }, + None => { + let database = db::pool::UserDb::new(&config.data_dir, &session.user_hash); + if let Some(bytes) = database + .run(db::messages::cached_storage_bytes) + .await + .map_err(AppError::InternalError)? + { + return Ok(Json(QuotaResponse { + usage_bytes: Some(bytes), + limit_bytes: None, + }) + .into_response()); + } + // Empty cache: sum RFC822.SIZE across all folders. + // Run SQLite access in spawn_blocking to avoid blocking the executor. + let data_dir = config.data_dir.clone(); + let user_hash = session.user_hash.clone(); + let folders: Vec = tokio::task::spawn_blocking(move || { + let conn = db::pool::open_user_db(&data_dir, &user_hash)?; + Ok::<_, String>( + db::folders::get_all_folders(&conn) + .unwrap_or_default() + .into_iter() + .map(|f| f.name) + .collect(), + ) + }) + .await + .map_err(|e| AppError::InternalError(format!("Task error: {e}")))? + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + + // Fetch sizes with a 90s overall timeout. + let total = tokio::time::timeout( + Duration::from_secs(90), + crate::imap::pool::background(async { + let mut sum: u64 = 0; + for folder in &folders { + sum += imap_client.fetch_folder_size(&creds, folder).await?; + } + Ok::<_, crate::imap::error::ImapError>(sum) + }), + ) + .await; + let total = match total { + Ok(Ok(total)) => Some(total), + other => { + tracing::warn!(error = ?other, "Quota folder-size fallback failed"); + None + } + }; + + QuotaResponse { + usage_bytes: total, + limit_bytes: None, + } + } + }; + + Ok(Json(resp).into_response()) +} diff --git a/src/routes/search.rs b/src/routes/search.rs new file mode 100644 index 0000000..029f77c --- /dev/null +++ b/src/routes/search.rs @@ -0,0 +1,705 @@ +use std::sync::Arc; + +use axum::extract::{Path, Query}; +use axum::response::{IntoResponse, Response}; +use axum::{Extension, Json}; +use serde::{Deserialize, Serialize}; + +use crate::auth::session::SessionState; +use crate::config::AppConfig; +use crate::db; +use crate::error::AppError; +use crate::search::engine::{SearchEngine, SearchQuery}; + +// --------------------------------------------------------------------------- +// Search query parser +// --------------------------------------------------------------------------- + +/// Intermediate result of parsing structured operators out of a query string. +struct ParsedQuery { + text: String, + subject_only: Option, + folder: Option, + from: Option, + to: Option, + date_from: Option, + date_to: Option, + has_attachment: Option, + unread: Option, + flagged: Option, +} + +/// Parse a search query string extracting structured operators like `from:`, +/// `to:`, `subject:`, `in:`/`folder:`, `date:`, `after:`, `before:`, and +/// `has:attachment`. Remaining text becomes the free-text query. +/// +/// Supports quoted values: `from:"John Doe"`, `subject:"meeting notes"`. +fn parse_search_query(input: &str) -> ParsedQuery { + let mut text_parts: Vec = Vec::new(); + let mut subject_only: Option = None; + let mut folder: Option = None; + let mut from: Option = None; + let mut to: Option = None; + let mut date_from: Option = None; + let mut date_to: Option = None; + let mut has_attachment: Option = None; + let mut unread: Option = None; + let mut flagged: Option = None; + + let tokens = tokenize_query(input); + + for token in tokens { + if let Some((operator, value)) = split_operator(&token) { + let op = operator.to_lowercase(); + match op.as_str() { + "from" => from = Some(value), + "to" => to = Some(value), + "subject" => subject_only = Some(value), + "in" | "folder" => folder = Some(value), + "date" => { + if let Some((start, end)) = parse_date_range(&value) { + date_from = Some(start); + date_to = Some(end); + } + } + "after" => { + if let Some(epoch) = parse_date_start(&value) { + date_from = Some(epoch); + } + } + "before" => { + if let Some(epoch) = parse_date_start(&value) { + date_to = Some(epoch); + } + } + "has" if value.eq_ignore_ascii_case("attachment") => { + has_attachment = Some(true); + } + "is" if value.eq_ignore_ascii_case("unread") => unread = Some(true), + "is" if value.eq_ignore_ascii_case("read") => unread = Some(false), + "is" if value.eq_ignore_ascii_case("flagged") + || value.eq_ignore_ascii_case("starred") => + { + flagged = Some(true) + } + "is" if value.eq_ignore_ascii_case("unflagged") + || value.eq_ignore_ascii_case("unstarred") => + { + flagged = Some(false) + } + _ => { + // Unknown operator, treat as plain text + text_parts.push(token); + } + } + } else { + text_parts.push(token); + } + } + + ParsedQuery { + text: text_parts.join(" "), + subject_only, + folder, + from, + to, + date_from, + date_to, + has_attachment, + unread, + flagged, + } +} + +/// Tokenize a query string, respecting quoted values attached to operators. +/// For example: `from:"John Doe" hello world` yields +/// `["from:John Doe", "hello", "world"]`. +fn tokenize_query(input: &str) -> Vec { + let mut tokens = Vec::new(); + let mut chars = input.chars().peekable(); + let mut current = String::new(); + + while let Some(&ch) = chars.peek() { + if ch.is_whitespace() { + if !current.is_empty() { + tokens.push(std::mem::take(&mut current)); + } + chars.next(); + } else if ch == '"' { + // Start of a quoted section + chars.next(); // consume opening quote + while let Some(&qch) = chars.peek() { + if qch == '"' { + chars.next(); // consume closing quote + break; + } + current.push(qch); + chars.next(); + } + } else { + current.push(ch); + chars.next(); + } + } + + if !current.is_empty() { + tokens.push(current); + } + + tokens +} + +/// Split a token into (operator, value) if it matches `operator:value` pattern. +fn split_operator(token: &str) -> Option<(String, String)> { + if let Some(colon_pos) = token.find(':') { + let op = &token[..colon_pos]; + let val = &token[colon_pos + 1..]; + // Only treat as operator if the operator part is alphabetic and value is non-empty + if !op.is_empty() && op.chars().all(|c| c.is_ascii_alphabetic()) && !val.is_empty() { + return Some((op.to_string(), val.to_string())); + } + } + None +} + +/// Parse a `YYYY-MM-DD` date string into a start-of-day and end-of-day epoch +/// (UTC) pair. +fn parse_date_range(date_str: &str) -> Option<(i64, i64)> { + let start = parse_date_start(date_str)?; + let end = start + 86_400 - 1; // end of day + Some((start, end)) +} + +/// Parse a `YYYY-MM-DD` date string into a start-of-day epoch (UTC). +fn parse_date_start(date_str: &str) -> Option { + let parts: Vec<&str> = date_str.split('-').collect(); + if parts.len() != 3 { + return None; + } + let year: i64 = parts[0].parse().ok()?; + let month: i64 = parts[1].parse().ok()?; + let day: i64 = parts[2].parse().ok()?; + + if !(1..=12).contains(&month) || !(1..=31).contains(&day) { + return None; + } + + // Convert to Unix epoch using a simplified calculation (UTC). + // Days from year 1970 to the given date. + let mut total_days: i64 = 0; + + // Days from full years + for y in 1970..year { + total_days += if is_leap_year(y) { 366 } else { 365 }; + } + + // Days from full months in the target year + let days_in_months = [ + 31, + 28 + i64::from(is_leap_year(year)), + 31, + 30, + 31, + 30, + 31, + 31, + 30, + 31, + 30, + 31, + ]; + for days in days_in_months.iter().take((month - 1) as usize) { + total_days += days; + } + + total_days += day - 1; + + Some(total_days * 86_400) +} + +fn is_leap_year(y: i64) -> bool { + (y % 4 == 0 && y % 100 != 0) || y % 400 == 0 +} + +// --------------------------------------------------------------------------- +// Query parameters +// --------------------------------------------------------------------------- + +/// Query parameters for `GET /api/search`. +#[derive(Deserialize)] +pub struct SearchParams { + /// The search text (required, must not be empty). + pub q: Option, + /// Optional folder filter. + pub folder: Option, + /// Optional subject filter. + pub subject: Option, + /// Optional from address filter. + pub from: Option, + /// Optional to address filter. + pub to: Option, + /// Optional date range start (Unix epoch seconds). + pub date_from: Option, + /// Optional date range end (Unix epoch seconds). + pub date_to: Option, + /// Optional attachment filter. + pub has_attachment: Option, + /// Optional unread/read filter. + pub unread: Option, + /// Optional flagged/unflagged filter. + pub flagged: Option, + /// Maximum number of results (default 50). + #[serde(default = "default_limit")] + pub limit: usize, + /// Offset for pagination (default 0). + #[serde(default)] + pub offset: usize, + /// Sort order: "date_desc" (default) or "date_asc". + #[serde(default = "default_sort")] + pub sort: String, + /// Materialize every matching cached result. Intended for explicit + /// "select all search results" bulk actions, not normal rendering. + #[serde(default)] + pub all: bool, +} + +fn default_sort() -> String { + "date_desc".to_string() +} + +fn default_limit() -> usize { + 10_000 +} + +// --------------------------------------------------------------------------- +// Response types +// --------------------------------------------------------------------------- + +/// Response envelope for `GET /api/search`. +#[derive(Serialize)] +pub struct SearchResponse { + pub results: Vec, + pub total_count: usize, + pub query: String, +} + +/// A single search result item enriched with message metadata from SQLite. +#[derive(Serialize)] +pub struct SearchResultItem { + pub uid: u32, + pub folder: String, + pub score: f32, + pub subject: String, + pub from_address: String, + pub from_name: String, + pub to_addresses: String, + pub date: String, + pub flags: String, + pub has_attachments: bool, + pub snippet: String, +} + +// --------------------------------------------------------------------------- +// Handler +// --------------------------------------------------------------------------- + +/// `GET /api/search?q=text&folder=INBOX&from=alice&date_from=...&date_to=...&has_attachment=true&limit=50&offset=0` +/// +/// Searches the user's messages using both SQLite (for header fields) and +/// Tantivy (for body text). Results are merged and deduplicated. +pub async fn search_messages( + Extension(session): Extension, + Extension(config): Extension>, + Extension(search_engine): Extension>, + Query(params): Query, +) -> Result { + // Validate that `q` is provided and non-empty. + let query_text = params.q.as_deref().unwrap_or("").trim().to_string(); + + let has_direct_filters = params.folder.is_some() + || params.subject.is_some() + || params.from.is_some() + || params.to.is_some() + || params.date_from.is_some() + || params.date_to.is_some() + || params.has_attachment.is_some() + || params.unread.is_some() + || params.flagged.is_some(); + if query_text.is_empty() && !has_direct_filters { + return Err(AppError::BadRequest( + "A search query or at least one filter is required".to_string(), + )); + } + + let sort_order = params.sort.clone(); + let select_all = params.all; + let requested_limit = params.limit.clamp(1, 10_000); + let search_offset = params.offset; + + // Parse structured operators from the query string. + let parsed = parse_search_query(&query_text); + + let folder = params.folder.or(parsed.folder); + let subject = params.subject.or(parsed.subject_only.clone()); + let from = params.from.or(parsed.from); + let to = params.to.or(parsed.to); + let date_from = params.date_from.or(parsed.date_from); + let date_to = params.date_to.or(parsed.date_to); + let has_attachment = params.has_attachment.or(parsed.has_attachment); + let unread = params.unread.or(parsed.unread); + let flagged = params.flagged.or(parsed.flagged); + + // Run SQLite queries in spawn_blocking to avoid blocking the Tokio executor. + let data_dir = config.data_dir.clone(); + let user_hash = session.user_hash.clone(); + let text_clone = parsed.text.clone(); + let parsed_subject_clone = subject.clone(); + let folder_clone = folder.clone(); + let from_clone = from.clone(); + let to_clone = to.clone(); + let (sqlite_results, limit) = tokio::task::spawn_blocking(move || { + let conn = db::pool::open_user_db(&data_dir, &user_hash) + .map_err(|e| format!("Database error: {e}"))?; + let effective_limit = if select_all { + db::messages::count_all_messages(&conn)?.max(1) + } else { + requested_limit + }; + let results = db::messages::search_messages_sqlite( + &conn, + &text_clone, + parsed_subject_clone.as_deref(), + folder_clone.as_deref(), + from_clone.as_deref(), + to_clone.as_deref(), + date_from, + date_to, + has_attachment, + unread, + flagged, + effective_limit, + )?; + Ok::<_, String>((results, effective_limit)) + }) + .await + .map_err(|e| AppError::InternalError(format!("Task error: {e}")))? + .map_err(|e| AppError::InternalError(format!("Search error: {e}")))?; + + // Collect SQLite results as SearchResultItems. + let mut seen = std::collections::HashSet::new(); + let mut results: Vec = sqlite_results + .into_iter() + .map(|msg| { + seen.insert((msg.folder.clone(), msg.uid)); + SearchResultItem { + uid: msg.uid, + folder: msg.folder, + score: 1.0, + subject: msg.subject, + from_address: msg.from_address, + from_name: msg.from_name, + to_addresses: msg.to_addresses, + date: msg.date, + flags: msg.flags, + has_attachments: msg.has_attachments, + snippet: msg.snippet, + } + }) + .collect(); + + // Secondary search: Tantivy for body text matches not found by SQLite. + // Only add results up to the limit cap. + if !parsed.text.is_empty() + && results.len() < limit + && let Ok(user_index) = search_engine.open_user_index(&session.user_hash) + && let Ok((tantivy_results, _)) = user_index.search(&SearchQuery { + text: parsed.text, + subject_only: subject, + folder, + from, + to, + date_from, + date_to, + has_attachment, + limit, + offset: if select_all { 0 } else { search_offset }, + }) + { + // Resolve Tantivy UIDs via SQLite in spawn_blocking. + let data_dir2 = config.data_dir.clone(); + let user_hash2 = session.user_hash.clone(); + let remaining = limit.saturating_sub(results.len()); + let tantivy_enriched = tokio::task::spawn_blocking(move || { + let conn = db::pool::open_user_db(&data_dir2, &user_hash2) + .map_err(|e| format!("Database error: {e}"))?; + let mut items = Vec::new(); + for sr in &tantivy_results { + if items.len() >= remaining { + break; + } + if let Ok(Some(msg)) = db::messages::get_single_message(&conn, &sr.folder, sr.uid) { + let is_unread = !msg.flags.contains("\\Seen"); + let is_flagged = msg.flags.contains("\\Flagged"); + if unread.is_some_and(|wanted| wanted != is_unread) { + continue; + } + if flagged.is_some_and(|wanted| wanted != is_flagged) { + continue; + } + items.push((sr.score, sr.snippet.clone(), msg)); + } + } + Ok::<_, String>(items) + }) + .await + .map_err(|e| AppError::InternalError(format!("Task error: {e}")))? + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + + for (score, snippet, msg) in tantivy_enriched { + if seen.contains(&(msg.folder.clone(), msg.uid)) { + continue; + } + seen.insert((msg.folder.clone(), msg.uid)); + results.push(SearchResultItem { + uid: msg.uid, + folder: msg.folder, + score, + subject: msg.subject, + from_address: msg.from_address, + from_name: msg.from_name, + to_addresses: msg.to_addresses, + date: msg.date, + flags: msg.flags, + has_attachments: msg.has_attachments, + snippet: if snippet.is_empty() { + msg.snippet + } else { + snippet + }, + }); + } + } + + // Sort results by date. + let sort_asc = sort_order == "date_asc"; + results.sort_by(|a, b| { + let da = crate::db::messages::parse_date_to_epoch_public(&a.date); + let db_val = crate::db::messages::parse_date_to_epoch_public(&b.date); + if sort_asc { + da.cmp(&db_val) + } else { + db_val.cmp(&da) + } + }); + + // Cap to requested limit after sorting. + results.truncate(limit); + + Ok(Json(SearchResponse { + total_count: results.len(), + results, + query: query_text, + }) + .into_response()) +} + +#[derive(Debug, Deserialize)] +pub struct SavedSearchRequest { + pub name: String, + pub query_string: String, +} + +pub async fn list_saved_searches( + Extension(session): Extension, + Extension(config): Extension>, +) -> Result { + let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) + .map_err(|e| AppError::InternalError(format!("Failed to open database: {e}")))?; + let searches = db::saved_searches::list(&conn).map_err(AppError::InternalError)?; + Ok(Json(serde_json::json!({ "searches": searches })).into_response()) +} + +pub async fn create_saved_search( + Extension(session): Extension, + Extension(config): Extension>, + Json(data): Json, +) -> Result { + if data.name.trim().is_empty() || data.query_string.trim().is_empty() { + return Err(AppError::BadRequest( + "Saved search name and query are required".to_string(), + )); + } + let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) + .map_err(|e| AppError::InternalError(format!("Failed to open database: {e}")))?; + let saved = db::saved_searches::create(&conn, &data.name, &data.query_string) + .map_err(|e| AppError::BadRequest(e))?; + Ok((axum::http::StatusCode::CREATED, Json(saved)).into_response()) +} + +pub async fn update_saved_search( + Extension(session): Extension, + Extension(config): Extension>, + Path(id): Path, + Json(data): Json, +) -> Result { + let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) + .map_err(|e| AppError::InternalError(format!("Failed to open database: {e}")))?; + match db::saved_searches::update(&conn, id, &data.name, &data.query_string) + .map_err(AppError::BadRequest)? + { + Some(saved) => Ok(Json(saved).into_response()), + None => Err(AppError::NotFound("Saved search not found".to_string())), + } +} + +pub async fn delete_saved_search( + Extension(session): Extension, + Extension(config): Extension>, + Path(id): Path, +) -> Result { + let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) + .map_err(|e| AppError::InternalError(format!("Failed to open database: {e}")))?; + if db::saved_searches::delete(&conn, id).map_err(AppError::InternalError)? { + Ok(Json(serde_json::json!({ "status": "deleted" })).into_response()) + } else { + Err(AppError::NotFound("Saved search not found".to_string())) + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_plain_text() { + let p = parse_search_query("hello world"); + assert_eq!(p.text, "hello world"); + assert!(p.from.is_none()); + assert!(p.to.is_none()); + assert!(p.subject_only.is_none()); + assert!(p.folder.is_none()); + } + + #[test] + fn parse_from_operator() { + let p = parse_search_query("from:alice@example.com hello"); + assert_eq!(p.text, "hello"); + assert_eq!(p.from.as_deref(), Some("alice@example.com")); + } + + #[test] + fn parse_to_operator() { + let p = parse_search_query("to:bob@example.com updates"); + assert_eq!(p.text, "updates"); + assert_eq!(p.to.as_deref(), Some("bob@example.com")); + } + + #[test] + fn parse_subject_operator() { + let p = parse_search_query("subject:meeting notes"); + assert_eq!(p.subject_only.as_deref(), Some("meeting")); + assert_eq!(p.text, "notes"); + } + + #[test] + fn parse_subject_quoted() { + let p = parse_search_query("subject:\"meeting notes\" extra"); + assert_eq!(p.subject_only.as_deref(), Some("meeting notes")); + assert_eq!(p.text, "extra"); + } + + #[test] + fn parse_folder_operators() { + let p = parse_search_query("in:Sent hello"); + assert_eq!(p.folder.as_deref(), Some("Sent")); + assert_eq!(p.text, "hello"); + + let p2 = parse_search_query("folder:INBOX test"); + assert_eq!(p2.folder.as_deref(), Some("INBOX")); + } + + #[test] + fn parse_date_operator() { + let p = parse_search_query("date:2024-01-15 news"); + assert_eq!(p.text, "news"); + assert!(p.date_from.is_some()); + assert!(p.date_to.is_some()); + // 2024-01-15 00:00:00 UTC = 1705276800 + assert_eq!(p.date_from, Some(1_705_276_800)); + assert_eq!(p.date_to, Some(1_705_276_800 + 86_399)); + } + + #[test] + fn parse_after_before() { + let p = parse_search_query("after:2024-01-01 before:2024-06-01 hello"); + assert_eq!(p.text, "hello"); + // 2024-01-01 = 1704067200 + assert_eq!(p.date_from, Some(1_704_067_200)); + // 2024-06-01 = 1717200000 + assert_eq!(p.date_to, Some(1_717_200_000)); + } + + #[test] + fn parse_has_attachment() { + let p = parse_search_query("has:attachment report"); + assert_eq!(p.text, "report"); + assert_eq!(p.has_attachment, Some(true)); + } + + #[test] + fn parse_state_operators() { + let p = parse_search_query("is:unread is:flagged report"); + assert_eq!(p.text, "report"); + assert_eq!(p.unread, Some(true)); + assert_eq!(p.flagged, Some(true)); + + let p2 = parse_search_query("is:read is:unflagged"); + assert_eq!(p2.unread, Some(false)); + assert_eq!(p2.flagged, Some(false)); + } + #[test] + fn parse_multiple_operators() { + let p = parse_search_query( + "from:alice@test.com to:bob@test.com in:INBOX has:attachment meeting", + ); + assert_eq!(p.from.as_deref(), Some("alice@test.com")); + assert_eq!(p.to.as_deref(), Some("bob@test.com")); + assert_eq!(p.folder.as_deref(), Some("INBOX")); + assert_eq!(p.has_attachment, Some(true)); + assert_eq!(p.text, "meeting"); + } + + #[test] + fn parse_quoted_from() { + let p = parse_search_query("from:\"John Doe\" hello"); + assert_eq!(p.from.as_deref(), Some("John Doe")); + assert_eq!(p.text, "hello"); + } + + #[test] + fn parse_unknown_operator_kept_as_text() { + let p = parse_search_query("foo:bar hello"); + assert_eq!(p.text, "foo:bar hello"); + } + + #[test] + fn parse_empty_value_not_operator() { + // "from:" with no value should not be treated as an operator + let p = parse_search_query("hello from:"); + assert_eq!(p.text, "hello from:"); + assert!(p.from.is_none()); + } + + #[test] + fn date_parsing_basic() { + // 2024-01-01 00:00:00 UTC + assert_eq!(parse_date_start("2024-01-01"), Some(1_704_067_200)); + // Invalid date + assert_eq!(parse_date_start("not-a-date"), None); + assert_eq!(parse_date_start("2024-13-01"), None); + } +} diff --git a/src/routes/send.rs b/src/routes/send.rs new file mode 100644 index 0000000..dc6084d --- /dev/null +++ b/src/routes/send.rs @@ -0,0 +1,590 @@ +use std::path::Path; +use std::sync::Arc; + +use axum::response::{IntoResponse, Response}; +use axum::{Extension, Json}; +use serde::{Deserialize, Serialize}; + +use crate::auth::session::SessionState; +use crate::config::AppConfig; +use crate::db; +use crate::error::AppError; +use crate::imap::client::{ImapClient, ImapCredentials}; +use crate::smtp::client::{AttachmentData, SendableMessage, SmtpClient, SmtpCredentials}; +use crate::smtp::types::{PgpMode, PgpSendConfig}; + +#[derive(Deserialize)] +pub struct SendRequest { + pub to: Vec, + #[serde(default)] + pub cc: Vec, + #[serde(default)] + pub bcc: Vec, + #[serde(default)] + pub subject: String, + pub text_body: Option, + pub html_body: Option, + pub in_reply_to: Option, + pub references: Option, + /// If sending from a draft, include the draft ID to load attachments + /// and clean up after send. + pub draft_id: Option, + /// Optional identity ID to use as the From address. + /// If not provided, falls back to the session email. + pub from_identity_id: Option, + /// Request an MDN/read receipt at the selected identity/account address. + #[serde(default)] + pub request_mdn: bool, + /// Message priority: high, normal, or low. + #[serde(default = "default_priority")] + pub priority: String, + /// OpenPGP mode: none, sign, encrypt, or sign-encrypt. When omitted, + /// identity defaults are used. + pub pgp_mode: Option, + /// Override identity default for legacy inline OpenPGP. PGP/MIME is the default. + pub pgp_inline: Option, + /// Passphrase for the selected private key. Never persisted. + pub pgp_passphrase: Option, + /// Attach the sender's public OpenPGP key as application/pgp-keys. + #[serde(default)] + pub pgp_attach_public_key: bool, + /// Attach public OpenPGP keys for visible To/Cc recipients. Bcc is intentionally excluded. + #[serde(default)] + pub pgp_attach_recipient_keys: bool, +} + +fn default_priority() -> String { + "normal".to_string() +} + +fn validate_mailbox(label: &str, value: &str) -> Result<(), AppError> { + value + .parse::() + .map(|_| ()) + .map_err(|_| AppError::BadRequest(format!("Invalid {label} address: {value}"))) +} + +#[derive(Debug, Serialize)] +pub struct SendResponse { + pub status: String, + pub message_id: String, +} + +/// Handler for `POST /api/messages/send`. +/// +/// Validates the request, sends the message via SMTP, and appends a copy +/// to the IMAP "Sent" folder (best-effort). +pub async fn send_message_handler( + Extension(session): Extension, + Extension(config): Extension>, + Extension(smtp_client): Extension>, + Extension(imap_client): Extension>, + Json(req): Json, +) -> Result { + if let Some(ref draft_id) = req.draft_id { + db::drafts::validate_draft_id(draft_id).map_err(AppError::BadRequest)?; + } + + // Validate: at least one recipient. + if req.to.is_empty() && req.cc.is_empty() && req.bcc.is_empty() { + return Err(AppError::BadRequest( + "At least one recipient is required".to_string(), + )); + } + + // Validate: subject or body must be non-empty. + let has_subject = !req.subject.trim().is_empty(); + let has_text = req + .text_body + .as_deref() + .is_some_and(|t| !t.trim().is_empty()); + let has_html = req + .html_body + .as_deref() + .is_some_and(|h| !h.trim().is_empty()); + if !has_subject && !has_text && !has_html { + return Err(AppError::BadRequest( + "Subject or body is required".to_string(), + )); + } + + let priority = req.priority.trim().to_ascii_lowercase(); + if !matches!(priority.as_str(), "high" | "normal" | "low") { + return Err(AppError::BadRequest( + "Priority must be high, normal, or low".to_string(), + )); + } + + // Build SMTP credentials from the authenticated account session. + // Server endpoints are resolved from RustBox configuration at login and stored in the session. + let smtp_creds = SmtpCredentials { + host: session.smtp_host.clone(), + port: session.smtp_port, + tls: session.smtp_tls, + email: session.email.clone(), + password: session.password.clone(), + }; + + // Resolve identity and OpenPGP options before the first network await. + let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) + .map_err(|e| AppError::InternalError(format!("Failed to open database: {e}")))?; + let selected_identity = if let Some(identity_id) = req.from_identity_id { + Some( + db::identities::get_identity(&conn, identity_id) + .map_err(AppError::InternalError)? + .ok_or_else(|| AppError::NotFound("Identity not found".to_string()))?, + ) + } else { + db::identities::get_default_identity(&conn).map_err(AppError::InternalError)? + }; + let receipt_address = selected_identity + .as_ref() + .map(|identity| identity.email.clone()) + .unwrap_or_else(|| session.email.clone()); + let from_address = selected_identity + .as_ref() + .map(|identity| { + if identity.display_name.is_empty() { + identity.email.clone() + } else { + let display_name = identity + .display_name + .replace('\\', "\\\\") + .replace('\"', "\\\""); + format!("\"{display_name}\" <{}>", identity.email) + } + }) + .unwrap_or_else(|| session.email.clone()); + let reply_to = selected_identity.as_ref().and_then(|identity| { + let value = identity.reply_to.trim(); + if value.is_empty() { + None + } else { + Some(value.to_string()) + } + }); + let organization = selected_identity.as_ref().and_then(|identity| { + let value = identity.organization.trim(); + if value.is_empty() { + None + } else { + Some(value.to_string()) + } + }); + let mut bcc = req.bcc; + if let Some(identity) = selected_identity.as_ref() { + for address in identity + .bcc + .split(',') + .map(str::trim) + .filter(|value| !value.is_empty()) + { + if !bcc + .iter() + .any(|existing| existing.eq_ignore_ascii_case(address)) + { + bcc.push(address.to_string()); + } + } + } + + let pgp_mode = resolve_pgp_mode(req.pgp_mode.as_deref(), selected_identity.as_ref())?; + let pgp_inline = req.pgp_inline.unwrap_or_else(|| { + selected_identity + .as_ref() + .is_some_and(|identity| identity.pgp_inline_default) + }); + let attached_public_key = if req.pgp_attach_public_key { + Some(resolve_sender_public_key( + &conn, + selected_identity.as_ref(), + &receipt_address, + )?) + } else { + None + }; + let attached_recipient_keys = if req.pgp_attach_recipient_keys { + resolve_recipient_public_keys(&conn, &req.to, &req.cc)? + } else { + Vec::new() + }; + let pgp = build_pgp_send_config( + &conn, + &config.data_dir, + &session.user_hash, + pgp_mode, + pgp_inline, + req.pgp_passphrase.as_deref().unwrap_or(""), + selected_identity.as_ref(), + &receipt_address, + &req.to, + &req.cc, + &bcc, + )?; + drop(conn); + + // Validate every envelope/header mailbox before touching SMTP. Lettre then + // performs the actual SMTPUTF8 capability negotiation during delivery. + validate_mailbox("From", &from_address)?; + for address in &req.to { + validate_mailbox("To", address)?; + } + for address in &req.cc { + validate_mailbox("Cc", address)?; + } + for address in &bcc { + validate_mailbox("Bcc", address)?; + } + if let Some(address) = reply_to.as_deref() { + validate_mailbox("Reply-To", address)?; + } + if req.request_mdn { + validate_mailbox("read receipt", &receipt_address)?; + } + + // Load attachments from draft if draft_id is provided. + let mut attachments = if let Some(ref draft_id) = req.draft_id { + load_draft_attachments(&config.data_dir, &session.user_hash, draft_id)? + } else { + vec![] + }; + if let Some((key_id, armor)) = attached_public_key { + attachments.push(AttachmentData { + filename: format!("openpgp-{key_id}-public.asc"), + content_type: "application/pgp-keys".to_string(), + data: armor.into_bytes(), + content_id: None, + }); + } + for (key_id, email, armor) in attached_recipient_keys { + let safe_email: String = email + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || matches!(ch, '@' | '.' | '-' | '_') { + ch + } else { + '_' + } + }) + .collect(); + attachments.push(AttachmentData { + filename: format!("openpgp-{key_id}-{safe_email}-public.asc"), + content_type: "application/pgp-keys".to_string(), + data: armor.into_bytes(), + content_id: None, + }); + } + + // Build the sendable message. + let message = SendableMessage { + from: from_address, + to: req.to, + cc: req.cc, + bcc, + reply_to, + organization, + subject: req.subject, + text_body: req.text_body.unwrap_or_default(), + html_body: req.html_body, + in_reply_to: req.in_reply_to, + references: req.references, + read_receipt_to: req.request_mdn.then_some(receipt_address), + priority, + extra_headers: config + .parsed_outgoing_headers() + .map_err(AppError::InternalError)?, + attachments, + pgp, + }; + + // Send via SMTP. The exact RFC822 bytes returned here are also appended + // to IMAP Sent so PGP/MIME signatures and encryption remain identical. + let sent = smtp_client + .send_message(&smtp_creds, &message) + .await + .map_err(|e| AppError::ServiceUnavailable(format!("Failed to send email: {e}")))?; + let message_id = sent.message_id.clone(); + + // Best-effort: append a copy to the "Sent" folder via the account's IMAP server. + // Don't fail the SMTP send if this step fails. + let imap_creds = ImapCredentials { + host: session.imap_host.clone(), + port: session.imap_port, + tls: session.imap_tls, + email: session.email.clone(), + password: session.password.clone(), + }; + + { + let database = db::pool::UserDb::new(&config.data_dir, &session.user_hash); + let sent_folder = database + .run(|conn| { + Ok(db::special_folders::get_role(conn, "sent")? + .or(db::folders::find_folder_by_attribute(conn, "\\Sent")?) + .unwrap_or_else(|| "Sent".to_string())) + }) + .await + .unwrap_or_else(|_| "Sent".to_string()); + + match imap_client + .append_message(&imap_creds, &sent_folder, &sent.rfc822, &["\\Seen"]) + .await + { + Ok(()) => { + let folder = sent_folder.clone(); + if let Err(e) = database + .run(move |conn| db::folders::invalidate_folder_freshness(conn, &folder)) + .await + { + tracing::warn!(error = %e, folder = %sent_folder, "Failed to invalidate sent folder cache after IMAP append"); + } + } + Err(e) => { + tracing::warn!(error = %e, folder = %sent_folder, "Failed to append sent message to configured IMAP sent folder"); + } + } + } + + // Clean up the remote Drafts snapshot only after SMTP delivery succeeds. + // The UID is verified against the stored UIDVALIDITY before deletion. + if let Some(ref draft_id) = req.draft_id { + if let Ok(conn) = db::pool::open_user_db(&config.data_dir, &session.user_hash) + && let Ok(Some(draft)) = db::drafts::get_draft(&conn, draft_id) + { + crate::routes::drafts::remove_remote_draft_snapshot( + imap_client.as_ref(), + &imap_creds, + &draft, + ) + .await; + } + cleanup_draft(&config.data_dir, &session.user_hash, draft_id).await; + } + + Ok(Json(SendResponse { + status: "sent".to_string(), + message_id, + }) + .into_response()) +} + +/// Load attachment data from disk for a given draft. +fn load_draft_attachments( + data_dir: &str, + user_hash: &str, + draft_id: &str, +) -> Result, AppError> { + let conn = db::pool::open_user_db(data_dir, user_hash) + .map_err(|e| AppError::InternalError(format!("Failed to open database: {e}")))?; + + let db_attachments = + db::drafts::get_draft_attachments(&conn, draft_id).map_err(AppError::InternalError)?; + + let mut attachments = Vec::new(); + for att in db_attachments { + let data = std::fs::read(&att.file_path).map_err(|e| { + AppError::InternalError(format!( + "Failed to read attachment file '{}': {e}", + att.filename + )) + })?; + attachments.push(AttachmentData { + filename: att.filename, + content_type: att.content_type, + data, + content_id: Some(att.id), + }); + } + Ok(attachments) +} + +/// Clean up draft record and attachment files from disk after successful send. +async fn cleanup_draft(data_dir: &str, user_hash: &str, draft_id: &str) { + // Delete draft from DB (cascade deletes attachment records). + if let Ok(conn) = db::pool::open_user_db(data_dir, user_hash) + && let Err(e) = db::drafts::delete_draft(&conn, draft_id) + { + tracing::warn!(error = %e, "Failed to delete draft after send"); + } + + // Remove the attachment directory from disk. + let att_dir = Path::new(data_dir) + .join(user_hash) + .join("attachments") + .join(draft_id); + if att_dir.exists() + && let Err(e) = tokio::fs::remove_dir_all(&att_dir).await + { + tracing::warn!(error = %e, path = %att_dir.display(), "Failed to clean up attachment directory"); + } +} + +fn resolve_recipient_public_keys( + conn: &rusqlite::Connection, + to: &[String], + cc: &[String], +) -> Result, AppError> { + let mut resolved = Vec::new(); + let mut seen = std::collections::HashSet::new(); + for value in to.iter().chain(cc) { + let email = mailbox_address(value)?; + let Some(key) = + db::pgp::find_key_internal_by_email(conn, &email).map_err(AppError::InternalError)? + else { + continue; + }; + if seen.insert(key.record.fingerprint.clone()) { + resolved.push((key.record.key_id, email, key.record.public_key_armor)); + } + } + if resolved.is_empty() { + return Err(AppError::BadRequest( + "No OpenPGP public key is available for any To/Cc recipient".to_string(), + )); + } + Ok(resolved) +} + +fn resolve_sender_public_key( + conn: &rusqlite::Connection, + identity: Option<&crate::db::identities::Identity>, + sender_email: &str, +) -> Result<(String, String), AppError> { + if let Some(fingerprint) = identity.and_then(|item| item.pgp_fingerprint.as_deref()) { + if let Some(key) = db::pgp::get_key(conn, fingerprint).map_err(AppError::InternalError)? { + return Ok((key.record.key_id, key.record.public_key_armor)); + } + } + let key = db::pgp::find_key_internal_by_email(conn, sender_email) + .map_err(AppError::InternalError)? + .ok_or_else(|| { + AppError::BadRequest( + "No OpenPGP public key is available for the sending identity".to_string(), + ) + })?; + Ok((key.record.key_id, key.record.public_key_armor)) +} + +fn resolve_pgp_mode( + requested: Option<&str>, + identity: Option<&db::identities::Identity>, +) -> Result { + if let Some(value) = requested { + return match value.trim().to_ascii_lowercase().as_str() { + "" | "none" => Ok(PgpMode::None), + "sign" => Ok(PgpMode::Sign), + "encrypt" => Ok(PgpMode::Encrypt), + "sign-encrypt" | "sign_encrypt" | "sign+encrypt" => Ok(PgpMode::SignEncrypt), + _ => Err(AppError::BadRequest( + "pgpMode must be none, sign, encrypt, or sign-encrypt".to_string(), + )), + }; + } + let Some(identity) = identity else { + return Ok(PgpMode::None); + }; + Ok( + match (identity.pgp_sign_default, identity.pgp_encrypt_default) { + (true, true) => PgpMode::SignEncrypt, + (true, false) => PgpMode::Sign, + (false, true) => PgpMode::Encrypt, + (false, false) => PgpMode::None, + }, + ) +} + +#[allow(clippy::too_many_arguments)] +fn build_pgp_send_config( + conn: &rusqlite::Connection, + data_dir: &str, + user_hash: &str, + mode: PgpMode, + inline: bool, + passphrase: &str, + identity: Option<&db::identities::Identity>, + sender_email: &str, + to: &[String], + cc: &[String], + bcc: &[String], +) -> Result, AppError> { + if mode == PgpMode::None { + return Ok(None); + } + + let needs_signing = matches!(mode, PgpMode::Sign | PgpMode::SignEncrypt); + let needs_encryption = matches!(mode, PgpMode::Encrypt | PgpMode::SignEncrypt); + let mut secret_key_armor = None; + + if needs_signing { + let key = if let Some(fingerprint) = + identity.and_then(|identity| identity.pgp_fingerprint.as_deref()) + { + db::pgp::get_key(conn, fingerprint).map_err(AppError::InternalError)? + } else { + db::pgp::find_key_internal_by_email(conn, sender_email) + .map_err(AppError::InternalError)? + } + .ok_or_else(|| { + AppError::BadRequest( + "No OpenPGP signing key is configured for this identity".to_string(), + ) + })?; + let path = key.secret_key_path.ok_or_else(|| { + AppError::BadRequest("The selected OpenPGP key has no private key material".to_string()) + })?; + secret_key_armor = + Some(crate::pgp::read_secret_key(&path).map_err(AppError::InternalError)?); + } + + let mut recipient_public_keys = Vec::new(); + if needs_encryption { + let prefs = db::pgp::get_preferences(conn).map_err(AppError::InternalError)?; + let mut seen = std::collections::HashSet::new(); + for value in to.iter().chain(cc).chain(bcc) { + let email = mailbox_address(value)?; + let key = db::pgp::find_key_internal_by_email(conn, &email) + .map_err(AppError::InternalError)? + .ok_or_else(|| AppError::BadRequest(format!("Missing OpenPGP key for {email}")))?; + if key.record.trust == "untrusted" { + return Err(AppError::BadRequest(format!( + "OpenPGP key for {email} is explicitly untrusted" + ))); + } + if prefs.require_trusted_encryption + && !matches!(key.record.trust.as_str(), "trusted" | "verified") + { + return Err(AppError::BadRequest(format!( + "OpenPGP key for {email} is not trusted" + ))); + } + if seen.insert(key.record.fingerprint.clone()) { + recipient_public_keys.push(key.record.public_key_armor); + } + } + + // Include the sender's public key when available so the Sent copy can be decrypted later. + if let Some(key) = db::pgp::find_key_internal_by_email(conn, sender_email) + .map_err(AppError::InternalError)? + { + if seen.insert(key.record.fingerprint.clone()) { + recipient_public_keys.push(key.record.public_key_armor); + } + } + } + + let _ = (data_dir, user_hash); // kept in the signature for future key-agent storage without API churn + Ok(Some(PgpSendConfig { + mode, + inline, + secret_key_armor, + passphrase: passphrase.to_string(), + recipient_public_keys, + })) +} + +fn mailbox_address(value: &str) -> Result { + let mailbox: lettre::message::Mailbox = value + .parse() + .map_err(|_| AppError::BadRequest(format!("Invalid email address: {value}")))?; + Ok(mailbox.email.to_string().to_ascii_lowercase()) +} diff --git a/src/routes/sieve.rs b/src/routes/sieve.rs new file mode 100644 index 0000000..775b4e8 --- /dev/null +++ b/src/routes/sieve.rs @@ -0,0 +1,261 @@ +use std::sync::Arc; +use std::time::Duration; + +use axum::extract::Path; +use axum::response::{IntoResponse, Response}; +use axum::{Extension, Json}; +use serde::{Deserialize, Serialize}; + +use crate::auth::session::SessionState; +use crate::config::AppConfig; +use crate::error::AppError; +use crate::sieve::{ManageSieveClient, SieveCredentials, SieveError, SieveScript}; + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SieveStatusResponse { + connected: bool, + host: String, + port: u16, + tls_mode: String, + implementation: Option, + extensions: Vec, + sasl: Vec, +} + +#[derive(Debug, Serialize)] +pub struct ScriptsResponse { + scripts: Vec, +} + +#[derive(Debug, Serialize)] +pub struct ScriptResponse { + name: String, + content: String, +} + +#[derive(Debug, Deserialize)] +pub struct ScriptBody { + content: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RenameBody { + new_name: String, +} + +#[derive(Debug, Serialize)] +pub struct ValidationResponse { + valid: bool, + warning: Option, + error: Option, +} + +#[derive(Debug, Serialize)] +pub struct ActionResponse { + ok: bool, + warning: Option, +} + +fn credentials(config: &AppConfig, session: &SessionState) -> SieveCredentials { + SieveCredentials { + host: config + .sieve_host + .clone() + .unwrap_or_else(|| session.imap_host.clone()), + port: config.sieve_port, + tls_mode: config.sieve_tls_mode.clone(), + allow_insecure_auth: config.sieve_allow_insecure_auth, + username: session.email.clone(), + password: session.password.clone(), + timeout: Duration::from_secs(config.sieve_timeout_secs), + } +} + +fn map_sieve_error(error: SieveError) -> AppError { + match error { + SieveError::Protocol(message) => AppError::BadRequest(message), + SieveError::ScriptTooLarge => AppError::BadRequest("Sieve script exceeds the 1 MiB RustBox limit".to_string()), + SieveError::InsecureAuthenticationBlocked => AppError::BadRequest("ManageSieve plaintext authentication is disabled; use TLS/STARTTLS or explicitly opt in".to_string()), + SieveError::ServerRejected(message) => AppError::BadRequest(message), + SieveError::AuthenticationFailed(message) => AppError::ServiceUnavailable(format!( + "ManageSieve authentication failed: {message}" + )), + SieveError::ConnectionFailed(message) | SieveError::TlsFailed(message) => { + AppError::ServiceUnavailable(message) + } + SieveError::Timeout => AppError::ServiceUnavailable("ManageSieve request timed out".to_string()), + } +} + +async fn connect( + config: &AppConfig, + session: &SessionState, +) -> Result { + ManageSieveClient::connect(&credentials(config, session)) + .await + .map_err(map_sieve_error) +} + +/// GET /api/sieve/status +pub async fn status( + Extension(session): Extension, + Extension(config): Extension>, +) -> Result { + let creds = credentials(&config, &session); + let mut client = ManageSieveClient::connect(&creds) + .await + .map_err(map_sieve_error)?; + let capabilities = client.capabilities().clone(); + client.logout().await; + + Ok(Json(SieveStatusResponse { + connected: true, + host: creds.host, + port: creds.port, + tls_mode: creds.tls_mode, + implementation: capabilities.implementation, + extensions: capabilities.extensions, + sasl: capabilities.sasl, + }) + .into_response()) +} + +/// GET /api/sieve/scripts +pub async fn list_scripts( + Extension(session): Extension, + Extension(config): Extension>, +) -> Result { + let mut client = connect(&config, &session).await?; + let scripts = client.list_scripts().await.map_err(map_sieve_error)?; + client.logout().await; + Ok(Json(ScriptsResponse { scripts }).into_response()) +} + +/// GET /api/sieve/scripts/:name +pub async fn get_script( + Path(name): Path, + Extension(session): Extension, + Extension(config): Extension>, +) -> Result { + let mut client = connect(&config, &session).await?; + let content = client.get_script(&name).await.map_err(map_sieve_error)?; + client.logout().await; + Ok(Json(ScriptResponse { name, content }).into_response()) +} + +/// POST /api/sieve/check +pub async fn check_script( + Extension(session): Extension, + Extension(config): Extension>, + Json(body): Json, +) -> Result { + let mut client = connect(&config, &session).await?; + let result = client.check_script(&body.content).await; + client.logout().await; + + let response = match result { + Ok(warning) => ValidationResponse { + valid: true, + warning, + error: None, + }, + Err(SieveError::ServerRejected(message)) => ValidationResponse { + valid: false, + warning: None, + error: Some(message), + }, + Err(error) => return Err(map_sieve_error(error)), + }; + Ok(Json(response).into_response()) +} + +/// PUT /api/sieve/scripts/:name +pub async fn put_script( + Path(name): Path, + Extension(session): Extension, + Extension(config): Extension>, + Json(body): Json, +) -> Result { + let mut client = connect(&config, &session).await?; + let warning = client + .put_script(&name, &body.content) + .await + .map_err(map_sieve_error)?; + client.logout().await; + Ok(Json(ActionResponse { ok: true, warning }).into_response()) +} + +/// POST /api/sieve/scripts/:name/activate +pub async fn activate_script( + Path(name): Path, + Extension(session): Extension, + Extension(config): Extension>, +) -> Result { + let mut client = connect(&config, &session).await?; + client + .activate_script(Some(&name)) + .await + .map_err(map_sieve_error)?; + client.logout().await; + Ok(Json(ActionResponse { + ok: true, + warning: None, + }) + .into_response()) +} + +/// POST /api/sieve/deactivate +pub async fn deactivate_script( + Extension(session): Extension, + Extension(config): Extension>, +) -> Result { + let mut client = connect(&config, &session).await?; + client + .activate_script(None) + .await + .map_err(map_sieve_error)?; + client.logout().await; + Ok(Json(ActionResponse { + ok: true, + warning: None, + }) + .into_response()) +} + +/// DELETE /api/sieve/scripts/:name +pub async fn delete_script( + Path(name): Path, + Extension(session): Extension, + Extension(config): Extension>, +) -> Result { + let mut client = connect(&config, &session).await?; + client.delete_script(&name).await.map_err(map_sieve_error)?; + client.logout().await; + Ok(Json(ActionResponse { + ok: true, + warning: None, + }) + .into_response()) +} + +/// POST /api/sieve/scripts/:name/rename +pub async fn rename_script( + Path(name): Path, + Extension(session): Extension, + Extension(config): Extension>, + Json(body): Json, +) -> Result { + let mut client = connect(&config, &session).await?; + client + .rename_script(&name, &body.new_name) + .await + .map_err(map_sieve_error)?; + client.logout().await; + Ok(Json(ActionResponse { + ok: true, + warning: None, + }) + .into_response()) +} diff --git a/src/routes/special_folders.rs b/src/routes/special_folders.rs new file mode 100644 index 0000000..56b23d5 --- /dev/null +++ b/src/routes/special_folders.rs @@ -0,0 +1,39 @@ +use std::sync::Arc; + +use axum::response::{IntoResponse, Response}; +use axum::{Extension, Json}; + +use crate::auth::session::SessionState; +use crate::config::AppConfig; +use crate::db; +use crate::db::special_folders::SpecialFolders; +use crate::error::AppError; + +pub async fn get_special_folders( + Extension(session): Extension, + Extension(config): Extension>, +) -> Result { + let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) + .map_err(|e| AppError::InternalError(format!("Failed to open database: {e}")))?; + let value = db::special_folders::get_all(&conn).map_err(AppError::InternalError)?; + Ok(Json(value).into_response()) +} + +pub async fn update_special_folders( + Extension(session): Extension, + Extension(config): Extension>, + Json(value): Json, +) -> Result { + let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) + .map_err(|e| AppError::InternalError(format!("Failed to open database: {e}")))?; + let saved = db::special_folders::update_all(&conn, &value).map_err(|message| { + if message.starts_with("Folder does not exist:") + || message.starts_with("Invalid special folder role:") + { + AppError::BadRequest(message) + } else { + AppError::InternalError(message) + } + })?; + Ok(Json(saved).into_response()) +} diff --git a/src/routes/tags.rs b/src/routes/tags.rs new file mode 100644 index 0000000..f51fd4b --- /dev/null +++ b/src/routes/tags.rs @@ -0,0 +1,578 @@ +use std::sync::Arc; + +use axum::extract::{Path, Query}; +use axum::response::{IntoResponse, Response}; +use axum::{Extension, Json}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::auth::session::SessionState; +use crate::config::AppConfig; +use crate::db; +use crate::db::tags::{MessageTag, Tag}; +use crate::error::AppError; +use crate::imap::client::{ImapClient, ImapCredentials}; +use crate::routes::messages::types::{MessageSummary, default_per_page}; + +// --------------------------------------------------------------------------- +// Request / response types +// --------------------------------------------------------------------------- + +#[derive(Deserialize)] +pub struct CreateTagBody { + pub name: String, + pub color: Option, +} + +#[derive(Deserialize)] +pub struct UpdateTagBody { + pub name: String, + pub color: String, +} + +#[derive(Deserialize)] +pub struct TagMessageBody { + pub message_uid: u32, + pub message_folder: String, +} + +#[derive(Deserialize)] +pub struct BulkTagBody { + pub messages: Vec, +} + +#[derive(Deserialize)] +pub struct TagMessageRef { + pub uid: u32, + pub folder: String, +} + +#[derive(Deserialize)] +pub struct PaginationQuery { + #[serde(default)] + pub page: u32, + #[serde(default = "default_per_page")] + pub per_page: u32, +} + +#[derive(Serialize)] +pub struct ListTagsResponse { + pub tags: Vec, +} + +#[derive(Serialize)] +pub struct TagMessagesResponse { + pub messages: Vec, + pub total_count: u32, + pub page: u32, + pub per_page: u32, +} + +#[derive(Serialize)] +pub struct MessageTagsResponse { + pub tags: Vec, +} + +#[derive(Serialize)] +pub struct ReconcileTagsResponse { + pub folders_checked: usize, + pub messages_checked: usize, + pub labels_added: usize, + pub labels_removed: usize, + pub skipped_folders: usize, +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// Build a `MessageSummary` from a cached message and its tags. +fn build_summary(m: db::messages::CachedMessage, tags: Vec) -> MessageSummary { + MessageSummary { + uid: m.uid, + folder: m.folder, + subject: m.subject, + from_address: m.from_address, + from_name: m.from_name, + to_addresses: m.to_addresses, + date: m.date, + flags: m.flags, + size: m.size, + has_attachments: m.has_attachments, + snippet: m.snippet, + reaction: m.reaction, + tags, + } +} + +fn imap_credentials(session: &SessionState) -> ImapCredentials { + ImapCredentials { + host: session.imap_host.clone(), + port: session.imap_port, + tls: session.imap_tls, + email: session.email.clone(), + password: session.password.clone(), + } +} + +fn keyword_sync_enabled(conn: &rusqlite::Connection) -> bool { + db::mail_preferences::get_preferences(conn) + .map(|prefs| prefs.sync_imap_keywords) + .unwrap_or(false) +} + +async fn sync_keyword_to_imap( + client: &dyn ImapClient, + session: &SessionState, + folder: &str, + uid: u32, + keyword: &str, + add: bool, +) -> bool { + if keyword.is_empty() || session.imap_host.trim().is_empty() { + return false; + } + let creds = imap_credentials(session); + if add { + match client.folder_keyword_support(&creds, folder).await { + Ok(true) => client + .add_flags(&creds, folder, uid, &[keyword]) + .await + .is_ok(), + Ok(false) => false, + Err(error) => { + tracing::warn!(error = %error, folder = %folder, uid, "Failed to check IMAP keyword support"); + false + } + } + } else { + client + .remove_flags(&creds, folder, uid, &[keyword]) + .await + .is_ok() + } +} +fn validate_tag_color(color: &str) -> Result<(), AppError> { + let bytes = color.as_bytes(); + if bytes.len() == 7 + && bytes[0] == b'#' + && bytes[1..].iter().all(|byte| byte.is_ascii_hexdigit()) + { + Ok(()) + } else { + Err(AppError::BadRequest( + "Tag color must use #RRGGBB format".to_string(), + )) + } +} + +// --------------------------------------------------------------------------- +// Handlers +// --------------------------------------------------------------------------- + +/// `GET /api/tags` +pub async fn list_tags_handler( + Extension(session): Extension, + Extension(config): Extension>, +) -> Result { + let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + + let tags = db::tags::list_tags(&conn) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + + Ok(Json(ListTagsResponse { tags }).into_response()) +} + +/// `POST /api/tags` +pub async fn create_tag_handler( + Extension(session): Extension, + Extension(config): Extension>, + Json(body): Json, +) -> Result { + let name = body.name.trim(); + if name.is_empty() { + return Err(AppError::BadRequest("Tag name is required".to_string())); + } + + let color = body.color.as_deref().unwrap_or("#6b7280"); + validate_tag_color(color)?; + + let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + + let id = Uuid::new_v4().to_string(); + db::tags::create_tag(&conn, &id, name, color) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + + Ok(Json(serde_json::json!({ "id": id, "name": name, "color": color })).into_response()) +} + +/// `PUT /api/tags/{id}` +pub async fn update_tag_handler( + Extension(session): Extension, + Extension(config): Extension>, + Path(id): Path, + Json(body): Json, +) -> Result { + let name = body.name.trim(); + if name.is_empty() { + return Err(AppError::BadRequest("Tag name is required".to_string())); + } + validate_tag_color(&body.color)?; + + let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + + let updated = db::tags::update_tag(&conn, &id, name, &body.color) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + + if updated { + Ok( + Json(serde_json::json!({ "id": id, "name": name, "color": body.color })) + .into_response(), + ) + } else { + Err(AppError::NotFound(format!("Tag '{id}' not found"))) + } +} + +/// `DELETE /api/tags/{id}` +pub async fn delete_tag_handler( + Extension(session): Extension, + Extension(config): Extension>, + Extension(imap_client): Extension>, + Path(id): Path, +) -> Result { + // Phase 1: read everything needed from SQLite, then drop the connection + // before the first network await so the Axum handler future stays Send. + let (tag, refs) = { + let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + let tag = db::tags::get_tag(&conn, &id) + .map_err(AppError::InternalError)? + .ok_or_else(|| AppError::NotFound(format!("Tag '{id}' not found")))?; + let refs = if keyword_sync_enabled(&conn) { + db::tags::get_message_refs_by_tag(&conn, &id).unwrap_or_default() + } else { + Vec::new() + }; + (tag, refs) + }; + + // Phase 2: best-effort remote cleanup while the tag still exists locally. + let mut synced = 0usize; + for (uid, folder) in &refs { + if sync_keyword_to_imap( + imap_client.as_ref(), + &session, + folder, + *uid, + &tag.imap_keyword, + false, + ) + .await + { + synced += 1; + } + } + + // Phase 3: delete the local tag after the async work has finished. + let deleted = { + let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + db::tags::delete_tag(&conn, &id) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))? + }; + if !deleted { + return Err(AppError::NotFound(format!("Tag '{id}' not found"))); + } + + Ok(Json(serde_json::json!({ + "status": "deleted", + "imap_keywords_removed": synced, + "imap_keywords_expected": refs.len() + })) + .into_response()) +} + +/// `POST /api/tags/{id}/messages` +pub async fn tag_message_handler( + Extension(session): Extension, + Extension(config): Extension>, + Extension(imap_client): Extension>, + Path(id): Path, + Json(body): Json, +) -> Result { + let (tag_keyword, enabled) = { + let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + let tag = db::tags::get_tag(&conn, &id) + .map_err(AppError::InternalError)? + .ok_or_else(|| AppError::NotFound(format!("Tag '{id}' not found")))?; + db::tags::add_tag_to_message(&conn, &id, body.message_uid, &body.message_folder) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + (tag.imap_keyword, keyword_sync_enabled(&conn)) + }; + + let synced = if enabled { + sync_keyword_to_imap( + imap_client.as_ref(), + &session, + &body.message_folder, + body.message_uid, + &tag_keyword, + true, + ) + .await + } else { + false + }; + + Ok(Json(serde_json::json!({ "status": "ok", "imap_keyword_sync_enabled": enabled, "imap_keyword_synced": synced })).into_response()) +} + +/// `DELETE /api/tags/{id}/messages/{folder}/{uid}` +pub async fn untag_message_handler( + Extension(session): Extension, + Extension(config): Extension>, + Extension(imap_client): Extension>, + Path((id, folder, uid)): Path<(String, String, u32)>, +) -> Result { + let (tag_keyword, removed, enabled) = { + let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + let tag_keyword = db::tags::get_tag(&conn, &id) + .map_err(AppError::InternalError)? + .map(|tag| tag.imap_keyword); + let removed = db::tags::remove_tag_from_message(&conn, &id, uid, &folder) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + (tag_keyword, removed, keyword_sync_enabled(&conn)) + }; + + if !removed { + return Err(AppError::NotFound("Tag not found on message".to_string())); + } + + let synced = if enabled { + if let Some(tag_keyword) = tag_keyword { + sync_keyword_to_imap( + imap_client.as_ref(), + &session, + &folder, + uid, + &tag_keyword, + false, + ) + .await + } else { + false + } + } else { + false + }; + + Ok(Json(serde_json::json!({ "status": "ok", "imap_keyword_sync_enabled": enabled, "imap_keyword_synced": synced })).into_response()) +} + +/// `POST /api/tags/{id}/messages/bulk` +pub async fn bulk_tag_handler( + Extension(session): Extension, + Extension(config): Extension>, + Extension(imap_client): Extension>, + Path(id): Path, + Json(body): Json, +) -> Result { + let (tag_keyword, enabled) = { + let mut conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + + let tx = conn + .transaction() + .map_err(|e| AppError::InternalError(format!("Transaction error: {e}")))?; + + for msg in &body.messages { + db::tags::add_tag_to_message(&tx, &id, msg.uid, &msg.folder) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + } + + tx.commit() + .map_err(|e| AppError::InternalError(format!("Transaction commit error: {e}")))?; + + let enabled = keyword_sync_enabled(&conn); + let tag_keyword = db::tags::get_tag(&conn, &id) + .map_err(AppError::InternalError)? + .map(|tag| tag.imap_keyword); + (tag_keyword, enabled) + }; + + let mut synced = 0usize; + if enabled { + if let Some(tag_keyword) = tag_keyword { + for msg in &body.messages { + if sync_keyword_to_imap( + imap_client.as_ref(), + &session, + &msg.folder, + msg.uid, + &tag_keyword, + true, + ) + .await + { + synced += 1; + } + } + } + } + + Ok(Json(serde_json::json!({ "status": "ok", "count": body.messages.len(), "imap_keyword_sync_enabled": enabled, "imap_keywords_synced": synced })).into_response()) +} + +/// `POST /api/tags/reconcile` +/// +/// Reconcile local RustBox labels against authoritative IMAP FLAGS. Folders +/// that do not advertise persistent custom-keyword support are skipped so +/// local-only labels are never removed by accident. +pub async fn reconcile_tags_handler( + Extension(session): Extension, + Extension(config): Extension>, + Extension(imap_client): Extension>, +) -> Result { + let enabled = { + let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + keyword_sync_enabled(&conn) + }; + if !enabled { + return Err(AppError::BadRequest( + "IMAP keyword synchronization is disabled in Mailbox settings".to_string(), + )); + } + + let creds = imap_credentials(&session); + let folders = imap_client + .list_folders(&creds) + .await + .map_err(|e| AppError::ServiceUnavailable(format!("IMAP error: {e}")))?; + let mut remote = Vec::new(); + let mut skipped_folders = 0usize; + for folder in folders { + if folder + .attributes + .iter() + .any(|attr| attr.eq_ignore_ascii_case("\\Noselect")) + { + skipped_folders += 1; + continue; + } + match imap_client + .folder_keyword_support(&creds, &folder.name) + .await + { + Ok(true) => { + let flags = imap_client + .fetch_uids_and_flags(&creds, &folder.name) + .await + .map_err(|e| AppError::ServiceUnavailable(format!("IMAP error: {e}")))?; + remote.push((folder.name, flags)); + } + Ok(false) => skipped_folders += 1, + Err(error) => { + tracing::warn!(folder = %folder.name, error = %error, "Skipping label reconcile for folder"); + skipped_folders += 1; + } + } + } + + let (messages_checked, labels_added, labels_removed) = { + let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + let tx = conn + .unchecked_transaction() + .map_err(|e| AppError::InternalError(format!("Transaction error: {e}")))?; + let mut messages_checked = 0usize; + let mut labels_added = 0usize; + let mut labels_removed = 0usize; + for (folder, messages) in &remote { + for (uid, flags) in messages { + let (added, removed) = db::tags::reconcile_imap_keywords(&tx, *uid, folder, flags) + .map_err(|e| { + AppError::InternalError(format!("IMAP keyword reconcile error: {e}")) + })?; + labels_added += added; + labels_removed += removed; + messages_checked += 1; + } + } + tx.commit() + .map_err(|e| AppError::InternalError(format!("Transaction commit error: {e}")))?; + (messages_checked, labels_added, labels_removed) + }; + + Ok(Json(ReconcileTagsResponse { + folders_checked: remote.len(), + messages_checked, + labels_added, + labels_removed, + skipped_folders, + }) + .into_response()) +} + +/// `GET /api/tags/{id}/messages` +pub async fn list_tag_messages_handler( + Extension(session): Extension, + Extension(config): Extension>, + Path(id): Path, + Query(query): Query, +) -> Result { + let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + + let messages = db::tags::get_messages_by_tag(&conn, &id, query.page, query.per_page) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + let total_count = db::tags::count_messages_by_tag(&conn, &id) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + + // Batch-fetch tags for returned messages + let message_refs: Vec<(u32, &str)> = messages + .iter() + .map(|m| (m.uid, m.folder.as_str())) + .collect(); + let tags_map = db::tags::get_tags_for_messages(&conn, &message_refs).unwrap_or_default(); + + let summaries: Vec = messages + .into_iter() + .map(|m| { + let msg_tags = tags_map + .get(&(m.uid, m.folder.clone())) + .cloned() + .unwrap_or_default(); + build_summary(m, msg_tags) + }) + .collect(); + + Ok(Json(TagMessagesResponse { + messages: summaries, + total_count, + page: query.page, + per_page: query.per_page, + }) + .into_response()) +} + +/// `GET /api/messages/{folder}/{uid}/tags` +pub async fn get_message_tags_handler( + Extension(session): Extension, + Extension(config): Extension>, + Path((folder, uid)): Path<(String, u32)>, +) -> Result { + let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + + let tags = db::tags::get_message_tags(&conn, uid, &folder) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + + Ok(Json(MessageTagsResponse { tags }).into_response()) +} diff --git a/src/routes/tests.rs b/src/routes/tests.rs new file mode 100644 index 0000000..9b486e5 --- /dev/null +++ b/src/routes/tests.rs @@ -0,0 +1,2238 @@ +use super::*; +use axum::body::Body; +use axum::extract::ConnectInfo; +use axum::http::{HeaderValue, Request, StatusCode}; +use http_body_util::BodyExt; +use std::fs; +use std::net::{IpAddr, SocketAddr}; +use std::time::Duration; +use tempfile::TempDir; +use tower::ServiceExt; +use tower_governor::key_extractor::KeyExtractor; + +use crate::imap::client::mock::MockImapClient; +use crate::imap::client::{ + EmailAddress, ImapAttachment, ImapError, ImapFolder, ImapMessageBody, ImapMessageHeader, +}; +use crate::smtp::client::SmtpError; +use crate::smtp::client::mock::MockSmtpClient; + +/// Helper: create a test AppConfig with the given static dir. +fn test_config(_static_dir: &str) -> Arc { + Arc::new(AppConfig { + host: "127.0.0.1".to_string(), + port: 3100, + imap_host: None, + imap_port: 993, + smtp_host: None, + smtp_port: 587, + tls_enabled: true, + data_dir: "/tmp/oxi-test".to_string(), + session_timeout_hours: 24, + environment: "development".to_string(), + base_path: None, + sieve_host: None, + sieve_port: 4190, + sieve_tls_mode: "starttls".to_string(), + sieve_allow_insecure_auth: false, + sieve_timeout_secs: 10, + trusted_proxies: None, + outgoing_headers: Vec::new(), + }) +} + +/// Helper: create a test AppConfig with IMAP host configured and a custom data dir. +fn test_config_with_imap(_static_dir: &str, data_dir: &str) -> Arc { + Arc::new(AppConfig { + host: "127.0.0.1".to_string(), + port: 3100, + imap_host: Some("imap.example.com".to_string()), + imap_port: 993, + smtp_host: None, + smtp_port: 587, + tls_enabled: true, + data_dir: data_dir.to_string(), + session_timeout_hours: 24, + environment: "development".to_string(), + base_path: None, + sieve_host: None, + sieve_port: 4190, + sieve_tls_mode: "starttls".to_string(), + sieve_allow_insecure_auth: false, + sieve_timeout_secs: 10, + trusted_proxies: None, + outgoing_headers: Vec::new(), + }) +} + +/// Helper: create a test AppConfig with IMAP + SMTP hosts configured. +fn test_config_with_smtp(_static_dir: &str, data_dir: &str) -> Arc { + Arc::new(AppConfig { + host: "127.0.0.1".to_string(), + port: 3001, + imap_host: Some("imap.example.com".to_string()), + imap_port: 993, + smtp_host: Some("smtp.example.com".to_string()), + smtp_port: 587, + tls_enabled: true, + data_dir: data_dir.to_string(), + session_timeout_hours: 24, + environment: "development".to_string(), + base_path: None, + sieve_host: None, + sieve_port: 4190, + sieve_tls_mode: "starttls".to_string(), + sieve_allow_insecure_auth: false, + sieve_timeout_secs: 10, + trusted_proxies: None, + outgoing_headers: Vec::new(), + }) +} + +/// Helper: create a fresh SessionStore for tests. +fn test_store() -> Arc { + Arc::new(SessionStore::new(Duration::from_secs(3600))) +} + +/// Helper: create a default mock IMAP client. +fn test_imap_client() -> Arc { + Arc::new(MockImapClient::new()) +} + +/// Helper: create a default mock SMTP client. +fn test_smtp_client() -> Arc { + Arc::new(MockSmtpClient::new()) +} + +/// Helper: create a temporary static directory with an index.html. +fn setup_static_dir() -> TempDir { + let dir = TempDir::new().expect("should create temp dir"); + fs::write( + dir.path().join("index.html"), + "SPA", + ) + .expect("should write index.html"); + dir +} + +/// Helper: create a test SearchEngine backed by the given data directory. +fn test_search_engine(data_dir: &str) -> Arc { + Arc::new(crate::search::engine::SearchEngine::new( + std::path::PathBuf::from(data_dir), + )) +} + +/// Helper: create a test EventBus. +fn test_event_bus() -> Arc { + Arc::new(crate::realtime::events::EventBus::new()) +} + +/// Helper: create a test IdleManager. +fn test_idle_manager() -> Arc { + Arc::new(crate::realtime::idle::IdleManager::new()) +} + +fn test_proxy_extractor(trusted_proxies: Option<&str>) -> ProxyAwareIpExtractor { + let mut config = (*test_config("/tmp")).clone(); + config.trusted_proxies = trusted_proxies.map(str::to_string); + ProxyAwareIpExtractor { + trusted_proxies: config.parsed_trusted_proxies(), + } +} + +fn extract_ip(extractor: &ProxyAwareIpExtractor, peer: &str, forwarded: Option<&str>) -> IpAddr { + let peer_addr = SocketAddr::new(peer.parse().unwrap(), 443); + let mut request = Request::builder().uri("/api/auth/login").body(()).unwrap(); + request + .extensions_mut() + .insert(ConnectInfo::(peer_addr)); + + if let Some(forwarded) = forwarded { + request + .headers_mut() + .insert("x-forwarded-for", HeaderValue::from_str(forwarded).unwrap()); + } + + extractor.extract(&request).unwrap() +} + +/// Helper: create a multi-account session for testing protected routes. +/// Returns (browser_id, account_id, token) for use in request headers. +fn setup_test_account(store: &SessionStore, email: &str) -> (String, String, String) { + let browser_id = store.create_browser(); + let (token, account_id) = store.add_account_to_browser( + &browser_id, + email.to_string(), + "password".to_string(), + crate::auth::user_data::hash_email(email), + "imap.example.com".to_string(), + 993, + true, + "smtp.example.com".to_string(), + 587, + true, + ); + (browser_id, account_id, token) +} + +/// Helper: build auth headers for multi-account requests. +fn auth_headers(browser_id: &str, account_id: &str, token: &str) -> Vec<(&'static str, String)> { + vec![ + ( + "cookie", + format!("rustbox_browser={browser_id}; rustbox_session_{account_id}={token}"), + ), + ("x-active-account", account_id.to_string()), + ] +} + +/// Helper: provision a user database so that route handlers can open it. +/// Migrations are applied automatically by `open_user_db`. +fn provision_user_db(data_dir: &str, user_hash: &str) { + let _conn = crate::db::pool::open_user_db(data_dir, user_hash).unwrap(); +} + +// ----------------------------------------------------------------------- +// Proxy-aware IP extraction tests +// ----------------------------------------------------------------------- + +#[test] +fn trusted_ipv4_proxy_uses_forwarded_client_ip() { + let extractor = test_proxy_extractor(Some("10.0.0.0/8")); + + let client_ip = extract_ip( + &extractor, + "10.0.0.10", + Some("203.0.113.9, 10.1.1.1, 10.2.2.2"), + ); + + assert_eq!(client_ip, "203.0.113.9".parse::().unwrap()); +} + +#[test] +fn repeated_forwarded_headers_use_the_full_proxy_chain() { + let extractor = test_proxy_extractor(Some("10.0.0.0/8")); + let mut request = Request::builder().uri("/api/auth/login").body(()).unwrap(); + request + .extensions_mut() + .insert(ConnectInfo::("10.0.0.10:443".parse().unwrap())); + request + .headers_mut() + .append("x-forwarded-for", HeaderValue::from_static("203.0.113.99")); + request.headers_mut().append( + "x-forwarded-for", + HeaderValue::from_static("198.51.100.20, 10.1.1.1"), + ); + + assert_eq!( + extractor.extract(&request).unwrap(), + "198.51.100.20".parse::().unwrap() + ); +} + +#[test] +fn untrusted_peer_ignores_spoofed_forwarded_header() { + let extractor = test_proxy_extractor(Some("10.0.0.0/8")); + + let client_ip = extract_ip(&extractor, "198.51.100.20", Some("203.0.113.9, 10.1.1.1")); + + assert_eq!(client_ip, "198.51.100.20".parse::().unwrap()); +} + +#[test] +fn trusted_ipv6_cidr_uses_forwarded_client_ip() { + let extractor = test_proxy_extractor(Some("2001:db8:abcd::/48")); + + let client_ip = extract_ip( + &extractor, + "2001:db8:abcd::10", + Some("2001:db8::123, 2001:db8:abcd::20"), + ); + + assert_eq!(client_ip, "2001:db8::123".parse::().unwrap()); +} + +#[test] +fn malformed_forwarded_header_falls_back_to_peer_ip() { + let extractor = test_proxy_extractor(Some("10.0.0.0/8")); + + let client_ip = extract_ip(&extractor, "10.0.0.10", Some("203.0.113.9, garbage")); + + assert_eq!(client_ip, "10.0.0.10".parse::().unwrap()); +} + +// ----------------------------------------------------------------------- +// Existing tests (updated to pass imap_client) +// ----------------------------------------------------------------------- + +#[tokio::test] +async fn api_health_works_with_static_fallback() { + let dir = setup_static_dir(); + let config = test_config(dir.path().to_str().unwrap()); + let store = test_store(); + let app = create_router( + config, + store, + test_imap_client(), + test_smtp_client(), + test_search_engine("/tmp/oxi-test"), + test_event_bus(), + test_idle_manager(), + ); + + let response = app + .oneshot( + Request::builder() + .uri("/api/health") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(json["status"], "ok"); +} + +#[tokio::test] +async fn root_serves_embedded_rustbox_ui() { + let dir = setup_static_dir(); + let config = test_config(dir.path().to_str().unwrap()); + let app = create_router( + config, + test_store(), + test_imap_client(), + test_smtp_client(), + test_search_engine("/tmp/oxi-test"), + test_event_bus(), + test_idle_manager(), + ); + + let response = app + .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let html = String::from_utf8(body.to_vec()).unwrap(); + assert!(html.contains("RustBox")); + assert!(html.contains("assets/app.js")); +} + +#[tokio::test] +async fn embedded_css_is_served_by_rustbox() { + let dir = setup_static_dir(); + let config = test_config(dir.path().to_str().unwrap()); + let app = create_router( + config, + test_store(), + test_imap_client(), + test_smtp_client(), + test_search_engine("/tmp/oxi-test"), + test_event_bus(), + test_idle_manager(), + ); + + let response = app + .oneshot( + Request::builder() + .uri("/assets/app.css") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.headers()["content-type"], + "text/css; charset=utf-8" + ); + let body = response.into_body().collect().await.unwrap().to_bytes(); + assert!( + String::from_utf8(body.to_vec()) + .unwrap() + .contains("--accent") + ); +} + +#[tokio::test] +async fn base_path_serves_the_same_embedded_ui() { + let dir = setup_static_dir(); + let mut config = (*test_config(dir.path().to_str().unwrap())).clone(); + config.base_path = Some("/mail".to_string()); + let app = create_router( + Arc::new(config), + test_store(), + test_imap_client(), + test_smtp_client(), + test_search_engine("/tmp/oxi-test"), + test_event_bus(), + test_idle_manager(), + ); + + let response = app + .oneshot( + Request::builder() + .uri("/mail/") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let html = String::from_utf8(body.to_vec()).unwrap(); + assert!(html.contains("data-base-path=\"/mail\"")); + assert!(html.contains("/mail/assets/app.js")); +} + +#[tokio::test] +async fn login_without_csrf_header_returns_403() { + let dir = setup_static_dir(); + let config = test_config(dir.path().to_str().unwrap()); + let store = test_store(); + let app = create_router( + config, + store, + test_imap_client(), + test_smtp_client(), + test_search_engine("/tmp/oxi-test"), + test_event_bus(), + test_idle_manager(), + ); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/auth/login") + .header("content-type", "application/json") + .body(Body::from(r#"{"email":"test@test.com","password":"pass"}"#)) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::FORBIDDEN); +} + +#[tokio::test] +async fn login_no_imap_host_returns_503() { + let dir = setup_static_dir(); + let config = test_config(dir.path().to_str().unwrap()); + let store = test_store(); + let app = create_router( + config, + store, + test_imap_client(), + test_smtp_client(), + test_search_engine("/tmp/oxi-test"), + test_event_bus(), + test_idle_manager(), + ); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/auth/login") + .header("content-type", "application/json") + .header("x-requested-with", "XMLHttpRequest") + .body(Body::from(r#"{"email":"test@test.com","password":"pass"}"#)) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(json["error"]["code"], "SERVICE_UNAVAILABLE"); + assert_eq!( + json["error"]["message"], + "IMAP server is not configured in RustBox" + ); +} + +#[tokio::test] +async fn login_empty_email_returns_400() { + let dir = setup_static_dir(); + let mut cfg = (*test_config(dir.path().to_str().unwrap())).clone(); + cfg.imap_host = Some("127.0.0.1".to_string()); + let config = Arc::new(cfg); + let store = test_store(); + let app = create_router( + config, + store, + test_imap_client(), + test_smtp_client(), + test_search_engine("/tmp/oxi-test"), + test_event_bus(), + test_idle_manager(), + ); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/auth/login") + .header("content-type", "application/json") + .header("x-requested-with", "XMLHttpRequest") + .body(Body::from(r#"{"email":"","password":"pass"}"#)) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(json["error"]["code"], "BAD_REQUEST"); +} + +#[tokio::test] +async fn login_empty_password_returns_400() { + let dir = setup_static_dir(); + let mut cfg = (*test_config(dir.path().to_str().unwrap())).clone(); + cfg.imap_host = Some("127.0.0.1".to_string()); + let config = Arc::new(cfg); + let store = test_store(); + let app = create_router( + config, + store, + test_imap_client(), + test_smtp_client(), + test_search_engine("/tmp/oxi-test"), + test_event_bus(), + test_idle_manager(), + ); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/auth/login") + .header("content-type", "application/json") + .header("x-requested-with", "XMLHttpRequest") + .body(Body::from(r#"{"email":"test@test.com","password":""}"#)) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn login_unreachable_imap_returns_503() { + let dir = setup_static_dir(); + let mut cfg = (*test_config(dir.path().to_str().unwrap())).clone(); + cfg.imap_host = Some("127.0.0.1".to_string()); + cfg.imap_port = 19999; // Nothing listening here + cfg.tls_enabled = false; + let config = Arc::new(cfg); + let store = test_store(); + let app = create_router( + config, + store, + test_imap_client(), + test_smtp_client(), + test_search_engine("/tmp/oxi-test"), + test_event_bus(), + test_idle_manager(), + ); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/auth/login") + .header("content-type", "application/json") + .header("x-requested-with", "XMLHttpRequest") + .body(Body::from(r#"{"email":"test@test.com","password":"pass"}"#)) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(json["error"]["code"], "SERVER_UNREACHABLE"); + assert!( + json["error"]["message"] + .as_str() + .unwrap() + .contains("Connection refused") + ); +} + +#[tokio::test] +async fn get_session_without_auth_returns_401() { + let dir = setup_static_dir(); + let config = test_config(dir.path().to_str().unwrap()); + let store = test_store(); + let app = create_router( + config, + store, + test_imap_client(), + test_smtp_client(), + test_search_engine("/tmp/oxi-test"), + test_event_bus(), + test_idle_manager(), + ); + + let response = app + .oneshot( + Request::builder() + .uri("/api/auth/session") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn get_session_with_valid_session_returns_200() { + let dir = setup_static_dir(); + let config = test_config(dir.path().to_str().unwrap()); + let store = test_store(); + let (browser_id, account_id, token) = setup_test_account(&store, "alice@example.com"); + let app = create_router( + config, + store, + test_imap_client(), + test_smtp_client(), + test_search_engine("/tmp/oxi-test"), + test_event_bus(), + test_idle_manager(), + ); + + let mut req = Request::builder().uri("/api/auth/session"); + for (name, value) in auth_headers(&browser_id, &account_id, &token) { + req = req.header(name, value); + } + let response = app.oneshot(req.body(Body::empty()).unwrap()).await.unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(json["user"]["email"], "alice@example.com"); +} + +#[tokio::test] +async fn logout_without_auth_returns_401() { + let dir = setup_static_dir(); + let config = test_config(dir.path().to_str().unwrap()); + let store = test_store(); + let app = create_router( + config, + store, + test_imap_client(), + test_smtp_client(), + test_search_engine("/tmp/oxi-test"), + test_event_bus(), + test_idle_manager(), + ); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/auth/logout") + .header("x-requested-with", "XMLHttpRequest") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn logout_with_valid_session_returns_200() { + let dir = setup_static_dir(); + let config = test_config(dir.path().to_str().unwrap()); + let store = test_store(); + let (browser_id, account_id, token) = setup_test_account(&store, "alice@example.com"); + let app = create_router( + config, + store.clone(), + test_imap_client(), + test_smtp_client(), + test_search_engine("/tmp/oxi-test"), + test_event_bus(), + test_idle_manager(), + ); + + let mut req = Request::builder() + .method("POST") + .uri("/api/auth/logout") + .header("x-requested-with", "XMLHttpRequest"); + for (name, value) in auth_headers(&browser_id, &account_id, &token) { + req = req.header(name, value); + } + let response = app.oneshot(req.body(Body::empty()).unwrap()).await.unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(json["status"], "logged_out"); + + // Session should be removed from the store. + assert!(store.get(&token).is_none()); +} + +#[tokio::test] +async fn base_path_mounts_protected_data_routes_and_scopes_logout_cookies() { + for (base_path, prefix, cookie_path) in [ + (None, "", "/"), + (Some(""), "", "/"), + (Some("/"), "", "/"), + (Some("/oxi"), "/oxi", "/oxi"), + ] { + let static_dir = setup_static_dir(); + let data_dir = TempDir::new().unwrap(); + let mut config = (*test_config_with_imap( + static_dir.path().to_str().unwrap(), + data_dir.path().to_str().unwrap(), + )) + .clone(); + config.base_path = base_path.map(str::to_string); + let store = test_store(); + let (browser_id, account_id, token) = setup_test_account(&store, "alice@example.com"); + provision_user_db( + data_dir.path().to_str().unwrap(), + &crate::auth::user_data::hash_email("alice@example.com"), + ); + let app = create_router( + Arc::new(config), + store, + test_imap_client(), + test_smtp_client(), + test_search_engine(data_dir.path().to_str().unwrap()), + test_event_bus(), + test_idle_manager(), + ); + + // This route is outside /api/auth, where browsers default empty cookie paths. + let mut request = Request::builder().uri(format!("{prefix}/api/folders")); + for (name, value) in auth_headers(&browser_id, &account_id, &token) { + request = request.header(name, value); + } + let response = app + .clone() + .oneshot(request.body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK, "{base_path:?}"); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert!(json["folders"].is_array(), "{base_path:?}: {json}"); + + let mut request = Request::builder() + .method("POST") + .uri(format!("{prefix}/api/auth/logout")) + .header("x-requested-with", "XMLHttpRequest"); + for (name, value) in auth_headers(&browser_id, &account_id, &token) { + request = request.header(name, value); + } + let response = app + .oneshot(request.body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK, "{base_path:?}"); + let cookies: Vec<_> = response.headers().get_all("set-cookie").iter().collect(); + assert_eq!(cookies.len(), 2); + for cookie in cookies { + let attributes: Vec<_> = cookie.to_str().unwrap().split(';').map(str::trim).collect(); + assert!(attributes.contains(&format!("Path={cookie_path}").as_str())); + assert!(attributes.contains(&"Max-Age=0")); + } + } +} + +#[tokio::test] +async fn logout_clears_cookie() { + let dir = setup_static_dir(); + let config = test_config(dir.path().to_str().unwrap()); + let store = test_store(); + let (browser_id, account_id, token) = setup_test_account(&store, "alice@example.com"); + let app = create_router( + config, + store, + test_imap_client(), + test_smtp_client(), + test_search_engine("/tmp/oxi-test"), + test_event_bus(), + test_idle_manager(), + ); + + let mut req = Request::builder() + .method("POST") + .uri("/api/auth/logout") + .header("x-requested-with", "XMLHttpRequest"); + for (name, value) in auth_headers(&browser_id, &account_id, &token) { + req = req.header(name, value); + } + let response = app.oneshot(req.body(Body::empty()).unwrap()).await.unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + + let set_cookie = response.headers().get_all("set-cookie"); + // Should have clearing cookies for both browser and account session + let cookie_str = format!("{:?}", set_cookie); + assert!(cookie_str.contains("rustbox_browser=;") || cookie_str.contains("Max-Age=0")); +} + +// ----------------------------------------------------------------------- +// New tests for folders and messages routes +// ----------------------------------------------------------------------- + +#[tokio::test] +async fn get_folders_returns_200_with_folder_list() { + let static_dir = setup_static_dir(); + let data_dir = TempDir::new().unwrap(); + let config = test_config_with_imap( + static_dir.path().to_str().unwrap(), + data_dir.path().to_str().unwrap(), + ); + let store = test_store(); + let (browser_id, account_id, token) = setup_test_account(&store, "alice@example.com"); + + // Provision user database. + provision_user_db( + data_dir.path().to_str().unwrap(), + &crate::auth::user_data::hash_email("alice@example.com"), + ); + + let mock = MockImapClient::new().with_folders(vec![ + ImapFolder { + name: "INBOX".to_string(), + delimiter: Some("/".to_string()), + attributes: vec!["\\HasNoChildren".to_string()], + }, + ImapFolder { + name: "Sent".to_string(), + delimiter: Some("/".to_string()), + attributes: vec![], + }, + ]); + let imap_client: Arc = Arc::new(mock); + let app = create_router( + config, + store, + imap_client, + test_smtp_client(), + test_search_engine(data_dir.path().to_str().unwrap()), + test_event_bus(), + test_idle_manager(), + ); + + let mut req = Request::builder() + .uri("/api/folders") + .header("x-requested-with", "XMLHttpRequest"); + for (name, value) in auth_headers(&browser_id, &account_id, &token) { + req = req.header(name, value); + } + let response = app.oneshot(req.body(Body::empty()).unwrap()).await.unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + + let folders = json["folders"].as_array().unwrap(); + assert_eq!(folders.len(), 2); + assert_eq!(folders[0]["name"], "INBOX"); + assert_eq!(folders[1]["name"], "Sent"); +} + +#[tokio::test] +async fn get_folders_returns_503_when_imap_fails() { + let static_dir = setup_static_dir(); + let data_dir = TempDir::new().unwrap(); + let config = test_config_with_imap( + static_dir.path().to_str().unwrap(), + data_dir.path().to_str().unwrap(), + ); + let store = test_store(); + let (browser_id, account_id, token) = setup_test_account(&store, "alice@example.com"); + + let mock = + MockImapClient::new().with_error(ImapError::ConnectionFailed("test failure".to_string())); + let imap_client: Arc = Arc::new(mock); + let app = create_router( + config, + store, + imap_client, + test_smtp_client(), + test_search_engine(data_dir.path().to_str().unwrap()), + test_event_bus(), + test_idle_manager(), + ); + + let mut req = Request::builder() + .uri("/api/folders") + .header("x-requested-with", "XMLHttpRequest"); + for (name, value) in auth_headers(&browser_id, &account_id, &token) { + req = req.header(name, value); + } + let response = app.oneshot(req.body(Body::empty()).unwrap()).await.unwrap(); + + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); +} + +#[tokio::test] +async fn get_folders_returns_401_without_auth() { + let static_dir = setup_static_dir(); + let data_dir = TempDir::new().unwrap(); + let config = test_config_with_imap( + static_dir.path().to_str().unwrap(), + data_dir.path().to_str().unwrap(), + ); + let store = test_store(); + let app = create_router( + config, + store, + test_imap_client(), + test_smtp_client(), + test_search_engine("/tmp/oxi-test"), + test_event_bus(), + test_idle_manager(), + ); + + let response = app + .oneshot( + Request::builder() + .uri("/api/folders") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn get_messages_returns_200_with_paginated_list() { + let static_dir = setup_static_dir(); + let data_dir = TempDir::new().unwrap(); + let config = test_config_with_imap( + static_dir.path().to_str().unwrap(), + data_dir.path().to_str().unwrap(), + ); + let store = test_store(); + let (browser_id, account_id, token) = setup_test_account(&store, "alice@example.com"); + + provision_user_db( + data_dir.path().to_str().unwrap(), + &crate::auth::user_data::hash_email("alice@example.com"), + ); + + let mock = MockImapClient::new().with_headers(vec![ + ImapMessageHeader { + uid: 1, + subject: Some("Hello World".to_string()), + from: vec![EmailAddress { + name: Some("Alice".to_string()), + address: "alice@example.com".to_string(), + }], + to: vec![EmailAddress { + name: None, + address: "bob@example.com".to_string(), + }], + date: Some("2024-01-01T10:00:00Z".to_string()), + flags: vec!["\\Seen".to_string()], + has_attachments: false, + size: 2048, + message_id: None, + in_reply_to: None, + references: None, + cc: vec![], + reaction: None, + }, + ImapMessageHeader { + uid: 2, + subject: Some("Second message".to_string()), + from: vec![EmailAddress { + name: Some("Bob".to_string()), + address: "bob@example.com".to_string(), + }], + to: vec![EmailAddress { + name: None, + address: "alice@example.com".to_string(), + }], + date: Some("2024-01-02T10:00:00Z".to_string()), + flags: vec![], + has_attachments: false, + size: 4096, + message_id: None, + in_reply_to: None, + references: None, + cc: vec![], + reaction: None, + }, + ]); + let imap_client: Arc = Arc::new(mock); + let app = create_router( + config, + store, + imap_client, + test_smtp_client(), + test_search_engine(data_dir.path().to_str().unwrap()), + test_event_bus(), + test_idle_manager(), + ); + + let mut req = Request::builder() + .uri("/api/folders/INBOX/messages?page=0&per_page=50") + .header("x-requested-with", "XMLHttpRequest"); + for (name, value) in auth_headers(&browser_id, &account_id, &token) { + req = req.header(name, value); + } + let response = app.oneshot(req.body(Body::empty()).unwrap()).await.unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + + assert_eq!(json["total_count"], 2); + assert_eq!(json["page"], 0); + assert_eq!(json["per_page"], 50); + + let messages = json["messages"].as_array().unwrap(); + assert_eq!(messages.len(), 2); +} + +#[tokio::test] +async fn get_message_returns_sanitized_html() { + let static_dir = setup_static_dir(); + let data_dir = TempDir::new().unwrap(); + let config = test_config_with_imap( + static_dir.path().to_str().unwrap(), + data_dir.path().to_str().unwrap(), + ); + let store = test_store(); + let (browser_id, account_id, token) = setup_test_account(&store, "alice@example.com"); + + provision_user_db( + data_dir.path().to_str().unwrap(), + &crate::auth::user_data::hash_email("alice@example.com"), + ); + + // First, we need the message header in cache (fetch_headers first). + let mock = MockImapClient::new() + .with_headers(vec![ImapMessageHeader { + uid: 42, + subject: Some("Test Subject".to_string()), + from: vec![EmailAddress { + name: Some("Alice".to_string()), + address: "alice@example.com".to_string(), + }], + to: vec![EmailAddress { + name: None, + address: "bob@example.com".to_string(), + }], + date: Some("2024-01-01T10:00:00Z".to_string()), + flags: vec!["\\Seen".to_string()], + has_attachments: false, + size: 1024, + message_id: None, + in_reply_to: None, + references: None, + cc: vec![], + reaction: None, + }]) + .with_bodies(vec![ImapMessageBody { + uid: 42, + text_plain: Some("Hello plain text".to_string()), + text_html: Some("

Hello

bold".to_string()), + attachments: vec![], + raw_headers: String::new(), + }]); + let imap_client: Arc = Arc::new(mock); + let app = create_router( + config.clone(), + store.clone(), + imap_client.clone(), + test_smtp_client(), + test_search_engine(data_dir.path().to_str().unwrap()), + test_event_bus(), + test_idle_manager(), + ); + + // First, populate the message cache by listing messages. + let mut req1 = Request::builder() + .uri("/api/folders/INBOX/messages") + .header("x-requested-with", "XMLHttpRequest"); + for (name, value) in auth_headers(&browser_id, &account_id, &token) { + req1 = req1.header(name, value); + } + let response = app + .oneshot(req1.body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + + // Now get the full message. + let app2 = create_router( + config, + store, + imap_client, + test_smtp_client(), + test_search_engine(data_dir.path().to_str().unwrap()), + test_event_bus(), + test_idle_manager(), + ); + let mut req2 = Request::builder() + .uri("/api/messages/INBOX/42") + .header("x-requested-with", "XMLHttpRequest"); + for (name, value) in auth_headers(&browser_id, &account_id, &token) { + req2 = req2.header(name, value); + } + let response = app2 + .oneshot(req2.body(Body::empty()).unwrap()) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + + assert_eq!(json["uid"], 42); + assert_eq!(json["subject"], "Test Subject"); + + // Script tag is preserved (frontend handles security via iframe sandbox). + let html = json["html"].as_str().unwrap(); + assert!(html.contains("script")); + assert!(html.contains("bold")); + assert!(html.contains("

Hello

")); + + // Plain text should be preserved. + assert_eq!(json["text"], "Hello plain text"); + + // Flags should be an array. + assert!(json["flags"].is_array()); + + // to_addresses should be an array. + assert!(json["to_addresses"].is_array()); +} + +#[tokio::test] +async fn update_flags_returns_200() { + let static_dir = setup_static_dir(); + let data_dir = TempDir::new().unwrap(); + let config = test_config_with_imap( + static_dir.path().to_str().unwrap(), + data_dir.path().to_str().unwrap(), + ); + let store = test_store(); + let (browser_id, account_id, token) = setup_test_account(&store, "alice@example.com"); + + let user_hash = crate::auth::user_data::hash_email("alice@example.com"); + provision_user_db(data_dir.path().to_str().unwrap(), &user_hash); + + // Seed a message in the cache. + let conn = + crate::db::pool::open_user_db(data_dir.path().to_str().unwrap(), &user_hash).unwrap(); + crate::db::folders::upsert_folder(&conn, "INBOX", None, None, "", true, 0, 0, 0, 0).unwrap(); + crate::db::messages::upsert_message( + &conn, + "INBOX", + 1, + None, + None, + None, + "Test", + "a@b.com", + "A", + "[]", + "[]", + "2024-01-01", + "", + 0, + false, + "", + None, + ) + .unwrap(); + drop(conn); + + let mock = MockImapClient::new(); + let imap_client: Arc = Arc::new(mock); + let app = create_router( + config, + store, + imap_client, + test_smtp_client(), + test_search_engine(data_dir.path().to_str().unwrap()), + test_event_bus(), + test_idle_manager(), + ); + + let mut req = Request::builder() + .method("PATCH") + .uri("/api/messages/INBOX/1/flags") + .header("x-requested-with", "XMLHttpRequest") + .header("content-type", "application/json"); + for (name, value) in auth_headers(&browser_id, &account_id, &token) { + req = req.header(name, value); + } + let response = app + .oneshot( + req.body(Body::from(r#"{"flags":["\\Seen","\\Flagged"],"add":true}"#)) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); +} + +#[tokio::test] +async fn update_flags_rejects_invalid_flag_characters() { + // Flag values must be IMAP atom characters (RFC 3501 §9). A value + // containing a space — e.g. two flags accidentally joined into one + // string — must be rejected with 400 before reaching the IMAP client. + let static_dir = setup_static_dir(); + let data_dir = TempDir::new().unwrap(); + let config = test_config_with_imap( + static_dir.path().to_str().unwrap(), + data_dir.path().to_str().unwrap(), + ); + let store = test_store(); + let (browser_id, account_id, token) = setup_test_account(&store, "alice@example.com"); + + let mock = MockImapClient::new(); + let imap_client: Arc = Arc::new(mock); + let app = create_router( + config, + store, + imap_client, + test_smtp_client(), + test_search_engine(data_dir.path().to_str().unwrap()), + test_event_bus(), + test_idle_manager(), + ); + + let mut req = Request::builder() + .method("PATCH") + .uri("/api/messages/INBOX/1/flags") + .header("x-requested-with", "XMLHttpRequest") + .header("content-type", "application/json"); + for (name, value) in auth_headers(&browser_id, &account_id, &token) { + req = req.header(name, value); + } + let response = app + .oneshot( + req.body(Body::from(r#"{"flags":["\\Seen \\Flagged"],"add":true}"#)) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + // BAD_REQUEST confirms rejection at the validation layer — the IMAP + // error path returns SERVICE_UNAVAILABLE (503), not 400. + let body = response.into_body().collect().await.unwrap().to_bytes(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(json["error"]["code"], "BAD_REQUEST"); +} + +#[tokio::test] +async fn update_flags_rejects_empty_flag() { + let static_dir = setup_static_dir(); + let data_dir = TempDir::new().unwrap(); + let config = test_config_with_imap( + static_dir.path().to_str().unwrap(), + data_dir.path().to_str().unwrap(), + ); + let store = test_store(); + let (browser_id, account_id, token) = setup_test_account(&store, "alice@example.com"); + + let mock = MockImapClient::new(); + let imap_client: Arc = Arc::new(mock); + let app = create_router( + config, + store, + imap_client, + test_smtp_client(), + test_search_engine(data_dir.path().to_str().unwrap()), + test_event_bus(), + test_idle_manager(), + ); + + let mut req = Request::builder() + .method("PATCH") + .uri("/api/messages/INBOX/1/flags") + .header("x-requested-with", "XMLHttpRequest") + .header("content-type", "application/json"); + for (name, value) in auth_headers(&browser_id, &account_id, &token) { + req = req.header(name, value); + } + let response = app + .oneshot( + req.body(Body::from(r#"{"flags":[""],"add":true}"#)) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn move_message_returns_200() { + let static_dir = setup_static_dir(); + let data_dir = TempDir::new().unwrap(); + let config = test_config_with_imap( + static_dir.path().to_str().unwrap(), + data_dir.path().to_str().unwrap(), + ); + let store = test_store(); + let (browser_id, account_id, token) = setup_test_account(&store, "alice@example.com"); + + let user_hash = crate::auth::user_data::hash_email("alice@example.com"); + provision_user_db(data_dir.path().to_str().unwrap(), &user_hash); + + // Seed a message in the cache. + let conn = + crate::db::pool::open_user_db(data_dir.path().to_str().unwrap(), &user_hash).unwrap(); + crate::db::folders::upsert_folder(&conn, "INBOX", None, None, "", true, 0, 0, 0, 0).unwrap(); + crate::db::messages::upsert_message( + &conn, + "INBOX", + 42, + None, + None, + None, + "Test", + "a@b.com", + "A", + "[]", + "[]", + "2024-01-01", + "", + 0, + false, + "", + None, + ) + .unwrap(); + drop(conn); + + let mock = MockImapClient::new(); + let imap_client: Arc = Arc::new(mock); + let app = create_router( + config, + store, + imap_client, + test_smtp_client(), + test_search_engine(data_dir.path().to_str().unwrap()), + test_event_bus(), + test_idle_manager(), + ); + + let mut req = Request::builder() + .method("POST") + .uri("/api/messages/move") + .header("x-requested-with", "XMLHttpRequest") + .header("content-type", "application/json"); + for (name, value) in auth_headers(&browser_id, &account_id, &token) { + req = req.header(name, value); + } + let response = app + .oneshot( + req.body(Body::from( + r#"{"from_folder":"INBOX","to_folder":"Archive","uid":42}"#, + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); +} + +#[tokio::test] +async fn move_message_returns_resolved_undo_uid() { + let static_dir = setup_static_dir(); + let data_dir = TempDir::new().unwrap(); + let config = test_config_with_imap( + static_dir.path().to_str().unwrap(), + data_dir.path().to_str().unwrap(), + ); + let store = test_store(); + let (browser_id, account_id, token) = setup_test_account(&store, "alice@example.com"); + + let user_hash = crate::auth::user_data::hash_email("alice@example.com"); + provision_user_db(data_dir.path().to_str().unwrap(), &user_hash); + let conn = + crate::db::pool::open_user_db(data_dir.path().to_str().unwrap(), &user_hash).unwrap(); + crate::db::folders::upsert_folder(&conn, "INBOX", None, None, "", true, 0, 0, 0, 0).unwrap(); + crate::db::folders::upsert_folder(&conn, "Archive", None, None, "", true, 0, 0, 0, 0).unwrap(); + crate::db::messages::upsert_message( + &conn, + "INBOX", + 42, + Some(""), + None, + None, + "Test", + "a@b.com", + "A", + "[]", + "[]", + "2024-01-01", + "", + 0, + false, + "", + None, + ) + .unwrap(); + drop(conn); + + let moved_header = ImapMessageHeader { + uid: 101, + subject: Some("Test".into()), + from: Vec::new(), + to: Vec::new(), + cc: Vec::new(), + date: None, + flags: Vec::new(), + has_attachments: false, + size: 0, + message_id: Some("".into()), + in_reply_to: None, + references: None, + reaction: None, + }; + let mock = MockImapClient::new() + .with_folder_status(crate::imap::types::FolderStatus { + uid_validity: 1, + exists: 0, + uid_next: 100, + }) + .with_headers(vec![moved_header]); + let imap_client: Arc = Arc::new(mock); + let app = create_router( + config, + store, + imap_client, + test_smtp_client(), + test_search_engine(data_dir.path().to_str().unwrap()), + test_event_bus(), + test_idle_manager(), + ); + + let mut req = Request::builder() + .method("POST") + .uri("/api/messages/move") + .header("x-requested-with", "XMLHttpRequest") + .header("content-type", "application/json"); + for (name, value) in auth_headers(&browser_id, &account_id, &token) { + req = req.header(name, value); + } + let response = app + .oneshot( + req.body(Body::from( + r#"{"from_folder":"INBOX","to_folder":"Archive","uid":42}"#, + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(json["undo_move"]["from_folder"], "Archive"); + assert_eq!(json["undo_move"]["to_folder"], "INBOX"); + assert_eq!(json["undo_move"]["uid"], 101); +} + +#[tokio::test] +async fn delete_message_returns_200() { + let static_dir = setup_static_dir(); + let data_dir = TempDir::new().unwrap(); + let config = test_config_with_imap( + static_dir.path().to_str().unwrap(), + data_dir.path().to_str().unwrap(), + ); + let store = test_store(); + let (browser_id, account_id, token) = setup_test_account(&store, "alice@example.com"); + + let user_hash = crate::auth::user_data::hash_email("alice@example.com"); + provision_user_db(data_dir.path().to_str().unwrap(), &user_hash); + + // Seed a message in the cache. + let conn = + crate::db::pool::open_user_db(data_dir.path().to_str().unwrap(), &user_hash).unwrap(); + crate::db::folders::upsert_folder(&conn, "INBOX", None, None, "", true, 0, 0, 0, 0).unwrap(); + crate::db::messages::upsert_message( + &conn, + "INBOX", + 7, + None, + None, + None, + "Test", + "a@b.com", + "A", + "[]", + "[]", + "2024-01-01", + "", + 0, + false, + "", + None, + ) + .unwrap(); + drop(conn); + + let mock = MockImapClient::new(); + let imap_client: Arc = Arc::new(mock); + let app = create_router( + config, + store, + imap_client, + test_smtp_client(), + test_search_engine(data_dir.path().to_str().unwrap()), + test_event_bus(), + test_idle_manager(), + ); + + let mut req = Request::builder() + .method("DELETE") + .uri("/api/messages/INBOX/7") + .header("x-requested-with", "XMLHttpRequest"); + for (name, value) in auth_headers(&browser_id, &account_id, &token) { + req = req.header(name, value); + } + let response = app.oneshot(req.body(Body::empty()).unwrap()).await.unwrap(); + + assert_eq!(response.status(), StatusCode::OK); +} + +// ----------------------------------------------------------------------- +// Attachment download tests +// ----------------------------------------------------------------------- + +#[tokio::test] +async fn download_attachment_returns_binary_with_correct_headers() { + let static_dir = setup_static_dir(); + let data_dir = TempDir::new().unwrap(); + let config = test_config_with_imap( + static_dir.path().to_str().unwrap(), + data_dir.path().to_str().unwrap(), + ); + let store = test_store(); + let (browser_id, account_id, token) = setup_test_account(&store, "alice@example.com"); + + let user_hash = crate::auth::user_data::hash_email("alice@example.com"); + provision_user_db(data_dir.path().to_str().unwrap(), &user_hash); + + let attachment_data: Vec = vec![0xDE, 0xAD, 0xBE, 0xEF]; + let mock = MockImapClient::new().with_bodies(vec![ImapMessageBody { + uid: 42, + text_plain: Some("text".to_string()), + text_html: None, + attachments: vec![ImapAttachment { + filename: Some("document.pdf".to_string()), + content_type: "application/pdf".to_string(), + size: 4, + data: attachment_data.clone(), + content_id: None, + }], + raw_headers: String::new(), + }]); + let imap_client: Arc = Arc::new(mock); + let app = create_router( + config, + store, + imap_client, + test_smtp_client(), + test_search_engine(data_dir.path().to_str().unwrap()), + test_event_bus(), + test_idle_manager(), + ); + + let mut req = Request::builder() + .uri("/api/messages/INBOX/42/attachments/0") + .header("x-requested-with", "XMLHttpRequest"); + for (name, value) in auth_headers(&browser_id, &account_id, &token) { + req = req.header(name, value); + } + let response = app.oneshot(req.body(Body::empty()).unwrap()).await.unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + + // Verify content-type header. + let ct = response + .headers() + .get("content-type") + .unwrap() + .to_str() + .unwrap(); + assert_eq!(ct, "application/pdf"); + + // Verify content-disposition header. + let cd = response + .headers() + .get("content-disposition") + .unwrap() + .to_str() + .unwrap(); + assert!(cd.contains("inline")); + assert!(cd.contains("document.pdf")); + + // Verify body bytes match the attachment data. + let body = response.into_body().collect().await.unwrap().to_bytes(); + assert_eq!(body.as_ref(), &attachment_data); +} + +#[tokio::test] +async fn download_attachment_strips_control_chars_from_filename() { + for (filename, expected_filename) in [ + ("doc\r\nument.pdf", "document.pdf"), + ("\r\n\0\u{7f}", "attachment_0"), + ] { + // MIME decoders can surface control characters in attachment filenames + // via quoted-pair escapes. Control characters must be stripped before + // the filename is used in the Content-Disposition header. + let static_dir = setup_static_dir(); + let data_dir = TempDir::new().unwrap(); + let config = test_config_with_imap( + static_dir.path().to_str().unwrap(), + data_dir.path().to_str().unwrap(), + ); + let store = test_store(); + let (browser_id, account_id, token) = setup_test_account(&store, "alice@example.com"); + + let user_hash = crate::auth::user_data::hash_email("alice@example.com"); + provision_user_db(data_dir.path().to_str().unwrap(), &user_hash); + + let mock = MockImapClient::new().with_bodies(vec![ImapMessageBody { + uid: 1, + text_plain: Some("text".to_string()), + text_html: None, + attachments: vec![ImapAttachment { + filename: Some(filename.to_string()), + content_type: "application/pdf".to_string(), + size: 4, + data: vec![0u8; 4], + content_id: None, + }], + raw_headers: String::new(), + }]); + let imap_client: Arc = Arc::new(mock); + let app = create_router( + config, + store, + imap_client, + test_smtp_client(), + test_search_engine(data_dir.path().to_str().unwrap()), + test_event_bus(), + test_idle_manager(), + ); + + let mut req = Request::builder() + .uri("/api/messages/INBOX/1/attachments/0") + .header("x-requested-with", "XMLHttpRequest"); + for (name, value) in auth_headers(&browser_id, &account_id, &token) { + req = req.header(name, value); + } + let response = app.oneshot(req.body(Body::empty()).unwrap()).await.unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let cd = response + .headers() + .get("content-disposition") + .unwrap() + .to_str() + .unwrap(); + // Control characters stripped; the rest of the filename is intact. + assert!(cd.contains(expected_filename), "got: {cd}"); + assert!(!cd.contains('\r')); + assert!(!cd.contains('\n')); + } +} + +#[tokio::test] +async fn download_attachment_returns_404_for_invalid_index() { + let static_dir = setup_static_dir(); + let data_dir = TempDir::new().unwrap(); + let config = test_config_with_imap( + static_dir.path().to_str().unwrap(), + data_dir.path().to_str().unwrap(), + ); + let store = test_store(); + let (browser_id, account_id, token) = setup_test_account(&store, "alice@example.com"); + + let user_hash = crate::auth::user_data::hash_email("alice@example.com"); + provision_user_db(data_dir.path().to_str().unwrap(), &user_hash); + + let mock = MockImapClient::new().with_bodies(vec![ImapMessageBody { + uid: 42, + text_plain: Some("text".to_string()), + text_html: None, + attachments: vec![ImapAttachment { + filename: Some("document.pdf".to_string()), + content_type: "application/pdf".to_string(), + size: 4, + data: vec![0xDE, 0xAD, 0xBE, 0xEF], + content_id: None, + }], + raw_headers: String::new(), + }]); + let imap_client: Arc = Arc::new(mock); + let app = create_router( + config, + store, + imap_client, + test_smtp_client(), + test_search_engine(data_dir.path().to_str().unwrap()), + test_event_bus(), + test_idle_manager(), + ); + + let mut req = Request::builder() + .uri("/api/messages/INBOX/42/attachments/99") + .header("x-requested-with", "XMLHttpRequest"); + for (name, value) in auth_headers(&browser_id, &account_id, &token) { + req = req.header(name, value); + } + let response = app.oneshot(req.body(Body::empty()).unwrap()).await.unwrap(); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn download_attachment_returns_400_for_non_numeric_id() { + let static_dir = setup_static_dir(); + let data_dir = TempDir::new().unwrap(); + let config = test_config_with_imap( + static_dir.path().to_str().unwrap(), + data_dir.path().to_str().unwrap(), + ); + let store = test_store(); + let (browser_id, account_id, token) = setup_test_account(&store, "alice@example.com"); + + let user_hash = crate::auth::user_data::hash_email("alice@example.com"); + provision_user_db(data_dir.path().to_str().unwrap(), &user_hash); + + let mock = MockImapClient::new().with_bodies(vec![ImapMessageBody { + uid: 42, + text_plain: Some("text".to_string()), + text_html: None, + attachments: vec![ImapAttachment { + filename: Some("document.pdf".to_string()), + content_type: "application/pdf".to_string(), + size: 4, + data: vec![0xDE, 0xAD, 0xBE, 0xEF], + content_id: None, + }], + raw_headers: String::new(), + }]); + let imap_client: Arc = Arc::new(mock); + let app = create_router( + config, + store, + imap_client, + test_smtp_client(), + test_search_engine(data_dir.path().to_str().unwrap()), + test_event_bus(), + test_idle_manager(), + ); + + let mut req = Request::builder() + .uri("/api/messages/INBOX/42/attachments/abc") + .header("x-requested-with", "XMLHttpRequest"); + for (name, value) in auth_headers(&browser_id, &account_id, &token) { + req = req.header(name, value); + } + let response = app.oneshot(req.body(Body::empty()).unwrap()).await.unwrap(); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); +} + +// ----------------------------------------------------------------------- +// Send message endpoint tests +// ----------------------------------------------------------------------- + +#[tokio::test] +async fn send_returns_200_on_success() { + let static_dir = setup_static_dir(); + let data_dir = TempDir::new().unwrap(); + let config = test_config_with_smtp( + static_dir.path().to_str().unwrap(), + data_dir.path().to_str().unwrap(), + ); + let store = test_store(); + let (browser_id, account_id, token) = setup_test_account(&store, "alice@example.com"); + + let user_hash = crate::auth::user_data::hash_email("alice@example.com"); + let conn = + crate::db::pool::open_user_db(data_dir.path().to_str().unwrap(), &user_hash).unwrap(); + for folder in ["Sent", "INBOX"] { + crate::db::folders::insert_folder_if_new(&conn, folder, None, "").unwrap(); + crate::db::folders::update_folder_status(&conn, folder, 1, 0).unwrap(); + assert!(crate::db::folders::is_folder_fresh(&conn, folder, 30).unwrap()); + } + + let mock_smtp: Arc = Arc::new(MockSmtpClient::new()); + let mock_imap = Arc::new(MockImapClient::new()); + let app = create_router( + config, + store, + mock_imap.clone(), + mock_smtp, + test_search_engine(data_dir.path().to_str().unwrap()), + test_event_bus(), + test_idle_manager(), + ); + + let mut req = Request::builder() + .method("POST") + .uri("/api/messages/send") + .header("x-requested-with", "XMLHttpRequest") + .header("content-type", "application/json"); + for (name, value) in auth_headers(&browser_id, &account_id, &token) { + req = req.header(name, value); + } + let response = app + .oneshot( + req.body(Body::from( + r#"{"to":["bob@example.com"],"subject":"Hello","text_body":"Hi Bob"}"#, + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(json["status"], "sent"); + assert!(json["message_id"].as_str().is_some()); + let appended = mock_imap.appended.lock().unwrap(); + assert_eq!(appended.len(), 1); + assert_eq!(appended[0].0, "Sent"); + assert!(!appended[0].1.is_empty()); + assert!(crate::db::folders::is_folder_messages_invalidated(&conn, "Sent").unwrap()); + assert!(!crate::db::folders::is_folder_fresh(&conn, "Sent", 30).unwrap()); + assert!(crate::db::folders::is_folder_fresh(&conn, "INBOX", 30).unwrap()); +} + +#[tokio::test] +async fn send_succeeds_when_sent_append_fails() { + let static_dir = setup_static_dir(); + let data_dir = TempDir::new().unwrap(); + let config = test_config_with_smtp( + static_dir.path().to_str().unwrap(), + data_dir.path().to_str().unwrap(), + ); + let store = test_store(); + let (browser_id, account_id, token) = setup_test_account(&store, "alice@example.com"); + + let user_hash = crate::auth::user_data::hash_email("alice@example.com"); + let conn = + crate::db::pool::open_user_db(data_dir.path().to_str().unwrap(), &user_hash).unwrap(); + crate::db::folders::insert_folder_if_new(&conn, "Sent", None, "").unwrap(); + crate::db::folders::update_folder_status(&conn, "Sent", 1, 0).unwrap(); + + let mock_smtp: Arc = Arc::new(MockSmtpClient::new()); + let mock_imap = Arc::new( + MockImapClient::new().with_error(ImapError::ProtocolError("APPEND rejected".to_string())), + ); + let app = create_router( + config, + store, + mock_imap.clone(), + mock_smtp, + test_search_engine(data_dir.path().to_str().unwrap()), + test_event_bus(), + test_idle_manager(), + ); + + let mut req = Request::builder() + .method("POST") + .uri("/api/messages/send") + .header("x-requested-with", "XMLHttpRequest") + .header("content-type", "application/json"); + for (name, value) in auth_headers(&browser_id, &account_id, &token) { + req = req.header(name, value); + } + let response = app + .oneshot( + req.body(Body::from( + r#"{"to":["bob@example.com"],"subject":"Hello","text_body":"Hi Bob"}"#, + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(json["status"], "sent"); + assert!(json["message_id"].as_str().is_some()); + assert!(mock_imap.appended.lock().unwrap().is_empty()); + assert!(crate::db::folders::is_folder_fresh(&conn, "Sent", 30).unwrap()); +} + +#[tokio::test] +async fn send_returns_400_without_recipients() { + let static_dir = setup_static_dir(); + let data_dir = TempDir::new().unwrap(); + let config = test_config_with_smtp( + static_dir.path().to_str().unwrap(), + data_dir.path().to_str().unwrap(), + ); + let store = test_store(); + let (browser_id, account_id, token) = setup_test_account(&store, "alice@example.com"); + + let user_hash = crate::auth::user_data::hash_email("alice@example.com"); + provision_user_db(data_dir.path().to_str().unwrap(), &user_hash); + + let app = create_router( + config, + store, + test_imap_client(), + test_smtp_client(), + test_search_engine(data_dir.path().to_str().unwrap()), + test_event_bus(), + test_idle_manager(), + ); + + let mut req = Request::builder() + .method("POST") + .uri("/api/messages/send") + .header("x-requested-with", "XMLHttpRequest") + .header("content-type", "application/json"); + for (name, value) in auth_headers(&browser_id, &account_id, &token) { + req = req.header(name, value); + } + let response = app + .oneshot( + req.body(Body::from( + r#"{"to":[],"subject":"Hello","text_body":"Hi"}"#, + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(json["error"]["code"], "BAD_REQUEST"); +} + +#[tokio::test] +async fn send_returns_400_with_empty_body_and_subject() { + let static_dir = setup_static_dir(); + let data_dir = TempDir::new().unwrap(); + let config = test_config_with_smtp( + static_dir.path().to_str().unwrap(), + data_dir.path().to_str().unwrap(), + ); + let store = test_store(); + let (browser_id, account_id, token) = setup_test_account(&store, "alice@example.com"); + + let user_hash = crate::auth::user_data::hash_email("alice@example.com"); + provision_user_db(data_dir.path().to_str().unwrap(), &user_hash); + + let app = create_router( + config, + store, + test_imap_client(), + test_smtp_client(), + test_search_engine(data_dir.path().to_str().unwrap()), + test_event_bus(), + test_idle_manager(), + ); + + let mut req = Request::builder() + .method("POST") + .uri("/api/messages/send") + .header("x-requested-with", "XMLHttpRequest") + .header("content-type", "application/json"); + for (name, value) in auth_headers(&browser_id, &account_id, &token) { + req = req.header(name, value); + } + let response = app + .oneshot( + req.body(Body::from( + r#"{"to":["bob@example.com"],"subject":"","text_body":""}"#, + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn send_returns_503_when_smtp_not_configured() { + let static_dir = setup_static_dir(); + let data_dir = TempDir::new().unwrap(); + // Use config WITHOUT smtp_host + let config = test_config_with_imap( + static_dir.path().to_str().unwrap(), + data_dir.path().to_str().unwrap(), + ); + let store = test_store(); + let (browser_id, account_id, token) = setup_test_account(&store, "alice@example.com"); + + let user_hash = crate::auth::user_data::hash_email("alice@example.com"); + provision_user_db(data_dir.path().to_str().unwrap(), &user_hash); + + let app = create_router( + config, + store, + test_imap_client(), + test_smtp_client(), + test_search_engine(data_dir.path().to_str().unwrap()), + test_event_bus(), + test_idle_manager(), + ); + + let mut req = Request::builder() + .method("POST") + .uri("/api/messages/send") + .header("x-requested-with", "XMLHttpRequest") + .header("content-type", "application/json"); + for (name, value) in auth_headers(&browser_id, &account_id, &token) { + req = req.header(name, value); + } + let response = app + .oneshot( + req.body(Body::from( + r#"{"to":["bob@example.com"],"subject":"Hello","text_body":"Hi"}"#, + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(json["error"]["code"], "SERVICE_UNAVAILABLE"); +} + +#[tokio::test] +async fn send_returns_503_when_smtp_fails() { + let static_dir = setup_static_dir(); + let data_dir = TempDir::new().unwrap(); + let config = test_config_with_smtp( + static_dir.path().to_str().unwrap(), + data_dir.path().to_str().unwrap(), + ); + let store = test_store(); + let (browser_id, account_id, token) = setup_test_account(&store, "alice@example.com"); + + let user_hash = crate::auth::user_data::hash_email("alice@example.com"); + provision_user_db(data_dir.path().to_str().unwrap(), &user_hash); + + let failing_smtp: Arc = Arc::new( + MockSmtpClient::new().with_error(SmtpError::SendFailed("relay denied".to_string())), + ); + let app = create_router( + config, + store, + test_imap_client(), + failing_smtp, + test_search_engine(data_dir.path().to_str().unwrap()), + test_event_bus(), + test_idle_manager(), + ); + + let mut req = Request::builder() + .method("POST") + .uri("/api/messages/send") + .header("x-requested-with", "XMLHttpRequest") + .header("content-type", "application/json"); + for (name, value) in auth_headers(&browser_id, &account_id, &token) { + req = req.header(name, value); + } + let response = app + .oneshot( + req.body(Body::from( + r#"{"to":["bob@example.com"],"subject":"Hello","text_body":"Hi"}"#, + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert!( + json["error"]["message"] + .as_str() + .unwrap() + .contains("relay denied") + ); +} + +#[tokio::test] +async fn quota_without_server_limit_uses_cached_sizes() { + let static_dir = setup_static_dir(); + let data_dir = TempDir::new().unwrap(); + let path = data_dir.path().to_str().unwrap(); + let config = test_config_with_imap(static_dir.path().to_str().unwrap(), path); + let store = test_store(); + let (browser_id, account_id, token) = setup_test_account(&store, "alice@example.com"); + let hash = crate::auth::user_data::hash_email("alice@example.com"); + let conn = crate::db::pool::open_user_db(path, &hash).unwrap(); + crate::db::folders::upsert_folder(&conn, "INBOX", None, None, "", true, 1, 1, 1, 0).unwrap(); + crate::db::messages::upsert_message( + &conn, + "INBOX", + 1, + None, + None, + None, + "subject", + "alice@example.com", + "Alice", + "[]", + "[]", + "", + "", + 4096, + false, + "", + None, + ) + .unwrap(); + drop(conn); + // The mock's IMAP size fallback returns zero; only SQLite has 4096. + let app = create_router( + config, + store, + test_imap_client(), + test_smtp_client(), + test_search_engine(path), + test_event_bus(), + test_idle_manager(), + ); + let mut req = Request::builder().uri("/api/quota"); + for (name, value) in auth_headers(&browser_id, &account_id, &token) { + req = req.header(name, value); + } + let response = app.oneshot(req.body(Body::empty()).unwrap()).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(json["usage_bytes"], 4096); + assert!(json["limit_bytes"].is_null()); +} + +#[tokio::test] +async fn export_contact_strips_control_chars_from_filename() { + for (name, expected_filename) in [ + ("Bob\nSmith", "BobSmith.vcf"), + ("\r\n\0\u{7f}", "contact.vcf"), + ] { + // A contact name containing control characters must have them stripped + // before the name is used in the Content-Disposition header. + let static_dir = setup_static_dir(); + let data_dir = TempDir::new().unwrap(); + let config = test_config_with_imap( + static_dir.path().to_str().unwrap(), + data_dir.path().to_str().unwrap(), + ); + let store = test_store(); + let (browser_id, account_id, token) = setup_test_account(&store, "alice@example.com"); + + let user_hash = crate::auth::user_data::hash_email("alice@example.com"); + provision_user_db(data_dir.path().to_str().unwrap(), &user_hash); + + // Insert a contact whose name contains a newline. + let conn = + crate::db::pool::open_user_db(data_dir.path().to_str().unwrap(), &user_hash).unwrap(); + let contact = crate::db::contacts::Contact { + id: "c1".to_string(), + email: "bob@example.com".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, + }; + crate::db::contacts::upsert_contact(&conn, &contact).unwrap(); + drop(conn); + + let imap_client: Arc = Arc::new(MockImapClient::new()); + let app = create_router( + config, + store, + imap_client, + test_smtp_client(), + test_search_engine(data_dir.path().to_str().unwrap()), + test_event_bus(), + test_idle_manager(), + ); + + let mut req = Request::builder() + .uri("/api/contacts/c1/export") + .header("x-requested-with", "XMLHttpRequest"); + for (name, value) in auth_headers(&browser_id, &account_id, &token) { + req = req.header(name, value); + } + let response = app.oneshot(req.body(Body::empty()).unwrap()).await.unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let cd = response + .headers() + .get("content-disposition") + .unwrap() + .to_str() + .unwrap(); + // Control character stripped; the rest of the name is intact. + assert!(cd.contains(expected_filename), "got: {cd}"); + assert!(!cd.contains('\n')); + } +} diff --git a/src/routes/unsubscribe.rs b/src/routes/unsubscribe.rs new file mode 100644 index 0000000..1dc8a56 --- /dev/null +++ b/src/routes/unsubscribe.rs @@ -0,0 +1,366 @@ +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; +use std::sync::Arc; +use std::time::Duration; + +use axum::extract::Path; +use axum::response::{IntoResponse, Response}; +use axum::{Extension, Json}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpStream; +use tokio::time::timeout; +use url::Url; + +use crate::auth::session::SessionState; +use crate::config::AppConfig; +use crate::db; +use crate::error::AppError; +use crate::imap::client::{ImapClient, ImapCredentials}; + +const REQUEST_TIMEOUT: Duration = Duration::from_secs(10); +const MAX_RESPONSE_BYTES: usize = 8192; +const ONE_CLICK_BODY: &str = "List-Unsubscribe=One-Click"; + +/// `POST /api/messages/{folder}/{uid}/unsubscribe` — perform RFC 8058 one-click +/// unsubscribe when the message explicitly advertises List-Unsubscribe-Post. +pub async fn unsubscribe_message( + Extension(session): Extension, + Extension(config): Extension>, + Extension(imap_client): Extension>, + Path((folder, uid)): Path<(String, u32)>, +) -> Result { + let raw_headers = + load_raw_headers(&session, &config, imap_client.as_ref(), &folder, uid).await?; + let list_unsubscribe = header_value(&raw_headers, "List-Unsubscribe").ok_or_else(|| { + AppError::BadRequest("Message does not provide List-Unsubscribe".to_string()) + })?; + let list_unsubscribe_post = + header_value(&raw_headers, "List-Unsubscribe-Post").ok_or_else(|| { + AppError::BadRequest("Message does not advertise one-click unsubscribe".to_string()) + })?; + + if !list_unsubscribe_post + .split(',') + .map(str::trim) + .any(|value| value.eq_ignore_ascii_case("List-Unsubscribe=One-Click")) + { + return Err(AppError::BadRequest( + "Message does not advertise RFC 8058 one-click unsubscribe".to_string(), + )); + } + + let target = angle_targets(&list_unsubscribe) + .into_iter() + .find(|value| value.to_ascii_lowercase().starts_with("https://")) + .ok_or_else(|| { + AppError::BadRequest("One-click unsubscribe requires an HTTPS target".to_string()) + })?; + + post_one_click(&target).await?; + + Ok(Json(serde_json::json!({ + "status": "unsubscribed", + "method": "one-click" + })) + .into_response()) +} + +async fn load_raw_headers( + session: &SessionState, + config: &AppConfig, + imap_client: &dyn ImapClient, + folder: &str, + uid: u32, +) -> Result { + let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + + if let Some(raw_headers) = db::messages::get_cached_body(&conn, folder, uid) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))? + .and_then(|body| body.raw_headers) + .filter(|headers| !headers.trim().is_empty()) + { + return Ok(raw_headers); + } + + let creds = ImapCredentials { + host: session.imap_host.clone(), + port: session.imap_port, + tls: session.imap_tls, + email: session.email.clone(), + password: session.password.clone(), + }; + let body = imap_client + .fetch_body(&creds, folder, uid) + .await + .map_err(|e| AppError::ServiceUnavailable(format!("IMAP error: {e}")))?; + Ok(body.raw_headers) +} + +fn header_value(raw_headers: &str, header_name: &str) -> Option { + let mut current_name = String::new(); + let mut current_value = String::new(); + let wanted = header_name.to_ascii_lowercase(); + + let finish = |name: &str, value: &str| { + if name.eq_ignore_ascii_case(&wanted) { + Some(value.trim().to_string()) + } else { + None + } + }; + + for line in raw_headers.lines() { + if line.starts_with(' ') || line.starts_with('\t') { + if !current_name.is_empty() { + current_value.push(' '); + current_value.push_str(line.trim()); + } + continue; + } + if let Some(value) = finish(¤t_name, ¤t_value) { + return Some(value); + } + let Some((name, value)) = line.split_once(':') else { + current_name.clear(); + current_value.clear(); + continue; + }; + current_name.clear(); + current_name.push_str(name.trim()); + current_value.clear(); + current_value.push_str(value.trim()); + } + + finish(¤t_name, ¤t_value) +} + +fn angle_targets(value: &str) -> Vec { + let mut targets = Vec::new(); + let mut rest = value; + while let Some(start) = rest.find('<') { + rest = &rest[start + 1..]; + let Some(end) = rest.find('>') else { break }; + let target = rest[..end].trim(); + if !target.is_empty() { + targets.push(target.to_string()); + } + rest = &rest[end + 1..]; + } + targets +} + +async fn post_one_click(target: &str) -> Result<(), AppError> { + let url = Url::parse(target) + .map_err(|_| AppError::BadRequest("Invalid unsubscribe URL".to_string()))?; + if url.scheme() != "https" || !url.username().is_empty() || url.password().is_some() { + return Err(AppError::BadRequest( + "One-click unsubscribe target must be a credential-free HTTPS URL".to_string(), + )); + } + + let host = url + .host_str() + .ok_or_else(|| AppError::BadRequest("Unsubscribe URL has no host".to_string()))?; + let port = url.port_or_known_default().unwrap_or(443); + let addresses: Vec = + timeout(REQUEST_TIMEOUT, tokio::net::lookup_host((host, port))) + .await + .map_err(|_| { + AppError::ServiceUnavailable("Unsubscribe DNS lookup timed out".to_string()) + })? + .map_err(|e| { + AppError::ServiceUnavailable(format!("Unsubscribe DNS lookup failed: {e}")) + })? + .filter(|addr| is_public_ip(addr.ip())) + .collect(); + + if addresses.is_empty() { + return Err(AppError::BadRequest( + "Unsubscribe target resolves only to non-public addresses".to_string(), + )); + } + + let mut last_error = None; + for address in addresses { + match post_one_click_to_address(&url, host, port, address).await { + Ok(()) => return Ok(()), + Err(error) => last_error = Some(error), + } + } + + Err(AppError::ServiceUnavailable(last_error.unwrap_or_else( + || "Unsubscribe request failed".to_string(), + ))) +} + +async fn post_one_click_to_address( + url: &Url, + host: &str, + port: u16, + address: SocketAddr, +) -> Result<(), String> { + let tcp = timeout(REQUEST_TIMEOUT, TcpStream::connect(address)) + .await + .map_err(|_| "Unsubscribe connection timed out".to_string())? + .map_err(|e| format!("Unsubscribe connection failed: {e}"))?; + let tls = async_native_tls::TlsConnector::new(); + let mut stream = timeout(REQUEST_TIMEOUT, tls.connect(host, tcp)) + .await + .map_err(|_| "Unsubscribe TLS handshake timed out".to_string())? + .map_err(|e| format!("Unsubscribe TLS handshake failed: {e}"))?; + + let mut path = url.path().to_string(); + if path.is_empty() { + path.push('/'); + } + if let Some(query) = url.query() { + path.push('?'); + path.push_str(query); + } + let host_header = if host.contains(':') { + if port == 443 { + format!("[{host}]") + } else { + format!("[{host}]:{port}") + } + } else if port == 443 { + host.to_string() + } else { + format!("{host}:{port}") + }; + let request = format!( + "POST {path} HTTP/1.1\r\nHost: {host_header}\r\nUser-Agent: RustBox/0.0.1\r\nContent-Type: application/x-www-form-urlencoded\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{ONE_CLICK_BODY}", + ONE_CLICK_BODY.len() + ); + + timeout(REQUEST_TIMEOUT, stream.write_all(request.as_bytes())) + .await + .map_err(|_| "Unsubscribe request timed out".to_string())? + .map_err(|e| format!("Unsubscribe request failed: {e}"))?; + timeout(REQUEST_TIMEOUT, stream.flush()) + .await + .map_err(|_| "Unsubscribe request timed out".to_string())? + .map_err(|e| format!("Unsubscribe request failed: {e}"))?; + + let mut response = Vec::with_capacity(1024); + let mut chunk = [0_u8; 1024]; + while response.len() < MAX_RESPONSE_BYTES { + let read = timeout(REQUEST_TIMEOUT, stream.read(&mut chunk)) + .await + .map_err(|_| "Unsubscribe response timed out".to_string())? + .map_err(|e| format!("Failed to read unsubscribe response: {e}"))?; + if read == 0 { + break; + } + let remaining = MAX_RESPONSE_BYTES - response.len(); + response.extend_from_slice(&chunk[..read.min(remaining)]); + if response.windows(4).any(|window| window == b"\r\n\r\n") { + break; + } + } + + let first_line = String::from_utf8_lossy(&response) + .lines() + .next() + .unwrap_or_default() + .trim_end_matches('\r') + .to_string(); + let status = first_line + .split_whitespace() + .nth(1) + .and_then(|value| value.parse::().ok()) + .ok_or_else(|| "Unsubscribe endpoint returned an invalid HTTP response".to_string())?; + if !(200..300).contains(&status) { + return Err(format!("Unsubscribe endpoint returned HTTP {status}")); + } + Ok(()) +} + +fn is_public_ip(ip: IpAddr) -> bool { + match ip { + IpAddr::V4(ip) => is_public_ipv4(ip), + IpAddr::V6(ip) => is_public_ipv6(ip), + } +} + +fn is_public_ipv4(ip: Ipv4Addr) -> bool { + let [a, b, c, d] = ip.octets(); + if ip.is_private() + || ip.is_loopback() + || ip.is_link_local() + || ip.is_multicast() + || ip.is_broadcast() + || ip.is_unspecified() + { + return false; + } + !matches!( + (a, b, c, d), + (0, _, _, _) + | (100, 64..=127, _, _) + | (192, 0, 0, _) + | (192, 0, 2, _) + | (198, 18..=19, _, _) + | (198, 51, 100, _) + | (203, 0, 113, _) + | (240..=255, _, _, _) + ) +} + +fn is_public_ipv6(ip: Ipv6Addr) -> bool { + if ip.is_loopback() + || ip.is_unspecified() + || ip.is_multicast() + || ip.is_unique_local() + || ip.is_unicast_link_local() + { + return false; + } + let segments = ip.segments(); + if segments[0] == 0x2001 && segments[1] == 0x0db8 { + return false; + } + if let Some(v4) = ip.to_ipv4_mapped() { + return is_public_ipv4(v4); + } + true +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_folded_list_unsubscribe_header() { + let raw = "List-Unsubscribe: ,\r\n \r\nList-Unsubscribe-Post: List-Unsubscribe=One-Click\r\n"; + let value = header_value(raw, "list-unsubscribe").unwrap(); + assert!(value.contains("https://example.com/unsub?id=1")); + assert_eq!(angle_targets(&value).len(), 2); + assert_eq!( + header_value(raw, "List-Unsubscribe-Post").as_deref(), + Some("List-Unsubscribe=One-Click") + ); + } + + #[test] + fn rejects_private_and_documentation_addresses() { + for value in [ + "127.0.0.1", + "10.0.0.1", + "169.254.1.1", + "192.0.2.1", + "198.51.100.1", + "203.0.113.1", + "::1", + "fd00::1", + "2001:db8::1", + ] { + assert!( + !is_public_ip(value.parse().unwrap()), + "{value} should not be public" + ); + } + assert!(is_public_ip("1.1.1.1".parse().unwrap())); + assert!(is_public_ip("2606:4700:4700::1111".parse().unwrap())); + } +} diff --git a/src/search/engine.rs b/src/search/engine.rs new file mode 100644 index 0000000..cef53f8 --- /dev/null +++ b/src/search/engine.rs @@ -0,0 +1,678 @@ +use std::ops::Bound; +use std::path::PathBuf; + +use serde::Serialize; +use tantivy::collector::{Count, TopDocs}; +use tantivy::query::{BooleanQuery, Occur, QueryParser, RangeQuery, TermQuery}; +use tantivy::schema::Value; +use tantivy::schema::{ + Field, INDEXED, IndexRecordOption, STORED, STRING, Schema, TEXT, TextFieldIndexing, TextOptions, +}; +use tantivy::snippet::SnippetGenerator; +use tantivy::{Index, IndexReader, IndexWriter, ReloadPolicy, TantivyDocument, Term, doc}; + +/// A message ready to be indexed into the search engine. +pub struct IndexableMessage { + pub uid: u32, + pub folder: String, + pub subject: String, + pub from_address: String, + pub from_name: String, + pub to_addresses: String, + pub body_text: String, + pub date_epoch: i64, + pub has_attachments: bool, +} + +/// A single search result returned from a query. +#[derive(Debug, Serialize)] +pub struct SearchResult { + pub uid: u32, + pub folder: String, + pub score: f32, + pub snippet: String, +} + +/// Parameters for a search query, with optional filters. +#[derive(Default)] +pub struct SearchQuery { + pub text: String, + pub subject_only: Option, + pub folder: Option, + pub from: Option, + pub to: Option, + pub date_from: Option, + pub date_to: Option, + pub has_attachment: Option, + pub limit: usize, + pub offset: usize, +} + +/// Top-level search engine that manages per-user indices. +pub struct SearchEngine { + base_dir: PathBuf, +} + +/// Named field handles for convenient access within the schema. +struct SchemaFields { + uid: Field, + folder: Field, + subject: Field, + from_address: Field, + from_name: Field, + to_addresses: Field, + body_text: Field, + date_epoch: Field, + has_attachments: Field, +} + +/// A per-user Tantivy index with reader and schema. +pub struct UserIndex { + index: Index, + reader: IndexReader, + #[allow(dead_code)] + schema: Schema, + fields: SchemaFields, +} + +/// Build the shared schema used by all user indices. +fn build_schema() -> (Schema, SchemaFields) { + let mut builder = Schema::builder(); + + let uid = builder.add_u64_field("uid", INDEXED | STORED); + let folder = builder.add_text_field("folder", STRING | STORED); + + let text_indexing = TextFieldIndexing::default() + .set_tokenizer("default") + .set_index_option(IndexRecordOption::WithFreqsAndPositions); + let text_stored = TextOptions::default() + .set_stored() + .set_indexing_options(text_indexing); + + let subject = builder.add_text_field("subject", text_stored); + let from_address = builder.add_text_field("from_address", STRING | STORED); + let from_name = builder.add_text_field("from_name", TEXT | STORED); + let to_addresses = builder.add_text_field("to_addresses", TEXT); + let body_text = builder.add_text_field("body_text", TEXT); + let date_epoch = builder.add_i64_field("date_epoch", INDEXED | STORED); + let has_attachments = builder.add_u64_field("has_attachments", INDEXED); + + let schema = builder.build(); + let fields = SchemaFields { + uid, + folder, + subject, + from_address, + from_name, + to_addresses, + body_text, + date_epoch, + has_attachments, + }; + + (schema, fields) +} + +impl SearchEngine { + /// Create a new search engine with the given base directory. + pub fn new(base_dir: PathBuf) -> Self { + Self { base_dir } + } + + /// Open or create the Tantivy index for a specific user. + pub fn open_user_index(&self, user_hash: &str) -> Result { + let index_dir = self.base_dir.join(user_hash).join("tantivy"); + std::fs::create_dir_all(&index_dir) + .map_err(|e| format!("Failed to create index directory: {e}"))?; + + let (schema, fields) = build_schema(); + + let index = if Index::open_in_dir(&index_dir).is_ok() { + Index::open_in_dir(&index_dir) + .map_err(|e| format!("Failed to open existing index: {e}"))? + } else { + Index::create_in_dir(&index_dir, schema.clone()) + .map_err(|e| format!("Failed to create index: {e}"))? + }; + + let reader = index + .reader_builder() + .reload_policy(ReloadPolicy::OnCommitWithDelay) + .try_into() + .map_err(|e| format!("Failed to create index reader: {e}"))?; + + Ok(UserIndex { + index, + reader, + schema, + fields, + }) + } +} + +impl UserIndex { + /// Returns `true` if the given folder should be excluded from search indexing + /// (e.g. Spam, Junk, Trash). + pub fn is_excluded_folder(folder: &str) -> bool { + let lower = folder.to_lowercase(); + lower == "trash" + || lower == "junk" + || lower == "spam" + || lower.ends_with("/trash") + || lower.ends_with("/junk") + || lower.ends_with("/spam") + } + + /// Index a single message. Uses delete-before-insert for upsert semantics. + pub fn index_message(&self, msg: &IndexableMessage) -> Result<(), String> { + let mut writer: IndexWriter = self + .index + .writer(15_000_000) + .map_err(|e| format!("Failed to create index writer: {e}"))?; + + self.delete_existing(&mut writer, msg.uid, &msg.folder); + + let doc = self.build_document(msg); + writer + .add_document(doc) + .map_err(|e| format!("Failed to add document: {e}"))?; + + writer + .commit() + .map_err(|e| format!("Failed to commit: {e}"))?; + + self.reader + .reload() + .map_err(|e| format!("Failed to reload reader: {e}"))?; + + Ok(()) + } + + /// Return the number of indexed documents currently visible to the reader. + pub fn document_count(&self) -> usize { + self.reader.searcher().num_docs() as usize + } + + /// Replace the complete user index with the supplied message set. + /// Used by the diagnostics/recovery workflow after index corruption or + /// schema changes. + pub fn rebuild_from_messages(&self, messages: &[IndexableMessage]) -> Result { + let mut writer: IndexWriter = self + .index + .writer(15_000_000) + .map_err(|e| format!("Failed to create index writer: {e}"))?; + + writer + .delete_all_documents() + .map_err(|e| format!("Failed to clear search index: {e}"))?; + + for msg in messages { + if Self::is_excluded_folder(&msg.folder) { + continue; + } + writer + .add_document(self.build_document(msg)) + .map_err(|e| format!("Failed to add document during rebuild: {e}"))?; + } + + writer + .commit() + .map_err(|e| format!("Failed to commit rebuilt index: {e}"))?; + self.reader + .reload() + .map_err(|e| format!("Failed to reload rebuilt index: {e}"))?; + + Ok(self.document_count()) + } + + /// Index multiple messages in a single batch. Uses delete-before-insert for each. + pub fn index_messages_batch(&self, messages: &[IndexableMessage]) -> Result<(), String> { + let mut writer: IndexWriter = self + .index + .writer(15_000_000) + .map_err(|e| format!("Failed to create index writer: {e}"))?; + + for msg in messages { + self.delete_existing(&mut writer, msg.uid, &msg.folder); + let doc = self.build_document(msg); + writer + .add_document(doc) + .map_err(|e| format!("Failed to add document: {e}"))?; + } + + writer + .commit() + .map_err(|e| format!("Failed to commit batch: {e}"))?; + + self.reader + .reload() + .map_err(|e| format!("Failed to reload reader: {e}"))?; + + Ok(()) + } + + /// Delete a message from the index by uid and folder. + #[allow(dead_code)] + pub fn delete_message(&self, uid: u32, folder: &str) -> Result<(), String> { + let mut writer: IndexWriter = self + .index + .writer(15_000_000) + .map_err(|e| format!("Failed to create index writer: {e}"))?; + + self.delete_existing(&mut writer, uid, folder); + + writer + .commit() + .map_err(|e| format!("Failed to commit delete: {e}"))?; + + self.reader + .reload() + .map_err(|e| format!("Failed to reload reader: {e}"))?; + + Ok(()) + } + + /// Search the index with a text query and optional filters. + /// Returns matching results and total count. + pub fn search(&self, query: &SearchQuery) -> Result<(Vec, usize), String> { + let searcher = self.reader.searcher(); + + // Collect all sub-queries into a BooleanQuery with Must clauses. + let mut clauses: Vec<(Occur, Box)> = Vec::new(); + + // Build the full-text query across subject, body_text, from_name, to_addresses. + if !query.text.is_empty() { + let query_parser = QueryParser::for_index( + &self.index, + vec![ + self.fields.subject, + self.fields.body_text, + self.fields.from_name, + self.fields.to_addresses, + ], + ); + + let text_query = query_parser + .parse_query(&query.text) + .map_err(|e| format!("Failed to parse query: {e}"))?; + + clauses.push((Occur::Must, text_query)); + } + + // Optional subject-only search (searches only the subject field). + if let Some(ref subject_text) = query.subject_only { + let subject_parser = QueryParser::for_index(&self.index, vec![self.fields.subject]); + let subject_query = subject_parser + .parse_query(subject_text) + .map_err(|e| format!("Failed to parse subject query: {e}"))?; + clauses.push((Occur::Must, subject_query)); + } + + // If no text clauses were added, use a match-all query so filters still work. + if clauses.is_empty() { + clauses.push((Occur::Must, Box::new(tantivy::query::AllQuery))); + } + + // Optional folder filter (STRING field -> TermQuery). + if let Some(ref folder) = query.folder { + let term = Term::from_field_text(self.fields.folder, folder); + let folder_query = TermQuery::new(term, IndexRecordOption::Basic); + clauses.push((Occur::Must, Box::new(folder_query))); + } + + // Optional from filter: if value contains '@', exact match on from_address; + // otherwise, text search on from_name. + if let Some(ref from) = query.from { + if from.contains('@') { + let term = Term::from_field_text(self.fields.from_address, from); + let from_query = TermQuery::new(term, IndexRecordOption::Basic); + clauses.push((Occur::Must, Box::new(from_query))); + } else { + let from_parser = QueryParser::for_index(&self.index, vec![self.fields.from_name]); + let from_query = from_parser + .parse_query(from) + .map_err(|e| format!("Failed to parse from query: {e}"))?; + clauses.push((Occur::Must, from_query)); + } + } + + // Optional to filter (TEXT field -> text search on to_addresses). + if let Some(ref to) = query.to { + let to_parser = QueryParser::for_index(&self.index, vec![self.fields.to_addresses]); + let to_query = to_parser + .parse_query(to) + .map_err(|e| format!("Failed to parse to query: {e}"))?; + clauses.push((Occur::Must, to_query)); + } + + // Optional date range filter. + if query.date_from.is_some() || query.date_to.is_some() { + let lower = match query.date_from { + Some(ts) => Bound::Included(ts), + None => Bound::Unbounded, + }; + let upper = match query.date_to { + Some(ts) => Bound::Included(ts), + None => Bound::Unbounded, + }; + let range_query = RangeQuery::new_i64_bounds("date_epoch".to_string(), lower, upper); + clauses.push((Occur::Must, Box::new(range_query))); + } + + // Optional has_attachment filter. + if let Some(has_att) = query.has_attachment { + let val = if has_att { 1u64 } else { 0u64 }; + let term = Term::from_field_u64(self.fields.has_attachments, val); + let att_query = TermQuery::new(term, IndexRecordOption::Basic); + clauses.push((Occur::Must, Box::new(att_query))); + } + + let combined_query = BooleanQuery::new(clauses); + + // Get total count and paginated results. + let total_limit = query.offset + query.limit; + let (total_count, top_docs) = searcher + .search(&combined_query, &(Count, TopDocs::with_limit(total_limit))) + .map_err(|e| format!("Search failed: {e}"))?; + + // Build a snippet generator for the subject field. + let mut snippet_generator = + SnippetGenerator::create(&searcher, &combined_query, self.fields.subject) + .map_err(|e| format!("Failed to create snippet generator: {e}"))?; + snippet_generator.set_max_num_chars(200); + + let mut results = Vec::new(); + for (i, (score, doc_address)) in top_docs.into_iter().enumerate() { + if i < query.offset { + continue; + } + + let retrieved_doc: TantivyDocument = searcher + .doc(doc_address) + .map_err(|e| format!("Failed to retrieve document: {e}"))?; + + let uid_val = retrieved_doc + .get_first(self.fields.uid) + .and_then(|v| v.as_u64()) + .unwrap_or(0) as u32; + + let folder_val = retrieved_doc + .get_first(self.fields.folder) + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + let snippet = snippet_generator.snippet_from_doc(&retrieved_doc); + let snippet_html = snippet.to_html(); + + results.push(SearchResult { + uid: uid_val, + folder: folder_val, + score, + snippet: snippet_html, + }); + } + + Ok((results, total_count)) + } + + /// Delete existing documents that match the uid+folder combination. + fn delete_existing(&self, writer: &mut IndexWriter, uid: u32, folder: &str) { + let uid_term = Term::from_field_u64(self.fields.uid, uid as u64); + let folder_term = Term::from_field_text(self.fields.folder, folder); + + let uid_query = TermQuery::new(uid_term, IndexRecordOption::Basic); + let folder_query = TermQuery::new(folder_term, IndexRecordOption::Basic); + + let delete_query = BooleanQuery::new(vec![ + (Occur::Must, Box::new(uid_query)), + (Occur::Must, Box::new(folder_query)), + ]); + + let _ = writer.delete_query(Box::new(delete_query)); + } + + /// Build a Tantivy document from an IndexableMessage. + fn build_document(&self, msg: &IndexableMessage) -> TantivyDocument { + doc!( + self.fields.uid => msg.uid as u64, + self.fields.folder => msg.folder.as_str(), + self.fields.subject => msg.subject.as_str(), + self.fields.from_address => msg.from_address.as_str(), + self.fields.from_name => msg.from_name.as_str(), + self.fields.to_addresses => msg.to_addresses.as_str(), + self.fields.body_text => msg.body_text.as_str(), + self.fields.date_epoch => msg.date_epoch, + self.fields.has_attachments => if msg.has_attachments { 1u64 } else { 0u64 } + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + /// Helper: create a SearchEngine + UserIndex in a temp directory. + fn setup() -> (TempDir, UserIndex) { + let tmp = TempDir::new().expect("Failed to create temp dir"); + let engine = SearchEngine::new(tmp.path().to_path_buf()); + let user_index = engine + .open_user_index("testuser") + .expect("Failed to open user index"); + (tmp, user_index) + } + + fn make_message(uid: u32, folder: &str, subject: &str, body: &str) -> IndexableMessage { + IndexableMessage { + uid, + folder: folder.to_string(), + subject: subject.to_string(), + from_address: "sender@example.com".to_string(), + from_name: "Sender Name".to_string(), + to_addresses: "recipient@example.com".to_string(), + body_text: body.to_string(), + date_epoch: 1700000000, + has_attachments: false, + } + } + + #[test] + fn index_and_search_by_subject() { + let (_tmp, idx) = setup(); + + let msg1 = make_message(1, "INBOX", "Meeting tomorrow morning", "Let us meet."); + let msg2 = make_message(2, "INBOX", "Invoice for October", "Please pay the invoice."); + + idx.index_message(&msg1).unwrap(); + idx.index_message(&msg2).unwrap(); + + let query = SearchQuery { + text: "meeting".to_string(), + limit: 10, + ..Default::default() + }; + let (results, total) = idx.search(&query).unwrap(); + + assert_eq!(total, 1); + assert_eq!(results.len(), 1); + assert_eq!(results[0].uid, 1); + assert_eq!(results[0].folder, "INBOX"); + } + + #[test] + fn index_and_search_by_body() { + let (_tmp, idx) = setup(); + + let msg1 = make_message(1, "INBOX", "Hello", "The quarterly report is attached."); + let msg2 = make_message( + 2, + "INBOX", + "Greetings", + "Please review the budget proposal.", + ); + + idx.index_messages_batch(&[msg1, msg2]).unwrap(); + + let query = SearchQuery { + text: "quarterly".to_string(), + limit: 10, + ..Default::default() + }; + let (results, total) = idx.search(&query).unwrap(); + + assert_eq!(total, 1); + assert_eq!(results.len(), 1); + assert_eq!(results[0].uid, 1); + } + + #[test] + fn search_with_folder_filter() { + let (_tmp, idx) = setup(); + + let msg1 = make_message(1, "INBOX", "Project update", "Status of the project."); + let msg2 = make_message(2, "Sent", "Project update", "Status of the project."); + + idx.index_messages_batch(&[msg1, msg2]).unwrap(); + + // Search with folder filter for "Sent" only. + let query = SearchQuery { + text: "project".to_string(), + folder: Some("Sent".to_string()), + limit: 10, + ..Default::default() + }; + let (results, total) = idx.search(&query).unwrap(); + + assert_eq!(total, 1); + assert_eq!(results.len(), 1); + assert_eq!(results[0].uid, 2); + assert_eq!(results[0].folder, "Sent"); + } + + #[test] + fn search_with_date_range() { + let (_tmp, idx) = setup(); + + let mut msg1 = make_message(1, "INBOX", "January news", "News from January."); + msg1.date_epoch = 1704067200; // 2024-01-01 + + let mut msg2 = make_message(2, "INBOX", "March news", "News from March."); + msg2.date_epoch = 1709251200; // 2024-03-01 + + let mut msg3 = make_message(3, "INBOX", "June news", "News from June."); + msg3.date_epoch = 1717200000; // 2024-06-01 + + idx.index_messages_batch(&[msg1, msg2, msg3]).unwrap(); + + // Search for "news" within February to April. + let query = SearchQuery { + text: "news".to_string(), + date_from: Some(1706745600), // 2024-02-01 + date_to: Some(1714521600), // 2024-05-01 + limit: 10, + ..Default::default() + }; + let (results, total) = idx.search(&query).unwrap(); + + assert_eq!(total, 1); + assert_eq!(results.len(), 1); + assert_eq!(results[0].uid, 2); + } + + #[test] + fn delete_removes_from_index() { + let (_tmp, idx) = setup(); + + let msg1 = make_message(1, "INBOX", "Important email", "Very important content."); + let msg2 = make_message(2, "INBOX", "Another email", "Some other important content."); + + idx.index_messages_batch(&[msg1, msg2]).unwrap(); + + // Verify both are found. + let query = SearchQuery { + text: "important".to_string(), + limit: 10, + ..Default::default() + }; + let (_, total) = idx.search(&query).unwrap(); + assert_eq!(total, 2); + + // Delete message 1. + idx.delete_message(1, "INBOX").unwrap(); + + let (results, total) = idx.search(&query).unwrap(); + assert_eq!(total, 1); + assert_eq!(results.len(), 1); + assert_eq!(results[0].uid, 2); + } + + #[test] + fn search_empty_text_returns_all() { + let (_tmp, idx) = setup(); + + let msg = make_message(1, "INBOX", "Test subject", "Test body text."); + idx.index_message(&msg).unwrap(); + + // Empty text with no filters uses AllQuery — returns everything. + let query = SearchQuery { + text: "".to_string(), + limit: 10, + ..Default::default() + }; + let (results, total) = idx.search(&query).unwrap(); + + assert_eq!(total, 1); + assert_eq!(results.len(), 1); + } + + #[test] + fn search_pagination() { + let (_tmp, idx) = setup(); + + let messages: Vec = (1..=5) + .map(|i| { + make_message( + i, + "INBOX", + &format!("Report number {i}"), + "Detailed report content here.", + ) + }) + .collect(); + + idx.index_messages_batch(&messages).unwrap(); + + // Page 1: limit=2, offset=0. + let query_page1 = SearchQuery { + text: "report".to_string(), + limit: 2, + offset: 0, + ..Default::default() + }; + let (results1, total1) = idx.search(&query_page1).unwrap(); + assert_eq!(total1, 5); + assert_eq!(results1.len(), 2); + + // Page 2: limit=2, offset=2. + let query_page2 = SearchQuery { + text: "report".to_string(), + limit: 2, + offset: 2, + ..Default::default() + }; + let (results2, total2) = idx.search(&query_page2).unwrap(); + assert_eq!(total2, 5); + assert_eq!(results2.len(), 2); + + // Results from page 1 and page 2 should be different. + let uids1: Vec = results1.iter().map(|r| r.uid).collect(); + let uids2: Vec = results2.iter().map(|r| r.uid).collect(); + for uid in &uids2 { + assert!( + !uids1.contains(uid), + "Page 2 result uid {uid} should not appear in page 1" + ); + } + } +} diff --git a/src/search/mod.rs b/src/search/mod.rs new file mode 100644 index 0000000..702e611 --- /dev/null +++ b/src/search/mod.rs @@ -0,0 +1 @@ +pub mod engine; diff --git a/src/sieve/client.rs b/src/sieve/client.rs new file mode 100644 index 0000000..11c2d89 --- /dev/null +++ b/src/sieve/client.rs @@ -0,0 +1,746 @@ +use std::collections::BTreeMap; +use std::pin::Pin; +use std::task::{Context, Poll}; +use std::time::Duration; + +use base64::Engine; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, ReadBuf}; +use tokio::net::TcpStream; +use tokio::time::timeout; + +use super::error::SieveError; + +const MAX_LINE_BYTES: usize = 64 * 1024; +const MAX_RESPONSE_LINES: usize = 256; +const MAX_LITERAL_BYTES: usize = 2 * 1024 * 1024; +pub const MAX_SCRIPT_BYTES: usize = 1024 * 1024; + +#[derive(Debug, Clone)] +pub struct SieveCredentials { + pub host: String, + pub port: u16, + /// `starttls`, `implicit`, or `none`. + pub tls_mode: String, + pub allow_insecure_auth: bool, + pub username: String, + pub password: String, + pub timeout: Duration, +} + +#[derive(Debug, Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SieveCapabilities { + pub implementation: Option, + pub sasl: Vec, + pub extensions: Vec, + pub starttls: bool, + pub raw: BTreeMap>, +} + +#[derive(Debug, Clone, serde::Serialize)] +pub struct SieveScript { + pub name: String, + pub active: bool, +} + +#[derive(Debug)] +pub struct ManageSieveClient { + stream: SieveStream, + timeout: Duration, + capabilities: SieveCapabilities, +} + +impl ManageSieveClient { + pub async fn connect(creds: &SieveCredentials) -> Result { + let connect_future = TcpStream::connect((creds.host.as_str(), creds.port)); + let mut tcp = timeout(creds.timeout, connect_future) + .await + .map_err(|_| SieveError::Timeout)? + .map_err(|e| SieveError::ConnectionFailed(e.to_string()))?; + tcp.set_nodelay(true) + .map_err(|e| SieveError::ConnectionFailed(e.to_string()))?; + + let (mut stream, greeting) = match creds.tls_mode.as_str() { + "implicit" => { + let tls = async_native_tls::TlsConnector::new(); + let mut tls_stream = timeout(creds.timeout, tls.connect(&creds.host, tcp)) + .await + .map_err(|_| SieveError::Timeout)? + .map_err(|e| SieveError::TlsFailed(e.to_string()))?; + let greeting = read_response(&mut tls_stream, creds.timeout).await?; + (SieveStream::Tls(tls_stream), greeting) + } + "starttls" => { + let greeting = read_response(&mut tcp, creds.timeout).await?; + ensure_ok(&greeting)?; + let initial_caps = parse_capabilities(&greeting.lines); + if !initial_caps.starttls { + return Err(SieveError::TlsFailed( + "server does not advertise STARTTLS".to_string(), + )); + } + + write_all_timeout(&mut tcp, b"STARTTLS\r\n", creds.timeout).await?; + let reply = read_response(&mut tcp, creds.timeout).await?; + ensure_ok(&reply)?; + + // Do not buffer beyond the STARTTLS response. read_response() consumes + // exactly one protocol line at a time, preventing plaintext bytes from + // being replayed into the TLS stream. + let tls = async_native_tls::TlsConnector::new(); + let mut tls_stream = timeout(creds.timeout, tls.connect(&creds.host, tcp)) + .await + .map_err(|_| SieveError::Timeout)? + .map_err(|e| SieveError::TlsFailed(e.to_string()))?; + + // RFC 5804 requires the server to re-issue capabilities after TLS. + let greeting = read_response(&mut tls_stream, creds.timeout).await?; + (SieveStream::Tls(tls_stream), greeting) + } + "none" => { + let greeting = read_response(&mut tcp, creds.timeout).await?; + (SieveStream::Plain(tcp), greeting) + } + other => { + return Err(SieveError::Protocol(format!( + "unsupported TLS mode: {other}" + ))); + } + }; + + ensure_ok(&greeting)?; + let capabilities = parse_capabilities(&greeting.lines); + + if creds.tls_mode == "none" && !creds.allow_insecure_auth { + return Err(SieveError::InsecureAuthenticationBlocked); + } + if !capabilities + .sasl + .iter() + .any(|mechanism| mechanism.eq_ignore_ascii_case("PLAIN")) + { + return Err(SieveError::AuthenticationFailed( + "server does not advertise SASL PLAIN".to_string(), + )); + } + + let plain = format!("\0{}\0{}", creds.username, creds.password); + let encoded = base64::engine::general_purpose::STANDARD.encode(plain.as_bytes()); + let command = format!("AUTHENTICATE \"PLAIN\" \"{}\"\r\n", encoded); + write_all_timeout(&mut stream, command.as_bytes(), creds.timeout).await?; + let auth = read_response(&mut stream, creds.timeout).await?; + if auth.status == ResponseStatus::No || auth.status == ResponseStatus::Bye { + return Err(SieveError::AuthenticationFailed(auth.human_message())); + } + ensure_ok(&auth)?; + + Ok(Self { + stream, + timeout: creds.timeout, + capabilities, + }) + } + + pub fn capabilities(&self) -> &SieveCapabilities { + &self.capabilities + } + + pub async fn list_scripts(&mut self) -> Result, SieveError> { + let response = self.command("LISTSCRIPTS\r\n").await?; + ensure_ok(&response)?; + + let mut scripts = Vec::new(); + for line in response.lines { + if let Some(literal) = line.literal { + let name = String::from_utf8(literal) + .map_err(|_| SieveError::Protocol("script name is not UTF-8".into()))?; + scripts.push(SieveScript { + name, + active: line.after.trim().eq_ignore_ascii_case("ACTIVE"), + }); + continue; + } + + if let Some((name, rest)) = parse_quoted(&line.before) { + scripts.push(SieveScript { + name, + active: rest.trim().eq_ignore_ascii_case("ACTIVE"), + }); + } + } + Ok(scripts) + } + + pub async fn get_script(&mut self, name: &str) -> Result { + let command = format!("GETSCRIPT {}\r\n", quote(name)?); + let response = self.command(&command).await?; + ensure_ok(&response)?; + let Some(line) = response.lines.first() else { + return Err(SieveError::Protocol( + "GETSCRIPT returned no script body".into(), + )); + }; + + let bytes = if let Some(literal) = &line.literal { + literal.clone() + } else if let Some((value, _)) = parse_quoted(&line.before) { + value.into_bytes() + } else { + return Err(SieveError::Protocol( + "GETSCRIPT returned an unsupported string encoding".into(), + )); + }; + + String::from_utf8(bytes).map_err(|_| SieveError::Protocol("script is not UTF-8".into())) + } + + pub async fn check_script(&mut self, script: &str) -> Result, SieveError> { + self.send_script_command("CHECKSCRIPT", None, script).await + } + + pub async fn put_script( + &mut self, + name: &str, + script: &str, + ) -> Result, SieveError> { + if script.is_empty() { + return Err(SieveError::Protocol( + "empty Sieve scripts are not accepted".into(), + )); + } + if script.len() > MAX_SCRIPT_BYTES { + return Err(SieveError::ScriptTooLarge); + } + + // RFC 5804 recommends HAVESPACE before PUTSCRIPT for larger scripts. + let have_space = format!("HAVESPACE {} {}\r\n", quote(name)?, script.len()); + let response = self.command(&have_space).await?; + ensure_ok(&response)?; + + self.send_script_command("PUTSCRIPT", Some(name), script) + .await + } + + pub async fn activate_script(&mut self, name: Option<&str>) -> Result<(), SieveError> { + let name = match name { + Some(name) => quote(name)?, + None => "\"\"".to_string(), + }; + let response = self.command(&format!("SETACTIVE {name}\r\n")).await?; + ensure_ok(&response) + } + + pub async fn delete_script(&mut self, name: &str) -> Result<(), SieveError> { + let response = self + .command(&format!("DELETESCRIPT {}\r\n", quote(name)?)) + .await?; + ensure_ok(&response) + } + + pub async fn rename_script(&mut self, name: &str, new_name: &str) -> Result<(), SieveError> { + let response = self + .command(&format!( + "RENAMESCRIPT {} {}\r\n", + quote(name)?, + quote(new_name)? + )) + .await?; + ensure_ok(&response) + } + + pub async fn logout(&mut self) { + let _ = self.command("LOGOUT\r\n").await; + } + + async fn command(&mut self, command: &str) -> Result { + write_all_timeout(&mut self.stream, command.as_bytes(), self.timeout).await?; + read_response(&mut self.stream, self.timeout).await + } + + async fn send_script_command( + &mut self, + verb: &str, + name: Option<&str>, + script: &str, + ) -> Result, SieveError> { + if script.len() > MAX_SCRIPT_BYTES { + return Err(SieveError::ScriptTooLarge); + } + let header = match name { + Some(name) => format!("{verb} {} {{{}+}}\r\n", quote(name)?, script.len()), + None => format!("{verb} {{{}+}}\r\n", script.len()), + }; + + let mut command = Vec::with_capacity(header.len() + script.len() + 2); + command.extend_from_slice(header.as_bytes()); + command.extend_from_slice(script.as_bytes()); + command.extend_from_slice(b"\r\n"); + write_all_timeout(&mut self.stream, &command, self.timeout).await?; + let response = read_response(&mut self.stream, self.timeout).await?; + ensure_ok(&response)?; + + if response.code.as_deref() == Some("WARNINGS") { + Ok(Some(response.human_message())) + } else { + Ok(None) + } + } +} + +#[derive(Debug)] +enum SieveStream { + Tls(async_native_tls::TlsStream), + Plain(TcpStream), +} + +impl AsyncRead for SieveStream { + fn poll_read( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + match self.get_mut() { + Self::Tls(stream) => Pin::new(stream).poll_read(cx, buf), + Self::Plain(stream) => Pin::new(stream).poll_read(cx, buf), + } + } +} + +impl AsyncWrite for SieveStream { + fn poll_write( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + match self.get_mut() { + Self::Tls(stream) => Pin::new(stream).poll_write(cx, buf), + Self::Plain(stream) => Pin::new(stream).poll_write(cx, buf), + } + } + + fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + match self.get_mut() { + Self::Tls(stream) => Pin::new(stream).poll_flush(cx), + Self::Plain(stream) => Pin::new(stream).poll_flush(cx), + } + } + + fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + match self.get_mut() { + Self::Tls(stream) => Pin::new(stream).poll_shutdown(cx), + Self::Plain(stream) => Pin::new(stream).poll_shutdown(cx), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ResponseStatus { + Ok, + No, + Bye, +} + +#[derive(Debug)] +struct WireLine { + before: String, + literal: Option>, + after: String, +} + +#[derive(Debug)] +struct ProtocolResponse { + status: ResponseStatus, + code: Option, + final_line: WireLine, + lines: Vec, +} + +impl ProtocolResponse { + fn human_message(&self) -> String { + if let Some(literal) = &self.final_line.literal { + return String::from_utf8_lossy(literal).trim().to_string(); + } + + let text = format!("{}{}", self.final_line.before, self.final_line.after); + // Prefer a quoted human-readable string, falling back to the full status line. + if let Some(pos) = text.rfind('"') { + let prefix = &text[..pos]; + if let Some(start) = prefix.rfind('"') { + return unescape_quoted(&text[start + 1..pos]); + } + } + text.trim().to_string() + } +} + +async fn write_all_timeout( + stream: &mut S, + bytes: &[u8], + duration: Duration, +) -> Result<(), SieveError> { + timeout(duration, async { + stream.write_all(bytes).await?; + stream.flush().await + }) + .await + .map_err(|_| SieveError::Timeout)? + .map_err(|e| SieveError::ConnectionFailed(e.to_string())) +} + +async fn read_response( + stream: &mut S, + duration: Duration, +) -> Result { + let mut lines = Vec::new(); + for _ in 0..MAX_RESPONSE_LINES { + let line = timeout(duration, read_wire_line(stream)) + .await + .map_err(|_| SieveError::Timeout)??; + if let Some((status, code)) = parse_status(&line) { + return Ok(ProtocolResponse { + status, + code, + final_line: line, + lines, + }); + } + lines.push(line); + } + Err(SieveError::Protocol( + "response exceeded the maximum number of lines".into(), + )) +} + +async fn read_wire_line(stream: &mut S) -> Result { + let before_bytes = read_crlf_line(stream).await?; + let before = String::from_utf8(before_bytes) + .map_err(|_| SieveError::Protocol("response line is not UTF-8".into()))?; + + let Some(size) = trailing_literal_size(&before)? else { + return Ok(WireLine { + before, + literal: None, + after: String::new(), + }); + }; + + if size > MAX_LITERAL_BYTES { + return Err(SieveError::Protocol(format!( + "server literal exceeds {} bytes", + MAX_LITERAL_BYTES + ))); + } + let mut literal = vec![0u8; size]; + stream + .read_exact(&mut literal) + .await + .map_err(|e| SieveError::ConnectionFailed(e.to_string()))?; + + // A literal is a token inside the response line. The bytes following it, + // including an optional ` ACTIVE`, continue until the response CRLF. + let after_bytes = read_crlf_line(stream).await?; + let after = String::from_utf8(after_bytes) + .map_err(|_| SieveError::Protocol("literal suffix is not UTF-8".into()))?; + + Ok(WireLine { + before, + literal: Some(literal), + after, + }) +} + +async fn read_crlf_line(stream: &mut S) -> Result, SieveError> { + let mut out = Vec::with_capacity(128); + let mut byte = [0u8; 1]; + loop { + if out.len() >= MAX_LINE_BYTES { + return Err(SieveError::Protocol("response line is too long".into())); + } + stream + .read_exact(&mut byte) + .await + .map_err(|e| SieveError::ConnectionFailed(e.to_string()))?; + out.push(byte[0]); + if out.len() >= 2 && &out[out.len() - 2..] == b"\r\n" { + out.truncate(out.len() - 2); + return Ok(out); + } + } +} + +fn trailing_literal_size(line: &str) -> Result, SieveError> { + let trimmed = line.trim_end(); + let Some(open) = trimmed.rfind('{') else { + return Ok(None); + }; + if !trimmed.ends_with('}') { + return Ok(None); + } + let raw = &trimmed[open + 1..trimmed.len() - 1]; + // Server-to-client literals do not use the non-synchronizing + form. + if raw.ends_with('+') { + return Err(SieveError::Protocol( + "server sent an invalid non-synchronizing literal".into(), + )); + } + let size = raw + .parse::() + .map_err(|_| SieveError::Protocol("invalid literal length".into()))?; + Ok(Some(size)) +} + +fn parse_status(line: &WireLine) -> Option<(ResponseStatus, Option)> { + let text = line.before.trim_start(); + let (status, rest) = if let Some(rest) = strip_token_ci(text, "OK") { + (ResponseStatus::Ok, rest) + } else if let Some(rest) = strip_token_ci(text, "NO") { + (ResponseStatus::No, rest) + } else if let Some(rest) = strip_token_ci(text, "BYE") { + (ResponseStatus::Bye, rest) + } else { + return None; + }; + + let rest = rest.trim_start(); + let code = rest + .strip_prefix('(') + .and_then(|inside| inside.split_once(')')) + .map(|(inside, _)| { + inside + .split_whitespace() + .next() + .unwrap_or(inside) + .to_ascii_uppercase() + }); + Some((status, code)) +} + +fn strip_token_ci<'a>(text: &'a str, token: &str) -> Option<&'a str> { + if text.len() < token.len() || !text[..token.len()].eq_ignore_ascii_case(token) { + return None; + } + let rest = &text[token.len()..]; + if rest.is_empty() + || rest.chars().next().is_some_and(char::is_whitespace) + || rest.starts_with('(') + { + Some(rest) + } else { + None + } +} + +fn ensure_ok(response: &ProtocolResponse) -> Result<(), SieveError> { + match response.status { + ResponseStatus::Ok => Ok(()), + ResponseStatus::No | ResponseStatus::Bye => { + Err(SieveError::ServerRejected(response.human_message())) + } + } +} + +fn parse_capabilities(lines: &[WireLine]) -> SieveCapabilities { + let mut raw = BTreeMap::new(); + for line in lines { + if line.literal.is_some() { + continue; + } + let Some((name, rest)) = parse_quoted(&line.before) else { + continue; + }; + let value = parse_quoted(rest).map(|(value, _)| value); + raw.insert(name.to_ascii_uppercase(), value); + } + + let implementation = raw.get("IMPLEMENTATION").cloned().flatten(); + let sasl = raw + .get("SASL") + .and_then(Clone::clone) + .map(|value| value.split_whitespace().map(str::to_string).collect()) + .unwrap_or_default(); + let extensions = raw + .get("SIEVE") + .and_then(Clone::clone) + .map(|value| value.split_whitespace().map(str::to_string).collect()) + .unwrap_or_default(); + let starttls = raw.contains_key("STARTTLS"); + + SieveCapabilities { + implementation, + sasl, + extensions, + starttls, + raw, + } +} + +fn quote(value: &str) -> Result { + if value.is_empty() { + return Ok("\"\"".into()); + } + if value + .chars() + .any(|c| c == '\r' || c == '\n' || c == '\0' || c.is_control()) + { + return Err(SieveError::Protocol( + "script name contains unsupported control characters".into(), + )); + } + Ok(format!( + "\"{}\"", + value.replace('\\', "\\\\").replace('"', "\\\"") + )) +} + +fn parse_quoted(text: &str) -> Option<(String, &str)> { + let text = text.trim_start(); + let bytes = text.as_bytes(); + if bytes.first() != Some(&b'"') { + return None; + } + let mut out = String::new(); + let mut escaped = false; + for (idx, ch) in text[1..].char_indices() { + if escaped { + out.push(ch); + escaped = false; + continue; + } + match ch { + '\\' => escaped = true, + '"' => { + let end = 1 + idx + ch.len_utf8(); + return Some((out, &text[end..])); + } + _ => out.push(ch), + } + } + None +} + +fn unescape_quoted(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + let mut escaped = false; + for ch in text.chars() { + if escaped { + out.push(ch); + escaped = false; + } else if ch == '\\' { + escaped = true; + } else { + out.push(ch); + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn quotes_script_names() { + assert_eq!(quote("main").unwrap(), "\"main\""); + assert_eq!(quote("a\\b\"c").unwrap(), "\"a\\\\b\\\"c\""); + assert!(quote("bad\nname").is_err()); + } + + #[test] + fn parses_quoted_strings() { + let (value, rest) = parse_quoted(" \"main\\\"script\" ACTIVE").unwrap(); + assert_eq!(value, "main\"script"); + assert_eq!(rest.trim(), "ACTIVE"); + } + + #[test] + fn parses_literal_length_only_at_end_of_wire_prefix() { + assert_eq!(trailing_literal_size("{54}").unwrap(), Some(54)); + assert_eq!(trailing_literal_size("NO (X) {31}").unwrap(), Some(31)); + assert_eq!(trailing_literal_size("\"name\"").unwrap(), None); + } + + #[test] + fn parses_capabilities() { + let lines = vec![ + WireLine { + before: "\"IMPLEMENTATION\" \"Dovecot Pigeonhole\"".into(), + literal: None, + after: String::new(), + }, + WireLine { + before: "\"SASL\" \"PLAIN\"".into(), + literal: None, + after: String::new(), + }, + WireLine { + before: "\"SIEVE\" \"fileinto vacation\"".into(), + literal: None, + after: String::new(), + }, + WireLine { + before: "\"STARTTLS\"".into(), + literal: None, + after: String::new(), + }, + ]; + let caps = parse_capabilities(&lines); + assert_eq!(caps.implementation.as_deref(), Some("Dovecot Pigeonhole")); + assert_eq!(caps.sasl, vec!["PLAIN"]); + assert_eq!(caps.extensions, vec!["fileinto", "vacation"]); + assert!(caps.starttls); + } + + #[tokio::test] + async fn plaintext_manage_sieve_round_trip_requires_explicit_opt_in() { + use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + use tokio::net::TcpListener; + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut stream = BufReader::new(stream); + stream + .get_mut() + .write_all(b"\"IMPLEMENTATION\" \"RustBox test\"\r\n\"SASL\" \"PLAIN\"\r\n\"SIEVE\" \"fileinto vacation\"\r\nOK\r\n") + .await + .unwrap(); + + let mut line = String::new(); + stream.read_line(&mut line).await.unwrap(); + assert!(line.starts_with("AUTHENTICATE \"PLAIN\" ")); + stream.get_mut().write_all(b"OK\r\n").await.unwrap(); + + line.clear(); + stream.read_line(&mut line).await.unwrap(); + assert_eq!(line, "LISTSCRIPTS\r\n"); + stream + .get_mut() + .write_all(b"\"main\" ACTIVE\r\nOK\r\n") + .await + .unwrap(); + + line.clear(); + stream.read_line(&mut line).await.unwrap(); + assert_eq!(line, "LOGOUT\r\n"); + stream.get_mut().write_all(b"OK\r\n").await.unwrap(); + }); + + let creds = SieveCredentials { + host: "127.0.0.1".into(), + port, + tls_mode: "none".into(), + allow_insecure_auth: true, + username: "alice@example.com".into(), + password: "secret".into(), + timeout: Duration::from_secs(2), + }; + let mut client = ManageSieveClient::connect(&creds).await.unwrap(); + let scripts = client.list_scripts().await.unwrap(); + assert_eq!(scripts.len(), 1); + assert_eq!(scripts[0].name, "main"); + assert!(scripts[0].active); + client.logout().await; + server.await.unwrap(); + } +} diff --git a/src/sieve/error.rs b/src/sieve/error.rs new file mode 100644 index 0000000..d314a83 --- /dev/null +++ b/src/sieve/error.rs @@ -0,0 +1,37 @@ +use std::fmt; + +#[derive(Debug)] +pub enum SieveError { + ConnectionFailed(String), + TlsFailed(String), + AuthenticationFailed(String), + InsecureAuthenticationBlocked, + Protocol(String), + ServerRejected(String), + Timeout, + ScriptTooLarge, +} + +impl fmt::Display for SieveError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::ConnectionFailed(msg) => write!(f, "ManageSieve connection failed: {msg}"), + Self::TlsFailed(msg) => write!(f, "ManageSieve TLS failed: {msg}"), + Self::AuthenticationFailed(msg) => { + write!(f, "ManageSieve authentication failed: {msg}") + } + Self::InsecureAuthenticationBlocked => write!( + f, + "ManageSieve plaintext authentication is blocked; use STARTTLS/implicit TLS or explicitly enable SIEVE_ALLOW_INSECURE_AUTH" + ), + Self::Protocol(msg) => write!(f, "ManageSieve protocol error: {msg}"), + Self::ServerRejected(msg) => { + write!(f, "ManageSieve server rejected the command: {msg}") + } + Self::Timeout => write!(f, "ManageSieve operation timed out"), + Self::ScriptTooLarge => write!(f, "Sieve script exceeds the RustBox 0.0.1 size limit"), + } + } +} + +impl std::error::Error for SieveError {} diff --git a/src/sieve/mod.rs b/src/sieve/mod.rs new file mode 100644 index 0000000..4dafb6c --- /dev/null +++ b/src/sieve/mod.rs @@ -0,0 +1,5 @@ +pub mod client; +pub mod error; + +pub use client::{ManageSieveClient, SieveCredentials, SieveScript}; +pub use error::SieveError; diff --git a/src/smtp/builder.rs b/src/smtp/builder.rs new file mode 100644 index 0000000..355a88c --- /dev/null +++ b/src/smtp/builder.rs @@ -0,0 +1,222 @@ +//! Shared RFC822/MIME builder used by SMTP delivery and IMAP Sent copies. + +use lettre::message::{ + Attachment, Mailbox, MessageBuilder, MultiPart, SinglePart, header::ContentType, +}; + +use crate::smtp::types::{PgpMode, SendableMessage}; + +pub fn build_message( + message: &SendableMessage, + message_id: &str, +) -> Result { + let from_mailbox: Mailbox = message + .from + .parse() + .map_err(|e: lettre::address::AddressError| e.to_string())?; + + let mut builder: MessageBuilder = lettre::Message::builder() + .from(from_mailbox) + .subject(&message.subject) + .message_id(Some(message_id.to_string())); + + for addr in &message.to { + builder = builder.to(addr.parse::().map_err(|e| e.to_string())?); + } + for addr in &message.cc { + builder = builder.cc(addr.parse::().map_err(|e| e.to_string())?); + } + for addr in &message.bcc { + builder = builder.bcc(addr.parse::().map_err(|e| e.to_string())?); + } + if let Some(reply_to) = &message.reply_to { + builder = builder.reply_to(reply_to.parse::().map_err(|e| e.to_string())?); + } + if let Some(in_reply_to) = &message.in_reply_to { + builder = builder.in_reply_to(in_reply_to.clone()); + } + if let Some(references) = &message.references { + builder = builder.references(references.clone()); + } + builder = crate::smtp::types::apply_optional_headers(builder, message); + + let content = build_content(message)?; + builder.multipart(content).map_err(|e| e.to_string()) +} + +pub fn build_rfc822(message: &SendableMessage, message_id: &str) -> Result, String> { + Ok(build_message(message, message_id)?.formatted()) +} + +fn build_content(message: &SendableMessage) -> Result { + let Some(pgp) = message.pgp.as_ref() else { + return build_clear_content(message); + }; + if pgp.mode == PgpMode::None { + return build_clear_content(message); + } + + if pgp.inline { + if !message.attachments.is_empty() { + return Err("Inline OpenPGP cannot be used with attachments; use PGP/MIME".to_string()); + } + let text = match pgp.mode { + PgpMode::Sign => { + let secret = pgp + .secret_key_armor + .as_deref() + .ok_or_else(|| "A private OpenPGP key is required for signing".to_string())?; + crate::pgp::inline_sign(&message.text_body, secret, &pgp.passphrase)? + } + PgpMode::Encrypt => { + crate::pgp::inline_encrypt(&message.text_body, &pgp.recipient_public_keys, None)? + } + PgpMode::SignEncrypt => { + let secret = pgp + .secret_key_armor + .as_deref() + .ok_or_else(|| "A private OpenPGP key is required for signing".to_string())?; + crate::pgp::inline_encrypt( + &message.text_body, + &pgp.recipient_public_keys, + Some((secret, pgp.passphrase.as_str())), + )? + } + PgpMode::None => message.text_body.clone(), + }; + return Ok(MultiPart::alternative().singlepart( + SinglePart::builder() + .content_type(ContentType::TEXT_PLAIN) + .body(text), + )); + } + + let clear = build_clear_content(message)?; + match pgp.mode { + PgpMode::None => Ok(clear), + PgpMode::Sign => build_signed(clear, pgp), + PgpMode::Encrypt => build_encrypted(clear.formatted(), pgp), + PgpMode::SignEncrypt => { + let signed = build_signed(clear, pgp)?; + build_encrypted(signed.formatted(), pgp) + } + } +} + +fn build_signed( + clear: MultiPart, + pgp: &crate::smtp::types::PgpSendConfig, +) -> Result { + let secret = pgp + .secret_key_armor + .as_deref() + .ok_or_else(|| "A private OpenPGP key is required for signing".to_string())?; + let signature = crate::pgp::sign_detached(&clear.formatted(), secret, &pgp.passphrase)?; + let signature_type: ContentType = "application/pgp-signature; name=signature.asc" + .parse() + .map_err(|e| format!("Invalid PGP signature content type: {e}"))?; + let signature_part = SinglePart::builder() + .content_type(signature_type) + .body(signature); + + Ok(MultiPart::signed( + "application/pgp-signature".to_string(), + "pgp-sha256".to_string(), + ) + .multipart(clear) + .singlepart(signature_part)) +} + +fn build_encrypted( + payload: Vec, + pgp: &crate::smtp::types::PgpSendConfig, +) -> Result { + let encrypted = crate::pgp::encrypt_bytes(&payload, &pgp.recipient_public_keys)?; + let version_type: ContentType = "application/pgp-encrypted" + .parse() + .map_err(|e| format!("Invalid PGP version content type: {e}"))?; + let payload_type: ContentType = "application/octet-stream; name=encrypted.asc" + .parse() + .map_err(|e| format!("Invalid PGP payload content type: {e}"))?; + + Ok( + MultiPart::encrypted("application/pgp-encrypted".to_string()) + .singlepart( + SinglePart::builder() + .content_type(version_type) + .body("Version: 1\r\n".to_string()), + ) + .singlepart( + SinglePart::builder() + .content_type(payload_type) + .body(encrypted), + ), + ) +} + +fn build_clear_content(message: &SendableMessage) -> Result { + let html_body = message.html_body.as_deref().unwrap_or(""); + let (inline_atts, file_atts): (Vec<_>, Vec<_>) = message.attachments.iter().partition(|att| { + att.content_id + .as_ref() + .is_some_and(|cid| html_body.contains(&format!("cid:{cid}"))) + }); + + let body_part = if let Some(html) = &message.html_body { + if inline_atts.is_empty() { + MultiPart::alternative() + .singlepart( + SinglePart::builder() + .content_type(ContentType::TEXT_PLAIN) + .body(message.text_body.clone()), + ) + .singlepart( + SinglePart::builder() + .content_type(ContentType::TEXT_HTML) + .body(html.clone()), + ) + } else { + let mut related = MultiPart::related().singlepart( + SinglePart::builder() + .content_type(ContentType::TEXT_HTML) + .body(html.clone()), + ); + for att in inline_atts { + let content_type: ContentType = + att.content_type.parse().unwrap_or(ContentType::TEXT_PLAIN); + let content_id = att.content_id.as_deref().unwrap_or("unknown"); + related = related.singlepart( + Attachment::new_inline(content_id.to_string()) + .body(att.data.clone(), content_type), + ); + } + MultiPart::alternative() + .singlepart( + SinglePart::builder() + .content_type(ContentType::TEXT_PLAIN) + .body(message.text_body.clone()), + ) + .multipart(related) + } + } else { + MultiPart::alternative().singlepart( + SinglePart::builder() + .content_type(ContentType::TEXT_PLAIN) + .body(message.text_body.clone()), + ) + }; + + if file_atts.is_empty() { + Ok(body_part) + } else { + let mut mixed = MultiPart::mixed().multipart(body_part); + for att in file_atts { + let content_type: ContentType = + att.content_type.parse().unwrap_or(ContentType::TEXT_PLAIN); + mixed = mixed.singlepart( + Attachment::new(att.filename.clone()).body(att.data.clone(), content_type), + ); + } + Ok(mixed) + } +} diff --git a/src/smtp/client.rs b/src/smtp/client.rs new file mode 100644 index 0000000..1981133 --- /dev/null +++ b/src/smtp/client.rs @@ -0,0 +1,157 @@ +//! SMTP client abstraction and real implementation. + +use async_trait::async_trait; + +// Import and re-export types for backward compatibility +pub use crate::smtp::error::SmtpError; +pub use crate::smtp::types::{AttachmentData, SendableMessage, SmtpCredentials, SmtpSendResult}; + +// --------------------------------------------------------------------------- +// Trait definition +// --------------------------------------------------------------------------- + +/// Abstraction over SMTP operations. +/// +/// Every method receives explicit connection parameters so that the trait +/// remains stateless — no persistent connections are held. +/// +/// The `Send + Sync` bounds allow implementations to be shared across +/// Tokio tasks and stored in `Arc`. +#[allow(clippy::double_must_use)] +#[async_trait] +pub trait SmtpClient: Send + Sync { + /// Send an email message. Returns the generated Message-ID and exact RFC822 bytes. + async fn send_message( + &self, + creds: &SmtpCredentials, + message: &SendableMessage, + ) -> Result; + + /// Verify that the configured SMTP endpoint accepts an authenticated NOOP. + async fn check_connection(&self, _creds: &SmtpCredentials) -> Result<(), SmtpError> { + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// Real implementation backed by lettre +// --------------------------------------------------------------------------- + +/// Production SMTP client that uses `lettre`. +/// +/// This is a stateless unit struct — every method creates a fresh connection, +/// performs the operation, and disconnects. +pub struct RealSmtpClient; + +#[async_trait] +impl SmtpClient for RealSmtpClient { + async fn send_message( + &self, + creds: &SmtpCredentials, + message: &SendableMessage, + ) -> Result { + use lettre::transport::smtp::authentication::Credentials; + use lettre::{AsyncSmtpTransport, AsyncTransport, Tokio1Executor}; + + let message_id = format!( + "<{}.{}@{}>", + uuid::Uuid::new_v4(), + uuid::Uuid::new_v4(), + creds.host + ); + let email = crate::smtp::builder::build_message(message, &message_id) + .map_err(SmtpError::SendFailed)?; + let rfc822 = email.formatted(); + + let smtp_creds = Credentials::new(creds.email.clone(), creds.password.clone()); + let transport: AsyncSmtpTransport = if creds.tls && creds.port == 587 { + AsyncSmtpTransport::::starttls_relay(&creds.host) + .map_err(|e| SmtpError::ConnectionFailed(e.to_string()))? + .port(creds.port) + .credentials(smtp_creds) + .build() + } else if creds.tls { + AsyncSmtpTransport::::relay(&creds.host) + .map_err(|e| SmtpError::ConnectionFailed(e.to_string()))? + .port(creds.port) + .credentials(smtp_creds) + .build() + } else { + AsyncSmtpTransport::::builder_dangerous(&creds.host) + .port(creds.port) + .credentials(smtp_creds) + .build() + }; + + transport.send(email).await.map_err(|e| { + let msg = e.to_string(); + let lower = msg.to_lowercase(); + if lower.contains("authentication") + || lower.contains("credentials") + || lower.contains("auth") + { + SmtpError::AuthenticationFailed + } else if lower.contains("connect") + || lower.contains("dns") + || lower.contains("resolve") + || lower.contains("timeout") + || lower.contains("tls") + { + SmtpError::ConnectionFailed(msg) + } else { + SmtpError::SendFailed(msg) + } + })?; + + Ok(SmtpSendResult { message_id, rfc822 }) + } + + async fn check_connection(&self, creds: &SmtpCredentials) -> Result<(), SmtpError> { + use lettre::transport::smtp::authentication::Credentials; + use lettre::{AsyncSmtpTransport, Tokio1Executor}; + + let smtp_creds = Credentials::new(creds.email.clone(), creds.password.clone()); + let transport: AsyncSmtpTransport = if creds.tls && creds.port == 587 { + AsyncSmtpTransport::::starttls_relay(&creds.host) + .map_err(|e| SmtpError::ConnectionFailed(e.to_string()))? + .port(creds.port) + .credentials(smtp_creds) + .build() + } else if creds.tls { + AsyncSmtpTransport::::relay(&creds.host) + .map_err(|e| SmtpError::ConnectionFailed(e.to_string()))? + .port(creds.port) + .credentials(smtp_creds) + .build() + } else { + AsyncSmtpTransport::::builder_dangerous(&creds.host) + .port(creds.port) + .credentials(smtp_creds) + .build() + }; + + let accepted = transport.test_connection().await.map_err(|e| { + let message = e.to_string(); + if message.to_ascii_lowercase().contains("auth") { + SmtpError::AuthenticationFailed + } else { + SmtpError::ConnectionFailed(message) + } + })?; + if accepted { + Ok(()) + } else { + Err(SmtpError::ConnectionFailed( + "SMTP NOOP was not accepted".to_string(), + )) + } + } +} + +// --------------------------------------------------------------------------- +// Mock implementation (test-only) +// --------------------------------------------------------------------------- + +#[cfg(test)] +#[path = "mock.rs"] +pub mod mock; diff --git a/src/smtp/error.rs b/src/smtp/error.rs new file mode 100644 index 0000000..9ba2bd1 --- /dev/null +++ b/src/smtp/error.rs @@ -0,0 +1,27 @@ +//! SMTP error types. + +use std::fmt; + +/// Errors that can occur during SMTP operations. +#[derive(Debug)] +#[allow(clippy::enum_variant_names)] +pub enum SmtpError { + /// Could not connect to the SMTP server. + ConnectionFailed(String), + /// The server rejected our credentials. + AuthenticationFailed, + /// The message could not be sent. + SendFailed(String), +} + +impl fmt::Display for SmtpError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + SmtpError::ConnectionFailed(msg) => write!(f, "Connection failed: {msg}"), + SmtpError::AuthenticationFailed => write!(f, "Authentication failed"), + SmtpError::SendFailed(msg) => write!(f, "Send failed: {msg}"), + } + } +} + +impl std::error::Error for SmtpError {} diff --git a/src/smtp/mock.rs b/src/smtp/mock.rs new file mode 100644 index 0000000..75d13df --- /dev/null +++ b/src/smtp/mock.rs @@ -0,0 +1,242 @@ +//! Mock SMTP client for testing. + +use super::{ + AttachmentData, SendableMessage, SmtpClient, SmtpCredentials, SmtpError, SmtpSendResult, +}; +use async_trait::async_trait; +use std::sync::Mutex; + +/// A mock SMTP client that records sent messages. +/// +/// Uses interior mutability (`Mutex`) so it can be shared behind `&self`. +/// Build it up with the `.with_*()` builder methods, then pass it wherever +/// an `&dyn SmtpClient` is needed. +pub struct MockSmtpClient { + should_fail: Mutex>, + sent_messages: Mutex>, +} + +impl MockSmtpClient { + /// Create a new empty mock. + pub fn new() -> Self { + Self { + should_fail: Mutex::new(None), + sent_messages: Mutex::new(Vec::new()), + } + } + + /// Make every subsequent call return this error. + pub fn with_error(self, error: SmtpError) -> Self { + *self.should_fail.lock().unwrap() = Some(error); + self + } + + /// Return the number of messages sent through this mock. + pub fn sent_count(&self) -> usize { + self.sent_messages.lock().unwrap().len() + } + + /// Return a clone of the most recently sent message, if any. + pub fn last_sent(&self) -> Option { + self.sent_messages.lock().unwrap().last().cloned() + } +} + +impl Default for MockSmtpClient { + fn default() -> Self { + Self::new() + } +} + +/// Helper to clone an `SmtpError` for the mock (the real errors are not +/// `Clone`, so we reconstruct them). +fn clone_error(err: &SmtpError) -> SmtpError { + match err { + SmtpError::ConnectionFailed(msg) => SmtpError::ConnectionFailed(msg.clone()), + SmtpError::AuthenticationFailed => SmtpError::AuthenticationFailed, + SmtpError::SendFailed(msg) => SmtpError::SendFailed(msg.clone()), + } +} + +#[async_trait] +impl SmtpClient for MockSmtpClient { + async fn send_message( + &self, + _creds: &SmtpCredentials, + message: &SendableMessage, + ) -> Result { + if let Some(ref err) = *self.should_fail.lock().unwrap() { + return Err(clone_error(err)); + } + self.sent_messages.lock().unwrap().push(message.clone()); + let message_id = format!("", uuid::Uuid::new_v4()); + let rfc822 = crate::smtp::builder::build_rfc822(message, &message_id) + .map_err(SmtpError::SendFailed)?; + Ok(SmtpSendResult { message_id, rfc822 }) + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + /// Convenience helper to build dummy credentials for tests. + fn test_creds() -> SmtpCredentials { + SmtpCredentials { + host: "smtp.example.com".to_string(), + port: 587, + tls: true, + email: "user@example.com".to_string(), + password: "hunter2".to_string(), + } + } + + /// Build a minimal test message. + fn test_message() -> SendableMessage { + SendableMessage { + from: "user@example.com".to_string(), + to: vec!["recipient@example.com".to_string()], + cc: vec![], + bcc: vec![], + reply_to: None, + organization: None, + subject: "Test subject".to_string(), + text_body: "Hello, world!".to_string(), + html_body: None, + in_reply_to: None, + references: None, + read_receipt_to: None, + priority: "normal".to_string(), + extra_headers: vec![], + attachments: vec![], + pgp: None, + } + } + + #[tokio::test] + async fn mock_send_succeeds_without_error() { + let mock = MockSmtpClient::new(); + let result = mock.send_message(&test_creds(), &test_message()).await; + assert!(result.is_ok()); + let result = result.unwrap(); + assert!(!result.message_id.is_empty()); + assert!(!result.rfc822.is_empty()); + } + + #[tokio::test] + async fn mock_send_captures_message() { + let mock = MockSmtpClient::new(); + let msg = SendableMessage { + from: "sender@example.com".to_string(), + to: vec!["alice@example.com".to_string()], + cc: vec!["bob@example.com".to_string()], + bcc: vec![], + reply_to: None, + organization: None, + subject: "Important".to_string(), + text_body: "Please read.".to_string(), + html_body: Some("

Please read.

".to_string()), + in_reply_to: Some("".to_string()), + references: Some("".to_string()), + read_receipt_to: Some("sender@example.com".to_string()), + priority: "high".to_string(), + extra_headers: vec![], + attachments: vec![AttachmentData { + filename: "notes.txt".to_string(), + content_type: "text/plain".to_string(), + data: b"some content".to_vec(), + content_id: None, + }], + pgp: None, + }; + + let result = mock.send_message(&test_creds(), &msg).await; + assert!(result.is_ok()); + assert_eq!(mock.sent_count(), 1); + + let captured = mock.last_sent().unwrap(); + assert_eq!(captured.from, "sender@example.com"); + assert_eq!(captured.to, vec!["alice@example.com"]); + assert_eq!(captured.cc, vec!["bob@example.com"]); + assert_eq!(captured.subject, "Important"); + assert_eq!(captured.text_body, "Please read."); + assert_eq!(captured.html_body.as_deref(), Some("

Please read.

")); + assert_eq!( + captured.in_reply_to.as_deref(), + Some("") + ); + assert_eq!( + captured.references.as_deref(), + Some("") + ); + assert_eq!( + captured.read_receipt_to.as_deref(), + Some("sender@example.com") + ); + assert_eq!(captured.priority, "high"); + assert_eq!(captured.attachments.len(), 1); + assert_eq!(captured.attachments[0].filename, "notes.txt"); + } + + #[tokio::test] + async fn mock_with_error_returns_error() { + let mock = MockSmtpClient::new().with_error(SmtpError::AuthenticationFailed); + + let err = mock + .send_message(&test_creds(), &test_message()) + .await + .unwrap_err(); + assert!(matches!(err, SmtpError::AuthenticationFailed)); + + // Ensure no messages were recorded. + assert_eq!(mock.sent_count(), 0); + } + + #[tokio::test] + async fn real_smtp_connection_fails_with_bad_host() { + use crate::smtp::client::RealSmtpClient; + + let client = RealSmtpClient; + let creds = SmtpCredentials { + host: "invalid.host.test".to_string(), + port: 587, + tls: true, + email: "user@invalid.host.test".to_string(), + password: "password".to_string(), + }; + let msg = test_message(); + + let err = client.send_message(&creds, &msg).await.unwrap_err(); + // With a fake host the connection should fail. + assert!( + matches!( + err, + SmtpError::ConnectionFailed(_) | SmtpError::SendFailed(_) + ), + "Expected ConnectionFailed or SendFailed, got: {err}" + ); + } + + #[tokio::test] + async fn smtp_error_display_formats_correctly() { + let cases: Vec<(SmtpError, &str)> = vec![ + ( + SmtpError::ConnectionFailed("timeout".to_string()), + "Connection failed: timeout", + ), + (SmtpError::AuthenticationFailed, "Authentication failed"), + ( + SmtpError::SendFailed("rejected by server".to_string()), + "Send failed: rejected by server", + ), + ]; + + for (err, expected) in cases { + assert_eq!(err.to_string(), expected); + } + } +} diff --git a/src/smtp/mod.rs b/src/smtp/mod.rs new file mode 100644 index 0000000..b2dd8a5 --- /dev/null +++ b/src/smtp/mod.rs @@ -0,0 +1,4 @@ +pub mod builder; +pub mod client; +pub mod error; +pub mod types; diff --git a/src/smtp/types.rs b/src/smtp/types.rs new file mode 100644 index 0000000..76214af --- /dev/null +++ b/src/smtp/types.rs @@ -0,0 +1,167 @@ +//! SMTP data types. + +/// Parameters needed to establish an SMTP connection. +/// Passed explicitly to every trait method so the trait stays stateless. +#[derive(Debug, Clone)] +pub struct SmtpCredentials { + pub host: String, + pub port: u16, + pub tls: bool, + pub email: String, + pub password: String, +} + +#[derive(Debug, Clone)] +pub struct SmtpSendResult { + pub message_id: String, + pub rfc822: Vec, +} + +/// A message ready to be sent via SMTP. +#[derive(Clone)] +pub struct SendableMessage { + /// Sender email address. + pub from: String, + /// Primary recipients. + pub to: Vec, + /// CC recipients. + pub cc: Vec, + /// BCC recipients. + pub bcc: Vec, + /// Optional Reply-To mailbox. + pub reply_to: Option, + /// Optional organization label from the selected identity. + pub organization: Option, + /// Subject line. + pub subject: String, + /// Plain-text body. + pub text_body: String, + /// Optional HTML body. + pub html_body: Option, + /// In-Reply-To header value for threading. + pub in_reply_to: Option, + /// References header value for threading. + pub references: Option, + /// Address that should receive an MDN/read receipt request. + pub read_receipt_to: Option, + /// Message priority: `high`, `normal`, or `low`. + pub priority: String, + /// Administrator-configured headers applied to every outgoing copy. + pub extra_headers: Vec<(String, String)>, + /// File attachments. + pub attachments: Vec, + /// Optional OpenPGP transformation applied to the MIME entity. + pub pgp: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PgpMode { + None, + Sign, + Encrypt, + SignEncrypt, +} + +#[derive(Clone)] +pub struct PgpSendConfig { + pub mode: PgpMode, + pub inline: bool, + pub secret_key_armor: Option, + pub passphrase: String, + pub recipient_public_keys: Vec, +} + +/// A single file attachment to include in an outgoing message. +#[derive(Debug, Clone)] +pub struct AttachmentData { + /// Filename as it should appear to the recipient. + pub filename: String, + /// MIME content type (e.g. "application/pdf"). + pub content_type: String, + /// Raw file content. + pub data: Vec, + /// Optional Content-ID for inline images (referenced via `cid:` in HTML). + /// When set, the attachment is treated as an inline image rather than a + /// regular file attachment. + pub content_id: Option, +} + +/// Apply RustBox-managed optional headers shared by SMTP delivery and the +/// copy appended to IMAP Sent. Header names are static and values are stripped +/// of CR/LF before they reach lettre's raw header API. +pub fn apply_optional_headers( + builder: lettre::message::MessageBuilder, + message: &SendableMessage, +) -> lettre::message::MessageBuilder { + use lettre::message::header::{HeaderName, HeaderValue}; + + let mut builder = apply_optional_header_values( + builder, + message.read_receipt_to.as_deref(), + message.organization.as_deref(), + &message.priority, + ); + for (name, value) in &message.extra_headers { + if let Ok(name) = HeaderName::new_from_ascii(name.clone()) { + let clean_value = value + .chars() + .map(|ch| if matches!(ch, '\r' | '\n') { ' ' } else { ch }) + .collect::() + .trim() + .to_string(); + if !clean_value.is_empty() { + builder = builder.raw_header(HeaderValue::new(name, clean_value)); + } + } + } + builder +} + +/// Apply the optional outgoing headers without requiring a full SendableMessage. +/// This is also used for RFC822 draft snapshots appended to IMAP. +pub fn apply_optional_header_values( + mut builder: lettre::message::MessageBuilder, + read_receipt_to: Option<&str>, + organization: Option<&str>, + priority: &str, +) -> lettre::message::MessageBuilder { + use lettre::message::header::{HeaderName, HeaderValue}; + + fn clean(value: &str) -> String { + value + .chars() + .map(|ch| if matches!(ch, '\r' | '\n') { ' ' } else { ch }) + .collect::() + .trim() + .to_string() + } + + fn raw(name: &'static str, value: String) -> HeaderValue { + HeaderValue::new(HeaderName::new_from_ascii_str(name), value) + } + + if let Some(address) = read_receipt_to.map(clean).filter(|value| !value.is_empty()) { + builder = builder.raw_header(raw("Disposition-Notification-To", address)); + } + + if let Some(organization) = organization.map(clean).filter(|value| !value.is_empty()) { + builder = builder.raw_header(raw("Organization", organization)); + } + + let normalized_priority = priority.trim().to_ascii_lowercase(); + match normalized_priority.as_str() { + "high" => { + builder = builder + .raw_header(raw("Importance", "high".to_string())) + .raw_header(raw("X-Priority", "1 (Highest)".to_string())); + } + "low" => { + builder = builder + .raw_header(raw("Importance", "low".to_string())) + .raw_header(raw("X-Priority", "5 (Lowest)".to_string())); + } + _ => {} + } + + builder +} diff --git a/src/ui.rs b/src/ui.rs new file mode 100644 index 0000000..f089908 --- /dev/null +++ b/src/ui.rs @@ -0,0 +1,93 @@ +use std::sync::Arc; + +use axum::Extension; +use axum::http::{StatusCode, Uri, header}; +use axum::response::{IntoResponse, Response}; + +use crate::config::AppConfig; + +const INDEX_HTML: &str = include_str!("web/index.html"); +const APP_CSS: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/rustbox-ui.css")); +const APP_JS: &[u8] = include_bytes!("web/app.js"); +const ICONS: &[u8] = include_bytes!("web/icons.svg"); +const I18N_EN: &[u8] = include_bytes!("web/i18n/en.json"); +const I18N_PL: &[u8] = include_bytes!("web/i18n/pl.json"); +const OXI_BASE: &str = include_str!("../UPSTREAM_BASE"); + +const FAVICON: &str = r##""##; + +fn bytes_response( + content_type: &'static str, + cache: &'static str, + body: &'static [u8], +) -> Response { + ( + StatusCode::OK, + [ + (header::CONTENT_TYPE, content_type), + (header::CACHE_CONTROL, cache), + (header::X_CONTENT_TYPE_OPTIONS, "nosniff"), + ], + body, + ) + .into_response() +} + +pub async fn serve_ui(uri: Uri, Extension(config): Extension>) -> Response { + let base_path = config.normalized_base_path(); + let request_path = uri.path(); + let relative = if base_path == "/" { + request_path.trim_start_matches('/') + } else { + request_path + .strip_prefix(base_path.trim_end_matches('/')) + .unwrap_or(request_path) + .trim_start_matches('/') + }; + + match relative { + // Internal 0.0.1 snapshots change frequently while keeping the same version. + // Do not let a browser mix a new HTML/JS bundle with stale CSS/icons. + "assets/app.css" => bytes_response("text/css; charset=utf-8", "no-store", APP_CSS), + "assets/app.js" => bytes_response("text/javascript; charset=utf-8", "no-store", APP_JS), + "assets/icons.svg" => bytes_response("image/svg+xml", "no-store", ICONS), + "assets/i18n/en.json" => { + bytes_response("application/json; charset=utf-8", "no-store", I18N_EN) + } + "assets/i18n/pl.json" => { + bytes_response("application/json; charset=utf-8", "no-store", I18N_PL) + } + "favicon.svg" => ( + StatusCode::OK, + [ + (header::CONTENT_TYPE, "image/svg+xml"), + (header::CACHE_CONTROL, "no-store"), + (header::X_CONTENT_TYPE_OPTIONS, "nosniff"), + ], + FAVICON, + ) + .into_response(), + _ => { + let asset_prefix = if base_path == "/" { + "" + } else { + base_path.trim_end_matches('/') + }; + let html = INDEX_HTML + .replace("__RUSTBOX_BASE_PATH__", base_path) + .replace("__RUSTBOX_ASSET_PREFIX__", asset_prefix) + .replace("__RUSTBOX_VERSION__", env!("CARGO_PKG_VERSION")) + .replace("__RUSTBOX_OXI_BASE__", OXI_BASE.trim()); + ( + StatusCode::OK, + [ + (header::CONTENT_TYPE, "text/html; charset=utf-8"), + (header::CACHE_CONTROL, "no-store"), + (header::X_CONTENT_TYPE_OPTIONS, "nosniff"), + ], + html, + ) + .into_response() + } + } +} diff --git a/src/web/app.js b/src/web/app.js new file mode 100644 index 0000000..14b6249 --- /dev/null +++ b/src/web/app.js @@ -0,0 +1,7050 @@ +(() => { + "use strict"; + + const root = document.documentElement; + const configuredBase = root.dataset.basePath || "/"; + const BASE = configuredBase === "/" ? "" : configuredBase.replace(/\/$/, ""); + const API = `${BASE}/api`; + const ICONS = `${BASE}/assets/icons.svg`; + + const state = { + accounts: [], + accountId: localStorage.getItem("rustbox.activeAccount") || null, + folders: [], + folder: "INBOX", + collapsedFolders: new Set(), + page: 0, + perPage: 50, + total: 0, + messages: [], + selected: null, + selectedMessages: new Set(), + selectionMode: false, + searchContext: null, + searchAllResults: null, + currentDetail: null, + specialFolders: { sent: null, drafts: null, trash: null, junk: null, archive: null }, + tags: [], + sieveScripts: [], + sieveInfo: null, + sieveEditingRuleId: null, + identities: [], + pgpKeys: [], + pgpPrefs: { wkdEnabled: true, hkpEnabled: false, requireTrustedEncryption: false }, + pgpRecipientStatus: [], + authPolicy: { rememberMeEnabled: true, rememberMeDays: 30, sessionTimeoutHours: 24 }, + contacts: [], + contactGroups: [], + contactPhotoUrl: null, + calendarCursor: new Date(), + calendarView: localStorage.getItem("rustbox.calendarView") || "month", + threadExpanded: localStorage.getItem("rustbox.threadExpanded") !== "false", + calendarEvents: [], + savedSearches: [], + notificationPrefs: { enabled: true, sound: false, folders: ["INBOX"], dnd_enabled: false, dnd_start: "22:00", dnd_end: "07:00" }, + mailPrefs: { messages_per_page: 50, mark_read_delay_ms: 0, remote_images: "ask", preferred_body: "auto", thread_mode: "threaded", collapse_quotes: true, show_snippets: true, default_sort: "date_desc", flagged_on_top: false, sync_imap_keywords: false }, + currentFolderFlaggedOnTop: false, + markReadTimer: null, + ws: null, + wsRetryTimer: null, + realtimeRefreshTimer: null, + composeDraftId: null, + composeAttachments: [], + composeDirty: false, + composeNeedsRemoteSync: false, + composeUploading: false, + composeReplyMeta: null, + composeInsertRange: null, + composeSelectedImage: null, + composeSignatureGeneration: 0, + identitySignatureRange: null, + inlineAttachmentIds: new Set(), + inlineImageCleanupTimer: null, + imageEditorBitmap: null, + imageEditorBlob: null, + draftSaveTimer: null, + prefs: { + theme: "system", density: "compact", preview: "right", scale: "normal", language: "en", composeFormat: "html", + composeAutosaveMs: 3000, defaultPriority: "normal", requestMdnDefault: false, attachmentReminder: true, composeFontFamily: "system", replyPosition: "above", quoteStyle: "standard", forwardMode: "inline", spellcheckEnabled: true, + colorPreset: "sage", accentColor: "#6f9c86", backgroundColor: "#121513", panelColor: "#181c19", textColor: "#edf0ec", fontFamily: "system", fontSize: 13, + rowHeight: 58, folderPaneWidth: 228, messagePaneWidth: 420, borderRadius: 4, + contrast: "normal", showAvatars: true, showAttachmentIcons: true, showFlags: true, reduceMotion: false, dateFormat: "locale", timeFormat: "locale", + iconSize: 18, toolbarDensity: "normal", sidebarItemHeight: 29, readerMaxWidth: 0, composerWidth: 900, selectionColor: "#28352e", unreadFontWeight: 730, unreadBadgeStyle: "dot", compactHeaders: false, showBorders: true, scrollbarDensity: "thin", monochrome: false, folderBackground: "", messageBackground: "", readerBackground: "", + }, + translations: {}, + quotaWarningLevel: null, + networkOnline: navigator.onLine, + listStateTimer: null, + }; + + const $ = (selector, parent = document) => parent.querySelector(selector); + const $$ = (selector, parent = document) => [...parent.querySelectorAll(selector)]; + const el = (tag, className, text) => { + const node = document.createElement(tag); + if (className) node.className = className; + if (text !== undefined) node.textContent = text; + return node; + }; + + function svgIcon(name) { + return ``; + } + + function escapeHtml(value) { + return String(value ?? "") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); + } + + function stripRustBoxUiLeak(value) { + let text = String(value ?? ""); + // Strip only RustBox's own embedded SVG sprite fragments if malformed MIME + // content captured adjacent UI markup. Legitimate SVG/code in mail remains. + text = text.replace(/]*>\s*]*(?:\/assets\/icons\.svg|assets\/icons\.svg)[^>]*><\/use>\s*<\/svg>/gi, ""); + text = text.replace(/<svg\b[\s\S]*?(?:\/assets\/icons\.svg|assets\/icons\.svg)[\s\S]*?<\/svg>/gi, ""); + return text; + } + + function cleanPlainMessageText(value) { + return stripRustBoxUiLeak(value); + } + + function sanitizeMessageHtml(html) { + const doc = new DOMParser().parseFromString(stripRustBoxUiLeak(html), "text/html"); + doc.querySelectorAll("script,iframe,frame,frameset,object,embed,applet,form,input,button,select,textarea,base,meta,link,svg,math").forEach((node) => node.remove()); + doc.querySelectorAll("*").forEach((node) => { + for (const attr of [...node.attributes]) { + const name = attr.name.toLowerCase(); + const value = attr.value.trim(); + if (name.startsWith("on") || ["srcdoc", "formaction"].includes(name)) node.removeAttribute(attr.name); + if (["href", "src", "background", "action", "poster"].includes(name) && /^\s*javascript:/i.test(value)) node.removeAttribute(attr.name); + } + }); + return doc.body.innerHTML; + } + + function apiPath(path) { + return `${API}${path.startsWith("/") ? path : `/${path}`}`; + } + + function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + function setConnectivityState(online, detail = "") { + state.networkOnline = Boolean(online); + document.body.classList.toggle("network-offline", !state.networkOnline); + const banner = $("#offline-banner"); + if (banner) { + banner.classList.toggle("hidden", state.networkOnline); + banner.textContent = detail || (navigator.onLine ? "RustBox cannot reach the server. Read-only cached content may still be visible." : "You are offline. RustBox will reconnect when the network returns."); + } + const label = $("#connection-label"); + if (!label) return; + if (!state.networkOnline) label.textContent = navigator.onLine ? "Reconnecting" : "Offline"; + else if (["Offline", "Reconnecting", "Connection error"].includes(label.textContent)) label.textContent = "Connected"; + } + + function listStateStorageKey(folder = state.folder) { + return `rustbox.listState.${state.accountId || "anonymous"}.${encodeURIComponent(folder || "INBOX")}`; + } + + function lastFolderStorageKey() { + return `rustbox.lastFolder.${state.accountId || "anonymous"}`; + } + + function activeSearchStorageKey() { + return `rustbox.activeSearch.${state.accountId || "anonymous"}`; + } + + function saveActiveSearchContext() { + if (!state.searchContext) return sessionStorage.removeItem(activeSearchStorageKey()); + sessionStorage.setItem(activeSearchStorageKey(), JSON.stringify(state.searchContext)); + } + + function readActiveSearchContext() { + try { + const parsed = JSON.parse(sessionStorage.getItem(activeSearchStorageKey()) || "null"); + return parsed && typeof parsed.params === "string" ? parsed : null; + } catch (_) { + return null; + } + } + + function clearActiveSearchContext() { + state.searchContext = null; + state.searchAllResults = null; + sessionStorage.removeItem(activeSearchStorageKey()); + } + + function readMessageListState(folder = state.folder) { + try { + const parsed = JSON.parse(localStorage.getItem(listStateStorageKey(folder)) || "null"); + if (!parsed || typeof parsed !== "object") return null; + return { + scrollTop: Math.max(0, Number(parsed.scrollTop) || 0), + page: Math.max(0, Number(parsed.page) || 0), + }; + } catch (_) { + return null; + } + } + + function saveMessageListState() { + if (!state.accountId) return; + const list = $("#message-list"); + if (!list) return; + if (state.searchContext) { + state.searchContext.scrollTop = list.scrollTop; + saveActiveSearchContext(); + return; + } + localStorage.setItem(listStateStorageKey(), JSON.stringify({ scrollTop: list.scrollTop, page: state.page })); + localStorage.setItem(lastFolderStorageKey(), state.folder); + } + + function scheduleMessageListStateSave() { + clearTimeout(state.listStateTimer); + state.listStateTimer = setTimeout(saveMessageListState, 120); + } + + async function request(path, options = {}) { + const headers = new Headers(options.headers || {}); + if (state.accountId) headers.set("X-Active-Account", state.accountId); + if (options.body && !(options.body instanceof FormData)) headers.set("Content-Type", "application/json"); + if (options.method && options.method !== "GET") headers.set("X-Requested-With", "XMLHttpRequest"); + + const method = String(options.method || "GET").toUpperCase(); + const maxAttempts = method === "GET" || method === "HEAD" ? 3 : 1; + let lastNetworkError = null; + for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + try { + const response = await fetch(apiPath(path), { + credentials: "include", + ...options, + headers, + }); + if ([502, 503, 504].includes(response.status) && attempt + 1 < maxAttempts) { + await sleep(250 * (attempt + 1)); + continue; + } + const contentType = response.headers.get("content-type") || ""; + const payload = contentType.includes("application/json") + ? await response.json().catch(() => ({})) + : await response.text().catch(() => ""); + if (!response.ok) { + const message = payload?.error?.message || (typeof payload === "string" && payload) || `Request failed (${response.status})`; + const error = new Error(message); + error.status = response.status; + throw error; + } + setConnectivityState(true); + return payload; + } catch (error) { + if (error?.status) throw error; + lastNetworkError = error; + setConnectivityState(false); + if (attempt + 1 < maxAttempts) { + await sleep(350 * (attempt + 1)); + continue; + } + } + } + const error = new Error(navigator.onLine ? "RustBox server is temporarily unreachable" : "You are offline"); + error.cause = lastNetworkError; + error.network = true; + throw error; + } + + function uploadRequest(path, formData, onProgress = null) { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open("POST", apiPath(path), true); + xhr.withCredentials = true; + if (state.accountId) xhr.setRequestHeader("X-Active-Account", state.accountId); + xhr.setRequestHeader("X-Requested-With", "XMLHttpRequest"); + if (typeof onProgress === "function") { + xhr.upload.addEventListener("progress", (event) => { + if (event.lengthComputable && event.total > 0) onProgress(Math.round((event.loaded / event.total) * 100)); + }); + } + xhr.addEventListener("error", () => reject(new Error("Upload failed: network error"))); + xhr.addEventListener("abort", () => reject(new Error("Upload cancelled"))); + xhr.addEventListener("load", () => { + const contentType = xhr.getResponseHeader("content-type") || ""; + let payload = xhr.responseText || ""; + if (contentType.includes("application/json")) { + try { payload = payload ? JSON.parse(payload) : {}; } catch (_) { payload = {}; } + } + if (xhr.status < 200 || xhr.status >= 300) { + const message = payload?.error?.message || (typeof payload === "string" && payload) || `Upload failed (${xhr.status})`; + const error = new Error(message); + error.status = xhr.status; + reject(error); + return; + } + resolve(payload); + }); + xhr.send(formData); + }); + } + + function toast(message, kind = "info", duration = null, action = null) { + const normalized = ["success", "error", "warning", "info"].includes(kind) ? kind : "info"; + const lifetime = Number(duration) > 0 ? Number(duration) : normalized === "error" ? 6500 : normalized === "warning" ? 5200 : 3800; + const node = el("div", `toast toast-${normalized}`); + node.setAttribute("role", normalized === "error" ? "alert" : "status"); + const iconName = normalized === "success" ? "check-circle" : normalized === "error" || normalized === "warning" ? "circle-alert" : "info"; + const icon = el("span", "toast-icon"); + icon.innerHTML = svgIcon(iconName); + const text = el("span", "toast-message", message); + let actionButton = null; + if (action?.label && typeof action.onClick === "function") { + actionButton = el("button", "toast-action", action.label); + actionButton.type = "button"; + } + const countdown = el("span", "toast-countdown"); + countdown.setAttribute("aria-hidden", "true"); + const close = el("button", "toast-close"); + close.type = "button"; + close.setAttribute("aria-label", "Dismiss notification"); + close.innerHTML = svgIcon("x"); + const controls = el("span", "toast-controls"); + if (actionButton) controls.append(actionButton); + controls.append(close); + node.append(icon, text, countdown, controls); + const openDialogs = $$("dialog[open]"); + let stack = $("#toast-stack"); + if (openDialogs.length) { + const dialog = openDialogs[openDialogs.length - 1]; + stack = dialog.querySelector(":scope > .modal-toast-stack"); + if (!stack) { + stack = el("div", "toast-stack modal-toast-stack"); + stack.setAttribute("aria-live", "polite"); + dialog.append(stack); + } + } + stack.append(node); + requestAnimationFrame(() => node.classList.add("visible")); + + let remaining = lifetime; + let startedAt = 0; + let timeout = null; + let ticker = null; + let dismissed = false; + const renderCountdown = () => { countdown.textContent = `${Math.max(1, Math.ceil(remaining / 1000))}s`; }; + const stopTimers = () => { clearTimeout(timeout); clearInterval(ticker); timeout = null; ticker = null; }; + const dismiss = (immediate = false) => { + if (dismissed) return; + dismissed = true; + stopTimers(); + if (immediate) return node.remove(); + node.classList.remove("visible"); + setTimeout(() => node.remove(), 160); + }; + const pause = () => { + if (dismissed || !startedAt) return; + remaining = Math.max(0, remaining - (performance.now() - startedAt)); + startedAt = 0; + stopTimers(); + renderCountdown(); + }; + const resume = () => { + if (dismissed || remaining <= 0) return dismiss(); + stopTimers(); + startedAt = performance.now(); + renderCountdown(); + timeout = setTimeout(() => dismiss(), remaining); + ticker = setInterval(() => { + if (!startedAt) return; + const elapsed = performance.now() - startedAt; + const visibleRemaining = Math.max(0, remaining - elapsed); + countdown.textContent = `${Math.max(1, Math.ceil(visibleRemaining / 1000))}s`; + }, 200); + }; + close.addEventListener("click", (event) => { + event.preventDefault(); + event.stopPropagation(); + dismiss(true); + }); + if (actionButton) { + actionButton.addEventListener("click", async (event) => { + event.preventDefault(); + event.stopPropagation(); + dismiss(true); + try { await action.onClick(); } + catch (error) { toast(error?.message || "Action failed", "error"); } + }); + } + node.addEventListener("mouseenter", pause); + node.addEventListener("mouseleave", resume); + node.addEventListener("focusin", pause); + node.addEventListener("focusout", (event) => { if (!node.contains(event.relatedTarget)) resume(); }); + resume(); + } + + function appDialog({ title = "RustBox", message = "", input = null, selectOptions = null, confirmLabel = "OK", cancelLabel = "Cancel", danger = false } = {}) { + return new Promise((resolve) => { + const dialog = el("dialog", "dialog app-prompt-dialog"); + const form = el("form", "dialog-card app-prompt-card"); + form.method = "dialog"; + const header = el("header", "dialog-header"); + header.append(el("strong", "", title)); + const close = el("button", "icon-button"); + close.type = "button"; + close.setAttribute("aria-label", cancelLabel); + close.innerHTML = svgIcon("x"); + header.append(close); + const body = el("div", "app-prompt-body"); + if (message) body.append(el("p", "app-prompt-message", message)); + let control = null; + if (Array.isArray(selectOptions)) { + control = document.createElement("select"); + control.className = "app-prompt-control"; + for (const option of selectOptions) control.add(new Option(option.label ?? option.value, option.value)); + if (input?.value !== undefined) control.value = String(input.value); + body.append(control); + } else if (input) { + control = input.multiline ? document.createElement("textarea") : document.createElement("input"); + control.className = "app-prompt-control"; + if (!input.multiline) control.type = input.type || "text"; + control.value = input.value ?? ""; + if (input.placeholder) control.placeholder = input.placeholder; + if (input.required) control.required = true; + body.append(control); + } + const actions = el("footer", "dialog-actions app-prompt-actions"); + const cancel = el("button", "button quiet", cancelLabel); + cancel.type = "button"; + const confirm = el("button", danger ? "button danger" : "button primary", confirmLabel); + confirm.type = "submit"; + actions.append(cancel, confirm); + form.append(header, body, actions); + dialog.append(form); + document.body.append(dialog); + + let settled = false; + const finish = (value) => { + if (settled) return; + settled = true; + resolve(value); + if (dialog.open) dialog.close(); + else dialog.remove(); + }; + close.addEventListener("click", () => finish(null)); + cancel.addEventListener("click", () => finish(null)); + dialog.addEventListener("cancel", (event) => { event.preventDefault(); finish(null); }); + dialog.addEventListener("close", () => { if (!settled) { settled = true; resolve(null); } dialog.remove(); }, { once: true }); + form.addEventListener("submit", (event) => { + event.preventDefault(); + if (control && !control.reportValidity()) return; + finish(control ? control.value : true); + }); + dialog.showModal(); + setTimeout(() => (control || confirm).focus(), 20); + }); + } + + async function appConfirm(message, { title = "Confirm action", confirmLabel = "Continue", danger = false } = {}) { + return (await appDialog({ title, message, confirmLabel, danger })) === true; + } + + function appPrompt(title, { message = "", value = "", placeholder = "", type = "text", confirmLabel = "OK", required = false } = {}) { + return appDialog({ title, message, input: { value, placeholder, type, required }, confirmLabel }); + } + + function appSelect(title, options, { message = "", value = "", confirmLabel = "Choose" } = {}) { + return appDialog({ title, message, input: { value }, selectOptions: options, confirmLabel }); + } + + function setBusy(button, busy) { + if (!button) return; + button.disabled = busy; + button.setAttribute("aria-busy", String(busy)); + } + + function resolveTheme(theme) { + if (theme !== "system") return theme; + return matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark"; + } + + const preferenceMap = { + theme: "theme", density: "density", preview: "preview_pane", scale: "ui_scale", language: "language", + colorPreset: "color_preset", accentColor: "accent_color", backgroundColor: "background_color", panelColor: "panel_color", textColor: "text_color", fontFamily: "font_family", fontSize: "font_size", + rowHeight: "row_height", folderPaneWidth: "folder_pane_width", messagePaneWidth: "message_pane_width", + borderRadius: "border_radius", contrast: "contrast", showAvatars: "show_avatars", + showAttachmentIcons: "show_attachment_icons", showFlags: "show_flags", reduceMotion: "reduce_motion", composeFormat: "compose_format", + composeAutosaveMs: "compose_autosave_ms", defaultPriority: "default_priority", requestMdnDefault: "request_mdn_default", attachmentReminder: "attachment_reminder", composeFontFamily: "compose_font_family", replyPosition: "reply_position", quoteStyle: "quote_style", forwardMode: "forward_mode", spellcheckEnabled: "spellcheck_enabled", + dateFormat: "date_format", timeFormat: "time_format", iconSize: "icon_size", toolbarDensity: "toolbar_density", sidebarItemHeight: "sidebar_item_height", + readerMaxWidth: "reader_max_width", composerWidth: "composer_width", selectionColor: "selection_color", unreadFontWeight: "unread_font_weight", unreadBadgeStyle: "unread_badge_style", + compactHeaders: "compact_headers", showBorders: "show_borders", scrollbarDensity: "scrollbar_density", monochrome: "monochrome", folderBackground: "folder_background", messageBackground: "message_background", readerBackground: "reader_background", + }; + const preferenceTimers = new Map(); + + async function loadTranslations(language) { + const locale = ["en", "pl"].includes(language) ? language : "en"; + try { + const response = await fetch(`${BASE}/assets/i18n/${locale}.json`, { credentials: "same-origin" }); + state.translations = response.ok ? await response.json() : {}; + } catch (_) { state.translations = {}; } + root.lang = locale; + $$('[data-i18n]').forEach((node) => { const key = node.dataset.i18n; if (state.translations[key]) node.textContent = state.translations[key]; }); + $$('[data-i18n-placeholder]').forEach((node) => { const key = node.dataset.i18nPlaceholder; if (state.translations[key]) node.placeholder = state.translations[key]; }); + $$('[data-i18n-title]').forEach((node) => { const key = node.dataset.i18nTitle; if (state.translations[key]) node.title = state.translations[key]; }); + $$('[data-i18n-aria-label]').forEach((node) => { const key = node.dataset.i18nAriaLabel; if (state.translations[key]) node.setAttribute('aria-label', state.translations[key]); }); + } + + function tr(key, fallback = key) { + return state.translations[key] || fallback; + } + + function applyPreferences() { + const p = state.prefs; + root.dataset.theme = resolveTheme(p.theme); + root.dataset.density = p.density; + root.dataset.preview = p.preview; + root.dataset.scale = p.scale; + root.dataset.colorPreset = p.colorPreset; + root.dataset.fontFamily = p.fontFamily; + root.dataset.contrast = p.contrast; + root.dataset.hideAvatars = String(!p.showAvatars); + root.dataset.hideAttachments = String(!p.showAttachmentIcons); + root.dataset.hideFlags = String(!p.showFlags); + root.dataset.reduceMotion = String(p.reduceMotion); + root.dataset.toolbarDensity = p.toolbarDensity; + root.dataset.unreadBadge = p.unreadBadgeStyle; + root.dataset.compactHeaders = String(p.compactHeaders); + root.dataset.showBorders = String(p.showBorders); + root.dataset.scrollbarDensity = p.scrollbarDensity; + root.dataset.monochrome = String(p.monochrome); + root.style.setProperty("--user-accent", p.accentColor); + root.style.setProperty("--user-bg", p.backgroundColor); + root.style.setProperty("--user-panel", p.panelColor); + root.style.setProperty("--user-text", p.textColor); + const scaleFactor = ({ small: 0.90, normal: 1, large: 1.12 })[p.scale] || 1; + const densityFactor = p.density === "comfortable" ? 1.13 : 0.93; + root.style.setProperty("--ui-scale-factor", String(scaleFactor)); + root.style.setProperty("--density-factor", String(densityFactor)); + root.style.setProperty("--ui-size", `${Math.round(Number(p.fontSize || 13) * scaleFactor * 100) / 100}px`); + root.style.setProperty("--row-height", `${Math.max(42, Math.round(Number(p.rowHeight || 58) * scaleFactor))}px`); + root.style.setProperty("--folder-pane-width", `${Math.max(170, Math.round(Number(p.folderPaneWidth || 228) * scaleFactor))}px`); + root.style.setProperty("--message-pane-width", `${Math.max(300, Math.round(Number(p.messagePaneWidth || 420) * scaleFactor))}px`); + root.style.setProperty("--control-height", `${Math.max(26, Math.round(30 * scaleFactor * densityFactor))}px`); + root.style.setProperty("--appbar-height", `${Math.max(38, Math.round(44 * scaleFactor * densityFactor))}px`); + root.style.setProperty("--dialog-header-height", `${Math.max(38, Math.round(43 * scaleFactor * densityFactor))}px`); + root.style.setProperty("--icon-size", `${Math.max(14, Math.round(Number(p.iconSize || 18) * scaleFactor))}px`); + const toolbarFactor = ({ compact: 0.88, normal: 1, spacious: 1.14 })[p.toolbarDensity] || 1; + root.style.setProperty("--toolbar-density-factor", String(toolbarFactor)); + root.style.setProperty("--sidebar-item-height", `${Math.max(24, Math.round(Number(p.sidebarItemHeight || 29) * scaleFactor))}px`); + root.style.setProperty("--reader-max-width", Number(p.readerMaxWidth) > 0 ? `${Number(p.readerMaxWidth)}px` : "none"); + root.style.setProperty("--composer-width", `${Math.max(640, Number(p.composerWidth || 900))}px`); + root.style.setProperty("--compose-font-family", composeFontCss(p.composeFontFamily)); + root.style.setProperty("--selected", p.selectionColor || "#28352e"); + root.style.setProperty("--unread-font-weight", String(Number(p.unreadFontWeight || 730))); + root.style.setProperty("--folder-bg", p.folderBackground || "var(--sidebar)"); + root.style.setProperty("--message-bg", p.messageBackground || "var(--panel)"); + root.style.setProperty("--reader-bg", p.readerBackground || "var(--panel)"); + root.style.setProperty("--button-pad-x", `${Math.max(8, Math.round(10 * scaleFactor))}px`); + root.style.setProperty("--button-pad-y", `${Math.max(3, Math.round(5 * scaleFactor * densityFactor))}px`); + root.style.setProperty("--field-pad-x", `${Math.max(7, Math.round(8 * scaleFactor))}px`); + root.style.setProperty("--field-pad-y", `${Math.max(3, Math.round(5 * scaleFactor * densityFactor))}px`); + root.style.setProperty("--radius-control", `${p.borderRadius}px`); + root.style.setProperty("--radius-panel", `${Math.min(p.borderRadius + 1, 13)}px`); + + const values = { + "#pref-theme": p.theme, "#pref-density": p.density, "#pref-preview": p.preview, "#pref-scale": p.scale, + "#pref-language": p.language, "#pref-color-preset": p.colorPreset, "#pref-accent": p.accentColor, + "#pref-background": p.backgroundColor, "#pref-panel": p.panelColor, "#pref-text-color": p.textColor, + "#pref-font-family": p.fontFamily, "#pref-font-size": p.fontSize, "#pref-row-height": p.rowHeight, + "#pref-folder-width": p.folderPaneWidth, "#pref-message-width": p.messagePaneWidth, "#pref-radius": p.borderRadius, + "#pref-contrast": p.contrast, "#pref-date-format": p.dateFormat, "#pref-time-format": p.timeFormat, + "#compose-pref-format": p.composeFormat, "#compose-pref-autosave": String(p.composeAutosaveMs), "#compose-pref-priority": p.defaultPriority, "#compose-pref-font": p.composeFontFamily, "#compose-pref-reply-position": p.replyPosition, "#compose-pref-quote-style": p.quoteStyle, "#compose-pref-forward-mode": p.forwardMode, + "#pref-icon-size": p.iconSize, "#pref-toolbar-density": p.toolbarDensity, "#pref-sidebar-height": p.sidebarItemHeight, "#pref-reader-width": String(p.readerMaxWidth), "#pref-composer-width": p.composerWidth, + "#pref-selection-color": p.selectionColor, "#pref-unread-weight": p.unreadFontWeight, "#pref-unread-badge": p.unreadBadgeStyle, "#pref-scrollbar-density": p.scrollbarDensity, + }; + Object.entries(values).forEach(([selector, value]) => { const node = $(selector); if (node) node.value = value; }); + const checks = { "#pref-avatars": p.showAvatars, "#pref-attachment-icons": p.showAttachmentIcons, "#pref-flags": p.showFlags, "#pref-reduce-motion": p.reduceMotion, "#compose-pref-mdn": p.requestMdnDefault, "#compose-pref-attachment-reminder": p.attachmentReminder, "#compose-pref-spellcheck": p.spellcheckEnabled, "#pref-compact-headers": p.compactHeaders, "#pref-show-borders": p.showBorders, "#pref-monochrome": p.monochrome, "#pref-folder-bg-enabled": Boolean(p.folderBackground), "#pref-message-bg-enabled": Boolean(p.messageBackground), "#pref-reader-bg-enabled": Boolean(p.readerBackground) }; + Object.entries(checks).forEach(([selector, value]) => { const node = $(selector); if (node) node.checked = Boolean(value); }); + const outputs = { "#pref-accent-value": p.accentColor, "#pref-background-value": p.backgroundColor, "#pref-panel-value": p.panelColor, "#pref-text-color-value": p.textColor, "#pref-font-size-value": `${p.fontSize} px`, "#pref-row-height-value": `${p.rowHeight} px`, "#pref-folder-width-value": `${p.folderPaneWidth} px`, "#pref-message-width-value": `${p.messagePaneWidth} px`, "#pref-radius-value": `${p.borderRadius} px`, "#pref-icon-size-value": `${p.iconSize} px`, "#pref-sidebar-height-value": `${p.sidebarItemHeight} px`, "#pref-composer-width-value": `${p.composerWidth} px`, "#pref-selection-color-value": p.selectionColor, "#pref-unread-weight-value": String(p.unreadFontWeight) }; + Object.entries(outputs).forEach(([selector, value]) => { const node = $(selector); if (node) node.textContent = value; }); + [["#pref-folder-bg", p.folderBackground || "#101310"], ["#pref-message-bg", p.messageBackground || "#181c19"], ["#pref-reader-bg", p.readerBackground || "#181c19"]].forEach(([selector, value]) => { const node = $(selector); if (node) node.value = value; }); + } + + function normalizeServerPreferences(data) { + if (!data) return; + state.prefs = { + ...state.prefs, + theme: data.theme || "system", density: data.density || "compact", preview: data.preview_pane || "right", composeFormat: data.compose_format || "html", + composeAutosaveMs: Number(data.compose_autosave_ms || 3000), defaultPriority: data.default_priority || "normal", requestMdnDefault: Boolean(data.request_mdn_default), attachmentReminder: data.attachment_reminder !== false, composeFontFamily: data.compose_font_family || "system", replyPosition: data.reply_position || "above", quoteStyle: data.quote_style || "standard", forwardMode: data.forward_mode || "inline", spellcheckEnabled: data.spellcheck_enabled !== false, + scale: data.ui_scale || "normal", language: data.language || "en", colorPreset: data.color_preset || "sage", + accentColor: data.accent_color || "#6f9c86", backgroundColor: data.background_color || "#121513", panelColor: data.panel_color || "#181c19", textColor: data.text_color || "#edf0ec", fontFamily: data.font_family || "system", fontSize: data.font_size || 13, + rowHeight: data.row_height || 58, folderPaneWidth: data.folder_pane_width || 228, messagePaneWidth: data.message_pane_width || 420, + borderRadius: data.border_radius ?? 4, contrast: data.contrast || "normal", showAvatars: data.show_avatars !== false, + showAttachmentIcons: data.show_attachment_icons !== false, showFlags: data.show_flags !== false, reduceMotion: Boolean(data.reduce_motion), dateFormat: data.date_format || "locale", timeFormat: data.time_format || "locale", + iconSize: Number(data.icon_size || 18), toolbarDensity: data.toolbar_density || "normal", sidebarItemHeight: Number(data.sidebar_item_height || 29), readerMaxWidth: Number(data.reader_max_width || 0), composerWidth: Number(data.composer_width || 900), selectionColor: data.selection_color || "#28352e", unreadFontWeight: Number(data.unread_font_weight || 730), unreadBadgeStyle: data.unread_badge_style || "dot", compactHeaders: Boolean(data.compact_headers), showBorders: data.show_borders !== false, scrollbarDensity: data.scrollbar_density || "thin", monochrome: Boolean(data.monochrome), folderBackground: data.folder_background || "", messageBackground: data.message_background || "", readerBackground: data.reader_background || "", + }; + } + + async function loadPreferences() { + try { normalizeServerPreferences(await request("/settings/display", { method: "GET" })); } + catch (error) { console.warn("Could not load display preferences", error); } + applyPreferences(); + await loadTranslations(state.prefs.language); + } + + async function loadMailPreferences() { + try { + const prefs = await request("/settings/mailbox", { method: "GET" }); + state.mailPrefs = { ...state.mailPrefs, ...prefs }; + state.perPage = Number(state.mailPrefs.messages_per_page || 50); + const values = { + "#mail-pref-per-page": String(state.perPage), + "#mail-pref-read-delay": String(state.mailPrefs.mark_read_delay_ms || 0), + "#mail-pref-remote-images": state.mailPrefs.remote_images || "ask", + "#mail-pref-preferred-body": state.mailPrefs.preferred_body || "auto", + "#mail-pref-thread-mode": state.mailPrefs.thread_mode || "threaded", + "#mail-pref-sort": state.mailPrefs.default_sort || "date_desc", + }; + Object.entries(values).forEach(([selector, value]) => { const node = $(selector); if (node) node.value = value; }); + const messageSort = $("#message-sort"); + if (messageSort) messageSort.value = state.mailPrefs.default_sort || "date_desc"; + const collapse = $("#mail-pref-collapse-quotes"); + if (collapse) collapse.checked = state.mailPrefs.collapse_quotes !== false; + const snippets = $("#mail-pref-show-snippets"); + if (snippets) snippets.checked = state.mailPrefs.show_snippets !== false; + const flaggedTop = $("#mail-pref-flagged-top"); + if (flaggedTop) flaggedTop.checked = Boolean(state.mailPrefs.flagged_on_top); + const syncKeywords = $("#mail-pref-sync-keywords"); + if (syncKeywords) syncKeywords.checked = Boolean(state.mailPrefs.sync_imap_keywords); + } catch (error) { console.warn("Could not load mailbox preferences", error); } + } + + async function saveMailPreferences() { + const payload = { + messages_per_page: Number($("#mail-pref-per-page").value), + mark_read_delay_ms: Number($("#mail-pref-read-delay").value), + remote_images: $("#mail-pref-remote-images").value, + preferred_body: $("#mail-pref-preferred-body").value, + thread_mode: $("#mail-pref-thread-mode").value, + collapse_quotes: $("#mail-pref-collapse-quotes").checked, + show_snippets: $("#mail-pref-show-snippets").checked, + default_sort: $("#mail-pref-sort").value, + flagged_on_top: $("#mail-pref-flagged-top").checked, + sync_imap_keywords: $("#mail-pref-sync-keywords").checked, + }; + try { + state.mailPrefs = await request("/settings/mailbox", { method: "PUT", body: JSON.stringify(payload) }); + state.perPage = Number(state.mailPrefs.messages_per_page || 50); + toast("Mailbox settings saved"); + if (state.currentDetail) renderMessageDetail(state.currentDetail); + await loadMessages(true); + } catch (error) { toast(error.message, "error"); } + } + + async function reconcileImapLabels() { + const button = $("#mail-pref-reconcile-labels"); + const status = $("#mail-pref-reconcile-status"); + if (!$("#mail-pref-sync-keywords")?.checked) { + toast("Enable IMAP keyword synchronization first", "warning"); + return; + } + if (button) button.disabled = true; + if (status) status.textContent = "Reconciling…"; + try { + const result = await request("/tags/reconcile", { method: "POST", body: "{}" }); + const added = Number(result.labels_added || 0); + const removed = Number(result.labels_removed || 0); + const folders = Number(result.folders_checked || 0); + const skipped = Number(result.skipped_folders || 0); + if (status) status.textContent = `${folders} folder(s) checked · +${added} / −${removed}${skipped ? ` · ${skipped} skipped` : ""}`; + toast(`Labels reconciled: +${added}, −${removed}`, "success"); + await loadTags(); + await loadMessages(true); + } catch (error) { + if (status) status.textContent = error.message; + toast(error.message, "error"); + } finally { + if (button) button.disabled = false; + } + } + + function savePreference(key, value) { + state.prefs[key] = value; + if (["accentColor", "backgroundColor", "panelColor", "textColor"].includes(key)) state.prefs.colorPreset = "custom"; + applyPreferences(); + if (key === "language") loadTranslations(value); + const previousTimer = preferenceTimers.get(key); + if (previousTimer) clearTimeout(previousTimer); + const timer = setTimeout(async () => { + preferenceTimers.delete(key); + const field = preferenceMap[key]; + if (!field) return; + const payload = { [field]: state.prefs[key] }; + if (["accentColor", "backgroundColor", "panelColor", "textColor"].includes(key)) payload.color_preset = "custom"; + try { await request("/settings/display", { method: "PUT", body: JSON.stringify(payload) }); } + catch (error) { toast(error.message, "error"); } + }, 180); + preferenceTimers.set(key, timer); + } + + async function resetAppearance() { + const payload = { theme: "system", density: "compact", preview_pane: "right", ui_scale: "normal", color_preset: "sage", accent_color: "#6f9c86", background_color: "#121513", panel_color: "#181c19", text_color: "#edf0ec", font_family: "system", font_size: 13, row_height: 58, folder_pane_width: 228, message_pane_width: 420, border_radius: 4, contrast: "normal", show_avatars: true, show_attachment_icons: true, show_flags: true, reduce_motion: false, date_format: "locale", time_format: "locale", icon_size: 18, toolbar_density: "normal", sidebar_item_height: 29, reader_max_width: 0, composer_width: 900, selection_color: "#28352e", unread_font_weight: 730, unread_badge_style: "dot", compact_headers: false, show_borders: true, scrollbar_density: "thin", monochrome: false, folder_background: "", message_background: "", reader_background: "" }; + try { normalizeServerPreferences(await request("/settings/display", { method: "PUT", body: JSON.stringify(payload) })); applyPreferences(); toast("Appearance reset"); } + catch (error) { toast(error.message, "error"); } + } + + const appearancePresets = { + compact: { density: "compact", scale: "small", fontSize: 12, rowHeight: 46, folderPaneWidth: 196, messagePaneWidth: 350, borderRadius: 2, preview: "right" }, + balanced: { density: "compact", scale: "normal", fontSize: 13, rowHeight: 56, folderPaneWidth: 228, messagePaneWidth: 420, borderRadius: 4, preview: "right" }, + comfortable: { density: "comfortable", scale: "normal", fontSize: 14, rowHeight: 66, folderPaneWidth: 244, messagePaneWidth: 460, borderRadius: 6, preview: "right" }, + reading: { density: "comfortable", scale: "large", fontSize: 15, rowHeight: 72, folderPaneWidth: 236, messagePaneWidth: 520, borderRadius: 6, preview: "bottom" }, + }; + + async function applyAppearancePreset(name) { + const preset = appearancePresets[name]; + if (!preset) return; + state.prefs = { ...state.prefs, ...preset }; + applyPreferences(); + const payload = {}; + for (const [key, value] of Object.entries(preset)) { + const field = preferenceMap[key]; + if (field) payload[field] = value; + } + try { + normalizeServerPreferences(await request("/settings/display", { method: "PUT", body: JSON.stringify(payload) })); + applyPreferences(); + const label = $(`[data-appearance-preset="${name}"]`)?.dataset.presetLabel || name; + toast(`Applied interface preset: ${label}`, "success"); + } catch (error) { toast(error.message, "error"); } + } + + function formatDate(raw, long = false) { + const date = new Date(raw); + if (Number.isNaN(date.getTime())) return raw || ""; + const timeOptions = state.prefs.timeFormat === "24h" ? { hour: "2-digit", minute: "2-digit", hour12: false } : state.prefs.timeFormat === "12h" ? { hour: "numeric", minute: "2-digit", hour12: true } : { hour: "2-digit", minute: "2-digit" }; + if (long) { + if (state.prefs.dateFormat === "iso") return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")} ${date.toLocaleTimeString([], timeOptions)}`; + const locale = state.prefs.dateFormat === "eu" ? "en-GB" : state.prefs.dateFormat === "us" ? "en-US" : undefined; + return `${date.toLocaleDateString(locale)} ${date.toLocaleTimeString([], timeOptions)}`; + } + const now = new Date(); + if (date.toDateString() === now.toDateString()) return date.toLocaleTimeString([], timeOptions); + if (state.prefs.dateFormat === "iso") return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`; + if (state.prefs.dateFormat === "eu") return date.toLocaleDateString("en-GB"); + if (state.prefs.dateFormat === "us") return date.toLocaleDateString("en-US"); + if (date.getFullYear() === now.getFullYear()) return date.toLocaleDateString([], { month: "short", day: "numeric" }); + return date.toLocaleDateString([], { year: "numeric", month: "short", day: "numeric" }); + } + + function formatBytes(value) { + const bytes = Number(value || 0); + if (!Number.isFinite(bytes) || bytes < 1) return "0 B"; + const units = ["B", "KB", "MB", "GB", "TB"]; + const index = Math.min(units.length - 1, Math.floor(Math.log(bytes) / Math.log(1024))); + return `${(bytes / (1024 ** index)).toFixed(index ? 1 : 0)} ${units[index]}`; + } + + function initial(value) { + return (value || "?").trim().charAt(0).toUpperCase() || "?"; + } + + function showLogin() { + $("#boot").classList.add("hidden"); + $("#app-view").classList.add("hidden"); + $("#login-view").classList.remove("hidden"); + } + + function showApp() { + $("#boot").classList.add("hidden"); + $("#login-view").classList.add("hidden"); + $("#app-view").classList.remove("hidden"); + } + + async function loadAuthPolicy() { + try { + state.authPolicy = await request("/auth/policy", { method: "GET" }); + } catch (_) { + state.authPolicy = { rememberMeEnabled: true, rememberMeDays: 30, sessionTimeoutHours: 24 }; + } + const enabled = state.authPolicy.rememberMeEnabled !== false; + ["#login-remember-row", "#add-account-remember-row"].forEach((selector) => $(selector)?.classList.toggle("hidden", !enabled)); + ["#login-remember", "#add-account-remember"].forEach((selector) => { const input = $(selector); if (input && !enabled) { input.checked = false; input.disabled = true; } }); + const days = Number(state.authPolicy.rememberMeDays || 30); + ["#login-remember-label", "#add-account-remember-label"].forEach((selector) => { const label = $(selector); if (label) label.textContent = `Keep me signed in (${days} days)`; }); + } + + async function loadAccounts() { + try { + const data = await request("/auth/accounts", { method: "GET" }); + state.accounts = data.accounts || []; + if (!state.accounts.length) { + state.accountId = null; + localStorage.removeItem("rustbox.activeAccount"); + state.folders = []; + updateUnreadAppBadge(); + showLogin(); + return false; + } + if (!state.accountId || !state.accounts.some((account) => account.id === state.accountId)) { + state.accountId = state.accounts[0].id; + localStorage.setItem("rustbox.activeAccount", state.accountId); + } + const account = state.accounts.find((item) => item.id === state.accountId) || state.accounts[0]; + $("#account-email").textContent = account.email; + showApp(); + return true; + } catch (error) { + console.error(error); + showLogin(); + return false; + } + } + + function renderAccountSettings() { + const list = $("#account-settings-list"); + if (!list) return; + list.replaceChildren(); + if (!state.accounts.length) { + list.append(el("div", "empty-list", "No active accounts.")); + return; + } + for (const account of state.accounts) { + const row = el("div", `account-settings-row ${account.id === state.accountId ? "active" : ""}`); + const email = el("span", "account-email", account.email || "Account"); + const use = el("button", "button quiet", account.id === state.accountId ? "Current" : "Switch"); + use.type = "button"; + use.disabled = account.id === state.accountId; + use.addEventListener("click", () => switchAccount(account.id)); + const remove = el("button", "button quiet danger", "Remove"); + remove.type = "button"; + remove.addEventListener("click", () => removeBrowserAccount(account.id)); + row.append(email, use, remove); + list.append(row); + } + } + + async function loadActiveSessions() { + const list = $("#account-session-list"); + if (!list) return; + list.replaceChildren(el("div", "empty-list", "Loading sessions…")); + try { + const data = await request("/auth/sessions", { method: "GET" }); + const sessions = data.sessions || []; + list.replaceChildren(); + if (!sessions.length) { + list.append(el("div", "empty-list", "No active sessions.")); + return; + } + for (const session of sessions) { + const row = el("div", `account-settings-row ${session.current ? "active" : ""}`); + const details = el("div", "account-session-details"); + const expiry = Number(session.expiresAt || 0); + const expiryText = expiry ? ` · expires ${new Date(expiry * 1000).toLocaleString()}` : ""; + const rememberedText = session.remembered ? " · remembered" : ""; + details.append( + el("strong", "account-email", session.current ? `${session.email} · current` : session.email), + el("span", "muted", `Created ${new Date(session.createdAt * 1000).toLocaleString()} · active ${new Date(session.lastActive * 1000).toLocaleString()}${expiryText}${rememberedText}`), + ); + const client = el("span", "muted", session.clientName || "Unknown device"); + if (session.userAgent) client.title = session.userAgent; + details.append(client); + const action = el("button", "button quiet danger", session.current ? "Current" : "Revoke"); + action.type = "button"; + action.disabled = Boolean(session.current); + action.addEventListener("click", () => revokeActiveSession(session.id)); + row.append(details, action); + list.append(row); + } + } catch (error) { + list.replaceChildren(el("div", "empty-list", error.message)); + } + } + + async function revokeActiveSession(id) { + if (!await appConfirm("Revoke this RustBox session?", { title: "Revoke session", confirmLabel: "Revoke", danger: true })) return; + try { + await request(`/auth/sessions/${encodeURIComponent(id)}`, { method: "DELETE" }); + toast("Session revoked"); + await loadActiveSessions(); + } catch (error) { toast(error.message, "error"); } + } + + async function revokeOtherSessions() { + if (!await appConfirm("Revoke every other active RustBox session for this account?", { title: "Revoke other sessions", confirmLabel: "Revoke sessions", danger: true })) return; + try { + const result = await request("/auth/sessions/revoke-others", { method: "POST", body: "{}" }); + toast(`Revoked ${result.count || 0} session(s)`); + await loadActiveSessions(); + } catch (error) { toast(error.message, "error"); } + } + + async function reloadActiveAccount() { + state.folder = "INBOX"; + state.selected = null; + state.currentDetail = null; + state.selectedMessages.clear(); + state.selectionMode = false; + closeReader(); + await loadPreferences(); + await loadMailPreferences(); + await loadOpenPgp(); + await loadIdentities(); + await loadFolders(true); + await loadSpecialFolders(); + await loadTags(); + await loadNotificationPreferences(); + renderAccountSettings(); + } + + async function switchAccount(accountId) { + if (!state.accounts.some((account) => account.id === accountId) || accountId === state.accountId) return; + state.accountId = accountId; + localStorage.setItem("rustbox.activeAccount", accountId); + const account = state.accounts.find((item) => item.id === accountId); + if (account) $("#account-email").textContent = account.email; + try { await reloadActiveAccount(); } catch (error) { toast(error.message, "error"); } + } + + async function removeBrowserAccount(accountId) { + const account = state.accounts.find((item) => item.id === accountId); + if (!account || !await appConfirm(`Remove ${account.email} from this browser session?`, { title: "Remove account", confirmLabel: "Remove", danger: true })) return; + try { + await request(`/auth/accounts/${encodeURIComponent(accountId)}`, { method: "DELETE" }); + const removingCurrent = accountId === state.accountId; + await loadAccounts(); + if (!state.accounts.length) return; + if (removingCurrent) await reloadActiveAccount(); + else renderAccountSettings(); + } catch (error) { toast(error.message, "error"); } + } + + async function addBrowserAccount(event) { + event.preventDefault(); + const errorBox = $("#add-account-error"); + const submit = $("#add-account-form button[type=submit]"); + errorBox.classList.add("hidden"); + setBusy(submit, true); + try { + const data = await request("/auth/login", { method: "POST", body: JSON.stringify({ email: $("#add-account-email").value.trim(), password: $("#add-account-password").value, remember: $("#add-account-remember").checked }) }); + state.accountId = data.account.id; + localStorage.setItem("rustbox.activeAccount", state.accountId); + $("#add-account-dialog").close(); + $("#add-account-form").reset(); + await loadAccounts(); + await reloadActiveAccount(); + disconnectRealtime(); + connectRealtime(); + toast("Account added"); + } catch (error) { + errorBox.textContent = error.message; + errorBox.classList.remove("hidden"); + } finally { setBusy(submit, false); } + } + + async function login(event) { + event.preventDefault(); + const submit = $("#login-form button[type=submit]"); + const errorBox = $("#login-error"); + errorBox.classList.add("hidden"); + setBusy(submit, true); + try { + const data = await request("/auth/login", { + method: "POST", + body: JSON.stringify({ + email: $("#login-email").value.trim(), + password: $("#login-password").value, + remember: $("#login-remember").checked, + }), + }); + state.accountId = data.account.id; + localStorage.setItem("rustbox.activeAccount", state.accountId); + await loadAccounts(); + await loadPreferences(); + await loadMailPreferences(); + await loadOpenPgp(); + await loadIdentities(); + await loadFolders(true); + await loadNotificationPreferences(); + connectRealtime(); + } catch (error) { + errorBox.textContent = error.message; + errorBox.classList.remove("hidden"); + } finally { + setBusy(submit, false); + } + } + + async function logout() { + disconnectRealtime(); + try { await request("/auth/logout", { method: "POST", body: "{}" }); } catch (_) { } + state.accountId = null; + state.accounts = []; + localStorage.removeItem("rustbox.activeAccount"); + showLogin(); + } + + function folderIconName(folder) { + const attrs = (folder.attributes || []).join(" ").toLowerCase(); + const name = String(folder.name || "").toLowerCase(); + if (name === "inbox" || attrs.includes("\\inbox")) return "inbox"; + if (attrs.includes("\\sent") || name.includes("sent")) return "send"; + if (attrs.includes("\\drafts") || name.includes("draft")) return "file-text"; + if (attrs.includes("\\archive") || name.includes("archive")) return "archive"; + if (attrs.includes("\\trash") || name.includes("trash") || name.includes("deleted")) return "trash"; + if (attrs.includes("\\junk") || name.includes("spam") || name.includes("junk")) return "shield-alert"; + return "folder"; + } + + function orderedFolders() { + return [...state.folders].sort((a, b) => { + if (a.name.toUpperCase() === "INBOX") return -1; + if (b.name.toUpperCase() === "INBOX") return 1; + return a.name.localeCompare(b.name); + }); + } + + function folderDepth(folder) { + const delimiter = folder.delimiter || "/"; + if (!delimiter || !folder.name.includes(delimiter)) return 0; + return Math.min(8, folder.name.split(delimiter).length - 1); + } + + function folderLeafName(folder) { + const delimiter = folder.delimiter || "/"; + if (!delimiter || !folder.name.includes(delimiter)) return folder.name; + return folder.name.split(delimiter).filter(Boolean).at(-1) || folder.name; + } + + function folderHasChildren(folder, folders = state.folders) { + const delimiter = folder.delimiter || "/"; + if (!delimiter) return false; + const prefix = `${folder.name}${delimiter}`; + return folders.some((candidate) => candidate.name !== folder.name && candidate.name.startsWith(prefix)); + } + + function folderAncestors(folder) { + const delimiter = folder.delimiter || "/"; + if (!delimiter || !folder.name.includes(delimiter)) return []; + const parts = folder.name.split(delimiter); + const result = []; + for (let index = 1; index < parts.length; index += 1) result.push(parts.slice(0, index).join(delimiter)); + return result; + } + + function collapsedFolderStorageKey() { + return `rustbox.collapsedFolders.${state.accountId || "guest"}`; + } + + function loadCollapsedFolders() { + try { + const raw = JSON.parse(localStorage.getItem(collapsedFolderStorageKey()) || "[]"); + state.collapsedFolders = new Set(Array.isArray(raw) ? raw.filter((value) => typeof value === "string") : []); + } catch (_) { + state.collapsedFolders = new Set(); + } + } + + function saveCollapsedFolders() { + localStorage.setItem(collapsedFolderStorageKey(), JSON.stringify([...state.collapsedFolders])); + } + + function folderIsHiddenByCollapsedAncestor(folder) { + return folderAncestors(folder).some((name) => state.collapsedFolders.has(name)); + } + + function toggleFolderBranch(folder) { + if (state.collapsedFolders.has(folder.name)) state.collapsedFolders.delete(folder.name); + else state.collapsedFolders.add(folder.name); + saveCollapsedFolders(); + renderFolders(); + } + + function renderFolders() { + const list = $("#folder-list"); + list.replaceChildren(); + const folders = orderedFolders(); + for (const folder of folders) { + if (folderIsHiddenByCollapsedAncestor(folder)) continue; + const row = el("div", "folder-row"); + row.style.setProperty("--folder-depth", String(folderDepth(folder))); + + const hasChildren = folderHasChildren(folder, folders); + const expander = el("button", `folder-expander ${hasChildren ? "" : "placeholder"}`); + expander.type = "button"; + expander.tabIndex = hasChildren ? 0 : -1; + expander.setAttribute("aria-label", state.collapsedFolders.has(folder.name) ? `Expand ${folder.name}` : `Collapse ${folder.name}`); + expander.setAttribute("aria-expanded", String(hasChildren && !state.collapsedFolders.has(folder.name))); + if (hasChildren) { + expander.innerHTML = svgIcon("chevron-right"); + expander.classList.toggle("expanded", !state.collapsedFolders.has(folder.name)); + expander.addEventListener("click", (event) => { event.stopPropagation(); toggleFolderBranch(folder); }); + } + + const button = el("button", `folder-button ${folder.name === state.folder ? "active" : ""}`); + button.type = "button"; + button.innerHTML = `${svgIcon(folderIconName(folder))}${escapeHtml(folderLeafName(folder))}${folder.unread_count || folder.total_count || ""}`; + button.addEventListener("contextmenu", (event) => { event.preventDefault(); openFolderContextMenu(event.clientX, event.clientY, folder); }); + button.addEventListener("dragover", (event) => { + if (!event.dataTransfer?.types?.includes("application/x-rustbox-message")) return; + event.preventDefault(); + event.dataTransfer.dropEffect = "move"; + button.classList.add("drop-target"); + }); + button.addEventListener("dragleave", () => button.classList.remove("drop-target")); + button.addEventListener("drop", async (event) => { + event.preventDefault(); + button.classList.remove("drop-target"); + try { + const payload = JSON.parse(event.dataTransfer.getData("application/x-rustbox-message")); + if (!payload?.folder || !payload?.uid || payload.folder === folder.name) return; + await moveMessageByDrag(payload.folder, Number(payload.uid), folder.name); + } catch (error) { toast(error.message || "Could not move message", "error"); } + }); + button.addEventListener("click", async () => { + saveMessageListState(); + state.folder = folder.name; + state.selected = null; + state.selectedMessages.clear(); + clearActiveSearchContext(); + state.currentDetail = null; + renderFolders(); + closeReader(); + $("#folder-pane").classList.remove("open"); + await loadMessages(true, { restoreState: true }); + }); + row.append(expander, button); + list.append(row); + } + renderMoveOptions(); + } + + function updateUnreadAppBadge() { + const unread = state.folders.reduce((sum, folder) => sum + Math.max(0, Number(folder.unread_count || 0)), 0); + document.title = unread > 0 ? `(${unread}) RustBox` : "RustBox"; + try { + if (unread > 0 && typeof navigator.setAppBadge === "function") navigator.setAppBadge(unread); + else if (unread === 0 && typeof navigator.clearAppBadge === "function") navigator.clearAppBadge(); + } catch (_) { } + } + + async function loadFolders(restoreView = false) { + $("#connection-label").textContent = "Connecting"; + try { + const data = await request("/folders", { method: "GET" }); + state.folders = data.folders || []; + updateUnreadAppBadge(); + loadCollapsedFolders(); + const inbox = state.folders.find((folder) => folder.name.toUpperCase() === "INBOX"); + if (restoreView) { + const remembered = localStorage.getItem(lastFolderStorageKey()); + if (remembered && state.folders.some((folder) => folder.name === remembered)) state.folder = remembered; + } + if (!state.folders.some((folder) => folder.name === state.folder)) { + state.folder = inbox?.name || state.folders[0]?.name || "INBOX"; + } + renderFolders(); + $("#connection-label").textContent = "Connected"; + if (restoreView) { + const restoredSearch = readActiveSearchContext(); + if (restoredSearch) { + state.searchContext = restoredSearch; + const restoredParams = new URLSearchParams(restoredSearch.params); + $("#search-input").value = restoredParams.get("q") || ""; + await runSearch(restoredParams, restoredSearch.label || "Search", { restoreScrollTop: restoredSearch.scrollTop }); + return; + } + } + if (state.searchContext && !restoreView) await runSearch(new URLSearchParams(state.searchContext.params), state.searchContext.label, { restoreScrollTop: state.searchContext.scrollTop }); + else await loadMessages(true, { restoreState: restoreView }); + } catch (error) { + $("#connection-label").textContent = "Connection error"; + if (error.status === 401) return showLogin(); + toast(error.message, "error"); + $("#message-list").innerHTML = `
${escapeHtml(error.message)}
`; + } + } + + function renderMoveOptions() { + const selects = [ + ["#move-select", "Move to…"], + ["#copy-select", "Copy to…"], + ["#bulk-move-select", "Move to…"], + ["#bulk-copy-select", "Copy to…"], + ]; + for (const [selector, placeholder] of selects) { + const select = $(selector); + if (!select) continue; + select.replaceChildren(new Option(placeholder, "")); + for (const folder of orderedFolders()) { + if ((selector === "#move-select" || selector === "#copy-select") && folder.name === state.currentDetail?.folder) continue; + select.add(new Option(folder.name, folder.name)); + } + } + } + + function messageKey(message) { + return `${message.folder || state.folder}\u0000${message.uid}`; + } + + function activeSearchHighlightTerms() { + if (!state.searchContext?.params) return []; + const params = new URLSearchParams(state.searchContext.params); + const terms = []; + for (const key of ["from", "to", "subject"]) { + const value = (params.get(key) || "").trim(); + if (value) terms.push(value); + } + const query = (params.get("q") || "").trim(); + const tokens = query.match(/(?:[^\s"]+|"[^"]*")+/g) || []; + for (const token of tokens) { + const cleaned = token.replace(/^"|"$/g, ""); + const colon = cleaned.indexOf(":"); + if (colon > 0) { + const op = cleaned.slice(0, colon).toLowerCase(); + const value = cleaned.slice(colon + 1).replace(/^"|"$/g, "").trim(); + if (["from", "to", "subject"].includes(op) && value) terms.push(value); + } else if (cleaned.length >= 2) { + terms.push(cleaned); + } + } + return [...new Set(terms.map((term) => term.trim()).filter((term) => term.length >= 2))] + .sort((a, b) => b.length - a.length) + .slice(0, 12); + } + + function renderHighlightedText(node, value, terms) { + const text = String(value || ""); + node.replaceChildren(); + if (!terms.length || !text) { + node.textContent = text; + return; + } + const lower = text.toLocaleLowerCase(); + let offset = 0; + while (offset < text.length) { + let matchIndex = -1; + let matchTerm = ""; + for (const term of terms) { + const index = lower.indexOf(term.toLocaleLowerCase(), offset); + if (index >= 0 && (matchIndex < 0 || index < matchIndex || (index === matchIndex && term.length > matchTerm.length))) { + matchIndex = index; + matchTerm = term; + } + } + if (matchIndex < 0) { + node.append(document.createTextNode(text.slice(offset))); + break; + } + if (matchIndex > offset) node.append(document.createTextNode(text.slice(offset, matchIndex))); + const mark = el("mark", "search-highlight", text.slice(matchIndex, matchIndex + matchTerm.length)); + node.append(mark); + offset = matchIndex + matchTerm.length; + } + } + + function selectedMessages() { + const source = state.searchAllResults || state.messages; + return source.filter((message) => state.selectedMessages.has(messageKey(message))); + } + + function renderBulkToolbar() { + const selected = selectedMessages(); + const toolbar = $("#bulk-toolbar"); + toolbar.classList.toggle("hidden", !state.selectionMode); + $("#bulk-count").textContent = `${selected.length} selected`; + const selectionUniverse = state.searchAllResults || state.messages; + const allLoaded = selectionUniverse.length > 0 && selectionUniverse.every((message) => state.selectedMessages.has(messageKey(message))); + $("#bulk-select-all").checked = allLoaded; + $("#bulk-select-all").indeterminate = selected.length > 0 && !allLoaded; + toolbar.querySelectorAll("button, select").forEach((control) => { if (control.id !== "selection-done" && control.id !== "bulk-select-search") control.disabled = selected.length === 0; }); + const selectSearch = $("#bulk-select-search"); + if (selectSearch) { + selectSearch.classList.toggle("hidden", !state.searchContext || Boolean(state.searchAllResults)); + selectSearch.disabled = !state.searchContext; + } + const junkButton = $("#bulk-junk"); + if (junkButton) junkButton.textContent = selected.length > 0 && selected.every((message) => isJunkFolder(message.folder || state.folder)) ? tr("not_spam", "Not spam") : tr("spam", "Spam"); + const toggle = $("#select-mode-btn"); + if (toggle) { + toggle.classList.toggle("active", state.selectionMode); + toggle.setAttribute("aria-pressed", String(state.selectionMode)); + toggle.title = state.selectionMode ? "Exit selection mode" : "Select messages"; + toggle.setAttribute("aria-label", toggle.title); + } + } + + function setSelectionMode(enabled) { + state.selectionMode = Boolean(enabled); + if (!state.selectionMode) { + state.selectedMessages.clear(); + state.searchAllResults = null; + } + renderMessages(); + } + + function setMessageSelected(message, selected) { + if (!state.selectionMode) state.selectionMode = true; + const key = messageKey(message); + selected ? state.selectedMessages.add(key) : state.selectedMessages.delete(key); + renderMessages(); + } + + function clearBulkSelection() { + state.selectedMessages.clear(); + state.searchAllResults = null; + renderMessages(); + } + + async function selectAllSearchResults() { + if (!state.searchContext) return; + const button = $("#bulk-select-search"); + setBusy(button, true, "Selecting…"); + try { + const params = new URLSearchParams(state.searchContext.params); + params.set("all", "true"); + params.delete("offset"); + const data = await request(`/search?${params.toString()}`, { method: "GET" }); + state.searchAllResults = data.results || []; + for (const message of state.searchAllResults) state.selectedMessages.add(messageKey(message)); + state.total = state.searchAllResults.length; + renderMessages(); + toast(`Selected all ${state.searchAllResults.length} search result${state.searchAllResults.length === 1 ? "" : "s"}`, "success"); + } catch (error) { + toast(error.message, "error"); + } finally { + setBusy(button, false); + } + } + + function renderTagOptions() { + const select = $("#bulk-tag-select"); + if (!select) return; + select.replaceChildren(new Option("Add label…", "")); + for (const tag of state.tags) select.add(new Option(tag.name, tag.id)); + } + + function renderTags() { + const list = $("#tag-list"); + if (!list) return; + list.replaceChildren(); + for (const tag of state.tags) { + const button = el("button", "tag-item"); + button.type = "button"; + const dot = el("span", "tag-dot"); dot.style.setProperty("--tag-color", tag.color || "#6b7280"); + button.append(dot, el("span", "", tag.name), el("span", "tag-count", String(tag.message_count || 0))); + button.addEventListener("click", () => loadTagMessages(tag)); + button.addEventListener("contextmenu", (event) => { event.preventDefault(); openTagDialog(tag); }); + list.append(button); + } + renderTagOptions(); + } + + async function loadTags() { + try { + const data = await request("/tags", { method: "GET" }); + state.tags = data.tags || []; + renderTags(); + } catch (error) { toast(error.message, "error"); } + } + + async function loadTagMessages(tag) { + try { + const data = await request(`/tags/${encodeURIComponent(tag.id)}/messages?page=0&per_page=250`, { method: "GET" }); + state.messages = data.messages || []; + state.total = data.total_count || state.messages.length; + state.selectedMessages.clear(); + clearActiveSearchContext(); + renderMessages(); + $("#folder-title").textContent = `Label: ${tag.name}`; + $("#message-count").textContent = `${state.total} results`; + } catch (error) { toast(error.message, "error"); } + } + + function openTagDialog(tag = null) { + $("#tag-id").value = tag?.id || ""; + $("#tag-name").value = tag?.name || ""; + $("#tag-color").value = /^#[0-9a-f]{6}$/i.test(tag?.color || "") ? tag.color : "#6b7280"; + $("#tag-delete").disabled = !tag; + $("#tag-dialog").showModal(); + setTimeout(() => $("#tag-name").focus(), 20); + } + + async function saveTag(event) { + event.preventDefault(); + const id = $("#tag-id").value; + const payload = { name: $("#tag-name").value.trim(), color: $("#tag-color").value }; + if (!payload.name) return; + try { + await request(id ? `/tags/${encodeURIComponent(id)}` : "/tags", { method: id ? "PUT" : "POST", body: JSON.stringify(payload) }); + $("#tag-dialog").close(); + await loadTags(); + toast("Label saved"); + } catch (error) { toast(error.message, "error"); } + } + + async function deleteTag() { + const id = $("#tag-id").value; + if (!id || !await appConfirm("Delete this label? Messages will not be deleted.", { title: "Delete label", confirmLabel: "Delete", danger: true })) return; + try { await request(`/tags/${encodeURIComponent(id)}`, { method: "DELETE" }); $("#tag-dialog").close(); await loadTags(); toast("Label deleted"); } + catch (error) { toast(error.message, "error"); } + } + + async function promptTag(label, tags = state.tags) { + if (!tags.length) { toast("Create a label first", "error"); return null; } + const answer = await appSelect(label, tags.map((tag) => ({ value: tag.id, label: tag.name })), { value: tags[0].id, confirmLabel: "Choose label" }); + return tags.find((tag) => String(tag.id) === String(answer)) || null; + } + + async function addTagToMessage(message, tag) { + const folder = message.folder || state.folder; + await request(`/tags/${encodeURIComponent(tag.id)}/messages`, { method: "POST", body: JSON.stringify({ message_uid: message.uid, message_folder: folder }) }); + message.tags = [...(message.tags || []).filter((item) => item.id !== tag.id), { id: tag.id, name: tag.name, color: tag.color }]; + renderMessages(); + await loadTags(); + } + + async function removeTagFromMessage(message, tag) { + const folder = message.folder || state.folder; + await request(`/tags/${encodeURIComponent(tag.id)}/messages/${encodeURIComponent(folder)}/${message.uid}`, { method: "DELETE" }); + message.tags = (message.tags || []).filter((item) => item.id !== tag.id); + renderMessages(); + await loadTags(); + } + + async function bulkAddTag(tagId) { + const items = selectedMessages(); + const tag = state.tags.find((item) => item.id === tagId); + if (!items.length || !tag) return; + try { + await request(`/tags/${encodeURIComponent(tag.id)}/messages/bulk`, { method: "POST", body: JSON.stringify({ messages: items.map((message) => ({ uid: message.uid, folder: message.folder || state.folder })) }) }); + for (const message of items) message.tags = [...(message.tags || []).filter((item) => item.id !== tag.id), { id: tag.id, name: tag.name, color: tag.color }]; + renderMessages(); + await loadTags(); + toast(`Label “${tag.name}” added`); + } catch (error) { toast(error.message, "error"); } + } + + async function loadMessages(reset = false, options = {}) { + const list = $("#message-list"); + const restoreState = Boolean(options.restoreState); + const savedState = restoreState ? readMessageListState(state.folder) : null; + const previousScrollTop = reset && !restoreState && list ? list.scrollTop : null; + if (reset) { + state.page = 0; + state.messages = []; + state.selectedMessages.clear(); + clearActiveSearchContext(); + list.innerHTML = `
Loading messages…
`; + } + $("#folder-title").textContent = state.folder; + try { + const sort = encodeURIComponent(state.mailPrefs.default_sort || "date_desc"); + const maxRestorePage = Math.max(0, Math.ceil(500 / Math.max(20, state.perPage)) - 1); + const targetPage = reset && savedState ? Math.min(savedState.page, maxRestorePage) : state.page; + const pages = reset && targetPage > 0 ? Array.from({ length: targetPage + 1 }, (_, index) => index) : [state.page]; + let loaded = []; + let totalCount = 0; + let flaggedOnTop = state.currentFolderFlaggedOnTop; + let lastLoadedPage = state.page; + for (const page of pages) { + const data = await request(`/folders/${encodeURIComponent(state.folder)}/messages?page=${page}&per_page=${state.perPage}&sort=${sort}`, { method: "GET" }); + totalCount = data.total_count || 0; + flaggedOnTop = Boolean(data.flagged_on_top); + loaded.push(...(data.messages || [])); + lastLoadedPage = page; + if (loaded.length >= totalCount) break; + } + state.page = reset ? lastLoadedPage : state.page; + state.total = totalCount; + state.currentFolderFlaggedOnTop = flaggedOnTop; + state.messages = reset ? loaded : [...state.messages, ...loaded]; + $("#connection-label").textContent = "Connected"; + renderMessages(); + const desiredScroll = savedState?.scrollTop ?? previousScrollTop; + if (Number.isFinite(desiredScroll) && desiredScroll > 0) { + requestAnimationFrame(() => { list.scrollTop = Math.min(desiredScroll, Math.max(0, list.scrollHeight - list.clientHeight)); }); + } + saveMessageListState(); + } catch (error) { + $("#connection-label").textContent = error.network ? (navigator.onLine ? "Reconnecting" : "Offline") : "Connection error"; + if (!state.messages.length) list.innerHTML = `
${escapeHtml(error.message)}
`; + $("#message-count").textContent = state.messages.length ? `${state.total} messages · cached view` : "Unavailable"; + } + } + + async function quickUpdateMessageFlag(message, flag, add, successMessage) { + const folder = message.folder || state.folder; + try { + await request(`/messages/${encodeURIComponent(folder)}/${message.uid}/flags`, { method: "PATCH", body: JSON.stringify({ flags: [flag], add }) }); + const flags = new Set(Array.isArray(message.flags) ? message.flags : String(message.flags || "").split(/[,\s]+/).filter(Boolean)); + add ? flags.add(flag) : flags.delete(flag); + message.flags = [...flags].join(" "); + if (state.currentDetail?.uid === message.uid && state.currentDetail?.folder === folder) { + const detailFlags = new Set(state.currentDetail.flags || []); + add ? detailFlags.add(flag) : detailFlags.delete(flag); + state.currentDetail.flags = [...detailFlags]; + renderMessageDetail(state.currentDetail); + } + if (flag === "\\Flagged" && state.currentFolderFlaggedOnTop && !state.searchContext && folder === state.folder) await loadMessages(true); + else renderMessages(); + toast(successMessage, "success"); + } catch (error) { toast(error.message, "error"); } + } + + function renderMessages() { + $("#folder-title").textContent = state.folder; + $("#message-count").textContent = `${state.total} ${state.total === 1 ? "message" : "messages"}`; + const list = $("#message-list"); + const highlightTerms = activeSearchHighlightTerms(); + list.classList.toggle("selection-mode", state.selectionMode); + list.replaceChildren(); + if (!state.messages.length) list.innerHTML = `
${state.searchContext ? "No matching messages." : "No messages in this folder."}
`; + for (const message of state.messages) { + const folder = message.folder || state.folder; + const flags = Array.isArray(message.flags) ? message.flags.join(" ") : (message.flags || ""); + const unread = !flags.includes("\\Seen"); + const flagged = flags.includes("\\Flagged"); + const checked = state.selectedMessages.has(messageKey(message)); + const active = state.selected?.uid === message.uid && state.selected?.folder === folder; + const row = el("div", `message-row ${unread ? "unread" : ""} ${flagged ? "flagged" : ""} ${active ? "active" : ""} ${checked ? "selected" : ""}`); + row.tabIndex = 0; + row.setAttribute("role", "button"); + row.setAttribute("aria-label", `${message.from_name || message.from_address || "Unknown"}: ${message.subject || "(no subject)"}`); + const checkbox = document.createElement("input"); + checkbox.type = "checkbox"; + checkbox.className = "message-select"; + checkbox.checked = checked; + checkbox.disabled = !state.selectionMode; + checkbox.setAttribute("aria-label", "Select message"); + checkbox.addEventListener("click", (event) => { event.stopPropagation(); }); + checkbox.addEventListener("change", () => setMessageSelected(message, checkbox.checked)); + row.innerHTML = `
`; + renderHighlightedText(row.querySelector(".message-from"), message.from_name || message.from_address || "Unknown", highlightTerms); + renderHighlightedText(row.querySelector(".message-subject"), message.subject || "(no subject)", highlightTerms); + renderHighlightedText(row.querySelector(".message-snippet"), state.mailPrefs.show_snippets !== false ? (message.snippet || "") : "", highlightTerms); + const icons = row.querySelector(".message-icons"); + if (message.has_attachments) { + const attachmentIcon = el("span", "message-static-icon attachment-indicator"); + attachmentIcon.innerHTML = svgIcon("paperclip"); + attachmentIcon.title = "Has attachments"; + icons.append(attachmentIcon); + } + const quick = el("span", "message-quick-actions"); + const readButton = el("button", "message-quick-action"); + readButton.type = "button"; + readButton.title = unread ? "Mark read" : "Mark unread"; + readButton.setAttribute("aria-label", readButton.title); + readButton.innerHTML = svgIcon("mail"); + readButton.addEventListener("click", (event) => { event.stopPropagation(); quickUpdateMessageFlag(message, "\\Seen", unread, unread ? "Marked read" : "Marked unread"); }); + const flagButton = el("button", `message-quick-action message-flag ${flagged ? "active" : ""}`); + flagButton.type = "button"; + flagButton.title = flagged ? "Unflag" : "Flag"; + flagButton.setAttribute("aria-label", flagButton.title); + flagButton.innerHTML = svgIcon("star"); + flagButton.addEventListener("click", (event) => { event.stopPropagation(); quickUpdateMessageFlag(message, "\\Flagged", !flagged, flagged ? "Flag removed" : "Message flagged"); }); + quick.append(readButton, flagButton); + icons.append(quick); + if (Array.isArray(message.tags) && message.tags.length) { + const wrap = el("span", "message-tags"); + for (const tag of message.tags) { + const chip = el("span", "message-tag", tag.name); + chip.style.setProperty("--tag-color", tag.color || "#6b7280"); + wrap.append(chip); + } + row.querySelector(".message-subject").after(wrap); + } + row.prepend(checkbox); + row.draggable = true; + row.addEventListener("dragstart", (event) => { + event.dataTransfer.effectAllowed = "move"; + event.dataTransfer.setData("application/x-rustbox-message", JSON.stringify({ folder, uid: message.uid })); + row.classList.add("dragging"); + }); + row.addEventListener("dragend", () => row.classList.remove("dragging")); + row.addEventListener("click", (event) => { + if (state.selectionMode || event.ctrlKey || event.metaKey) return setMessageSelected(message, !state.selectedMessages.has(messageKey(message))); + openMessage(folder, message.uid); + }); + row.addEventListener("keydown", (event) => { if (event.key === "Enter" && !state.selectionMode) openMessage(folder, message.uid); if (event.key === " ") { event.preventDefault(); setMessageSelected(message, !state.selectedMessages.has(messageKey(message))); } }); + row.addEventListener("contextmenu", (event) => { event.preventDefault(); openMessageContextMenu(event.clientX, event.clientY, message); }); + list.append(row); + } + $("#load-more").classList.toggle("hidden", Boolean(state.searchContext) || state.messages.length >= state.total); + renderBulkToolbar(); + } + + function navigateMessage(delta) { + if (!state.selected) return; + const index = state.messages.findIndex((message) => message.uid === state.selected.uid && (message.folder || state.folder) === state.selected.folder); + if (index < 0) return; + const next = state.messages[index + delta]; + if (!next) return toast(delta > 0 ? "No next loaded message" : "No previous loaded message"); + openMessage(next.folder || state.folder, next.uid); + } + + function updateMessageNavigation() { + const index = state.selected ? state.messages.findIndex((message) => message.uid === state.selected.uid && (message.folder || state.folder) === state.selected.folder) : -1; + $("#reader-prev").disabled = index <= 0; + $("#reader-next").disabled = index < 0 || index >= state.messages.length - 1; + } + + async function senderIsSavedContact(email) { + if (!email) return false; + try { + const data = await request(`/contacts?q=${encodeURIComponent(email)}&limit=10`, { method: "GET" }); + const wanted = String(email).trim().toLowerCase(); + return (data.contacts || []).some((contact) => { + const addresses = [contact.email, ...(contact.emails || [])].filter(Boolean).map((value) => String(value).trim().toLowerCase()); + return addresses.includes(wanted); + }); + } catch (_) { + return false; + } + } + + async function openMessage(folder, uid) { + clearTimeout(state.markReadTimer); + state.markReadTimer = null; + state.selected = { folder, uid }; + renderMessages(); + updateMessageNavigation(); + $("#reader-empty").classList.add("hidden"); + $("#reader-content").classList.remove("hidden"); + $("#reader-subject").textContent = "Loading…"; + $("#message-body").replaceChildren(); + $("#reader").classList.add("mobile-open"); + try { + const detail = await request(`/messages/${encodeURIComponent(folder)}/${uid}`, { method: "GET" }); + if (state.mailPrefs.remote_images === "contacts" && detail.from_address) { + detail._senderInContacts = await senderIsSavedContact(detail.from_address); + } + state.currentDetail = detail; + renderMessageDetail(detail); + renderMoveOptions(); + scheduleMarkRead(detail); + } catch (error) { + $("#reader-subject").textContent = "Unable to open message"; + $("#message-body").innerHTML = `
${escapeHtml(error.message)}
`; + } + } + + function scheduleMarkRead(detail) { + clearTimeout(state.markReadTimer); + state.markReadTimer = null; + if (!detail || (detail.flags || []).includes("\\Seen")) return; + const delay = Math.max(0, Number(state.mailPrefs.mark_read_delay_ms || 0)); + const run = async () => { + if (!state.currentDetail || state.currentDetail.uid !== detail.uid || state.currentDetail.folder !== detail.folder) return; + try { + await request(`/messages/${encodeURIComponent(detail.folder)}/${detail.uid}/flags`, { method: "PATCH", body: JSON.stringify({ flags: ["\\Seen"], add: true }) }); + detail.flags = [...new Set([...(detail.flags || []), "\\Seen"])]; + const item = state.messages.find((msg) => msg.uid === detail.uid && (msg.folder || state.folder) === detail.folder); + if (item) { + const flags = Array.isArray(item.flags) ? item.flags : String(item.flags || "").split(/[,\s]+/).filter(Boolean); + item.flags = [...new Set([...flags, "\\Seen"])].join(" "); + } + renderMessages(); + } catch (_) { } + }; + if (delay === 0) run(); else state.markReadTimer = setTimeout(run, delay); + } + + function renderThread(detail) { + const thread = detail.thread || []; + const section = $("#thread-section"); + const list = $("#thread-list"); + list.replaceChildren(); + const enabled = state.mailPrefs.thread_mode !== "flat"; + section.classList.toggle("hidden", !enabled || thread.length <= 1); + if (!enabled || thread.length <= 1) return; + $("#thread-title").textContent = `Thread (${thread.length})`; + list.classList.toggle("hidden", !state.threadExpanded); + $("#thread-toggle").classList.toggle("expanded", state.threadExpanded); + $("#thread-toggle").setAttribute("aria-expanded", String(state.threadExpanded)); + for (const item of thread) { + const button = el("button", `thread-item ${item.uid === detail.uid && item.folder === detail.folder ? "current" : ""}`); + button.type = "button"; + button.innerHTML = `${escapeHtml(item.from_name || item.from_address || "Unknown")} — ${escapeHtml(item.subject || "(no subject)")}`; + button.addEventListener("click", () => openMessage(item.folder, item.uid)); + list.append(button); + } + } + + function renderMessageDetail(detail) { + $("#reader-subject").textContent = detail.subject || "(no subject)"; + $("#reader-from").textContent = detail.from_name ? `${detail.from_name} <${detail.from_address}>` : detail.from_address; + $("#reader-from").title = detail.from_address ? "Sender actions" : ""; + $("#reader-from").tabIndex = detail.from_address ? 0 : -1; + if (detail.from_address) $("#reader-from").setAttribute("role", "button"); + else $("#reader-from").removeAttribute("role"); + const to = (detail.to_addresses || []).map((entry) => entry.name ? `${entry.name} <${entry.address}>` : entry.address).join(", "); + $("#reader-to").textContent = `to ${to || "me"}`; + $("#reader-date").textContent = formatDate(detail.date, true); + $("#reader-avatar").textContent = initial(detail.from_name || detail.from_address); + $("#flag-btn").classList.toggle("active", (detail.flags || []).includes("\\Flagged")); + const spamButton = $("#spam-btn"); + const inJunk = isJunkFolder(detail.folder); + spamButton.classList.toggle("active", inJunk); + spamButton.title = inJunk ? tr("mark_not_spam", "Mark as not spam") : tr("mark_spam", "Mark as spam"); + spamButton.setAttribute("aria-label", spamButton.title); + renderThread(detail); + renderPgpReaderState(detail); + + const attachments = $("#attachment-list"); + attachments.replaceChildren(); + attachments.classList.toggle("hidden", !(detail.attachments || []).length); + for (const attachment of detail.attachments || []) { + const item = el("div", "attachment-item"); + const button = el("button", "attachment-button"); + button.type = "button"; + button.innerHTML = `${svgIcon("paperclip")}${escapeHtml(attachment.filename || "attachment")} · ${Math.max(1, Math.ceil((attachment.size || 0) / 1024))} KB`; + button.addEventListener("click", () => downloadAttachment(detail.folder, detail.uid, attachment)); + button.addEventListener("contextmenu", (event) => { + event.preventDefault(); + const filename = String(attachment.filename || "").toLowerCase(); + const contentType = String(attachment.content_type || attachment.contentType || "").toLowerCase(); + const actions = [ + { label: "Open", action: () => openAttachment(detail.folder, detail.uid, attachment) }, + { label: "Download", action: () => downloadAttachment(detail.folder, detail.uid, attachment) }, + ]; + if (contentType.includes("text/calendar") || filename.endsWith(".ics")) actions.push("separator", { label: "Add to calendar", action: () => importCalendarAttachment(detail.folder, detail.uid, attachment) }); + if (contentType.includes("text/vcard") || contentType.includes("text/x-vcard") || filename.endsWith(".vcf")) actions.push("separator", { label: "Import contact", action: () => importVcardAttachment(detail.folder, detail.uid, attachment) }); + if ((detail.attachments || []).length > 1) actions.push("separator", { label: "Download all as ZIP", action: () => downloadAllAttachmentsZip(detail) }); + showContextMenu(event.clientX, event.clientY, actions); + }); + item.append(button); + const filename = String(attachment.filename || "").toLowerCase(); + const contentType = String(attachment.content_type || attachment.contentType || "").toLowerCase(); + if (contentType.includes("text/calendar") || filename.endsWith(".ics")) { + const add = el("button", "icon-button small"); + add.innerHTML = svgIcon("calendar-days"); + add.type = "button"; + add.title = "Add to calendar"; + add.setAttribute("aria-label", "Add calendar attachment"); + add.addEventListener("click", () => importCalendarAttachment(detail.folder, detail.uid, attachment)); + item.append(add); + } + if (contentType.includes("text/vcard") || contentType.includes("text/x-vcard") || filename.endsWith(".vcf")) { + const add = el("button", "icon-button small"); + add.innerHTML = svgIcon("user-round"); + add.type = "button"; + add.title = "Import contact"; + add.setAttribute("aria-label", "Import vCard attachment"); + add.addEventListener("click", () => importVcardAttachment(detail.folder, detail.uid, attachment)); + item.append(add); + } + attachments.append(item); + } + if ((detail.attachments || []).length > 1) { + const all = el("button", "attachment-download-all"); + all.type = "button"; + all.innerHTML = `${svgIcon("package")}Download all as ZIP`; + all.addEventListener("click", () => downloadAllAttachmentsZip(detail)); + attachments.prepend(all); + } + + renderMessageBody(detail); + } + + function pgpMessageKind(detail) { + const headers = String(detail?.raw_headers || "").toLowerCase(); + const text = String(detail?.text || "").toLowerCase(); + const encrypted = (headers.includes("multipart/encrypted") && headers.includes("application/pgp-encrypted")) || text.includes("-----begin pgp message-----"); + const signed = (headers.includes("multipart/signed") && headers.includes("application/pgp-signature")) || text.includes("-----begin pgp signed message-----"); + return { pgp: encrypted || signed, encrypted, signed }; + } + + function renderPgpReaderState(detail) { + const bar = $("#pgp-reader-bar"); + const status = $("#pgp-reader-status"); + const title = $("#pgp-reader-title"); + const find = $("#pgp-reader-discover"); + const kind = pgpMessageKind(detail); + const configured = state.pgpKeys.length > 0; + bar.classList.toggle("hidden", !kind.pgp || !configured); + find.classList.add("hidden"); + if (!kind.pgp || !configured) return; + const processed = detail?._pgpProcessed; + if (!processed) { + title.textContent = kind.encrypted ? "OpenPGP encrypted message" : "OpenPGP signed message"; + status.textContent = kind.encrypted ? "Decrypt to read and verify the protected MIME payload." : "Signature has not been verified yet."; + $("#pgp-reader-process").textContent = kind.encrypted ? "Decrypt / verify" : "Verify signature"; + return; + } + const signer = processed.signerFingerprint ? ` · ${pgpFingerprintLabel(processed.signerFingerprint)}` : ""; + const signerIdentity = processed.signerEmail ? ` · ${processed.signerEmail}` : ""; + const trust = processed.signerTrust ? ` · trust: ${processed.signerTrust}` : ""; + if (processed.encrypted && processed.verified) { title.textContent = "OpenPGP decrypted + verified"; status.textContent = `Encrypted content was decrypted and its signature is valid${signer}${signerIdentity}${trust}`; } + else if (processed.encrypted) { title.textContent = "OpenPGP decrypted"; status.textContent = processed.signed ? `Content decrypted; embedded signature could not be verified${signer}${signerIdentity}${trust}` : "Content decrypted successfully"; } + else if (processed.verified) { title.textContent = "OpenPGP signature verified"; status.textContent = `Valid signature${signer}${signerIdentity}${trust}`; } + else { + title.textContent = "OpenPGP signature not verified"; + status.textContent = "No matching public key verified this signature."; + if (detail?.from_address && (state.pgpPrefs.wkdEnabled || state.pgpPrefs.hkpEnabled)) find.classList.remove("hidden"); + } + $("#pgp-reader-process").textContent = "Reprocess"; + } + + async function processCurrentPgpMessage() { + const detail = state.currentDetail; + if (!detail) return; + const kind = pgpMessageKind(detail); + let passphrase = ""; + if (kind.encrypted) { + const value = await appPrompt("Decrypt OpenPGP message", { message: "Enter the private-key passphrase if this key is protected. It is used only for this request and is not stored.", type: "password", confirmLabel: "Decrypt" }); + if (value === null) return; + passphrase = value; + } + const button = $("#pgp-reader-process"); setBusy(button, true); + try { + const result = await request(`/pgp/messages/${encodeURIComponent(detail.folder)}/${detail.uid}/process`, { method: "POST", body: JSON.stringify({ passphrase }) }); + detail._pgpProcessed = result; + renderPgpReaderState(detail); + if (result.text != null || result.html != null) renderMessageBody({ ...detail, text: result.text, html: result.html }); + renderPgpProcessedAttachments(result.attachments || []); + if (result.verified && result.signerTrust === "untrusted") toast("OpenPGP signature is cryptographically valid, but this key is marked untrusted", "warning"); + else if (result.verified) toast("OpenPGP signature verified", "success"); + else if (result.decrypted) toast("OpenPGP message decrypted", "success"); + else if (result.signed) toast("OpenPGP signature could not be verified", "warning"); + } catch (error) { toast(error.message, "error"); } + finally { setBusy(button, false); } + } + + function renderPgpProcessedAttachments(items) { + if (!items.length) return; + const list = $("#attachment-list"); + list.replaceChildren(); list.classList.remove("hidden"); + for (const attachment of items) { + const item = el("div", "attachment-item"); + const button = el("button", "attachment-button"); button.type = "button"; + button.innerHTML = `${svgIcon("paperclip")}${escapeHtml(attachment.filename || "attachment")} · ${Math.max(1, Math.ceil((attachment.size || 0) / 1024))} KB`; + button.addEventListener("click", () => { + try { + const binary = atob(attachment.dataBase64 || ""); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + const blob = new Blob([bytes], { type: attachment.contentType || "application/octet-stream" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = attachment.filename || "attachment"; a.click(); setTimeout(() => URL.revokeObjectURL(url), 30000); + } catch (_) { toast("Unable to decode decrypted attachment", "error"); } + }); + item.append(button); list.append(item); + } + } + + async function discoverCurrentPgpSigner() { + const email = state.currentDetail?.from_address; + if (!email) return; + const key = await discoverPgpEmail(email, false); + if (key) await processCurrentPgpMessage(); + } + + function renderMessageBody(detail, forceRemote = false) { + const body = $("#message-body"); + const remoteBar = $("#remote-image-bar"); + body.replaceChildren(); + remoteBar.classList.add("hidden"); + const preferredBody = state.mailPrefs.preferred_body || "auto"; + if (preferredBody === "text" || !detail?.html) { + body.append(el("pre", "", cleanPlainMessageText(detail?.text || "(empty message)"))); + return; + } + + const hasRemote = /(?:src|background)\s*=\s*["']https?:\/\//i.test(detail.html) || /url\(\s*["']?https?:\/\//i.test(detail.html); + const policy = state.mailPrefs.remote_images || "ask"; + const allowRemote = forceRemote || policy === "always" || (policy === "contacts" && detail?._senderInContacts === true); + if (hasRemote && !allowRemote && (policy === "ask" || policy === "contacts")) remoteBar.classList.remove("hidden"); + + const iframe = document.createElement("iframe"); + iframe.setAttribute("sandbox", ""); + iframe.setAttribute("referrerpolicy", "no-referrer"); + const imageSources = allowRemote ? "data: cid: https: http:" : "data: cid:"; + const csp = ``; + const quoteStyle = state.mailPrefs.collapse_quotes !== false ? "blockquote{max-height:16em;overflow:auto;border-left:2px solid #d9ddda;padding-left:12px;margin-left:0}" : "blockquote{border-left:2px solid #d9ddda;padding-left:12px;margin-left:0}"; + const safeHtml = sanitizeMessageHtml(detail.html); + iframe.srcdoc = `${csp}${safeHtml}`; + body.append(iframe); + } + + async function downloadAttachment(folder, uid, attachment) { + try { + const headers = new Headers(); + if (state.accountId) headers.set("X-Active-Account", state.accountId); + const response = await fetch(apiPath(`/messages/${encodeURIComponent(folder)}/${uid}/attachments/${encodeURIComponent(attachment.id)}`), { credentials: "include", headers }); + if (!response.ok) throw new Error(`Download failed (${response.status})`); + const blob = await response.blob(); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = attachment.filename || "attachment"; + link.click(); + setTimeout(() => URL.revokeObjectURL(url), 30000); + toast(`Downloading ${attachment.filename || "attachment"}`, "info"); + } catch (error) { toast(error.message, "error"); } + } + + async function fetchAttachmentBlob(folder, uid, attachment) { + const headers = new Headers(); + if (state.accountId) headers.set("X-Active-Account", state.accountId); + const response = await fetch(apiPath(`/messages/${encodeURIComponent(folder)}/${uid}/attachments/${encodeURIComponent(attachment.id)}`), { credentials: "include", headers }); + if (!response.ok) throw new Error(`Attachment request failed (${response.status})`); + return response.blob(); + } + + function attachmentMime(attachment, blob = null) { + return String(blob?.type || attachment?.content_type || attachment?.contentType || "application/octet-stream").toLowerCase(); + } + + async function openAttachment(folder, uid, attachment) { + try { + const blob = await fetchAttachmentBlob(folder, uid, attachment); + const mime = attachmentMime(attachment, blob); + const safe = mime.startsWith("image/") || mime === "application/pdf" || mime.startsWith("text/"); + if (!safe) return toast("Preview is not available for this attachment type", "warning"); + if (blob.size > 30 * 1024 * 1024) return toast("Attachment is too large for preview", "warning"); + + const dialog = el("dialog", "dialog attachment-preview-dialog"); + const card = el("div", "dialog-card attachment-preview-card"); + const header = el("header", "dialog-header"); + header.append(el("strong", "", attachment.filename || "Attachment preview")); + const close = el("button", "icon-button"); + close.type = "button"; + close.setAttribute("aria-label", "Close preview"); + close.innerHTML = svgIcon("x"); + header.append(close); + const body = el("div", "attachment-preview-body"); + let objectUrl = null; + + if (mime.startsWith("image/")) { + objectUrl = URL.createObjectURL(blob); + const image = document.createElement("img"); + image.src = objectUrl; + image.alt = attachment.filename || "Attachment image"; + body.append(image); + } else if (mime === "application/pdf") { + objectUrl = URL.createObjectURL(blob); + const frame = document.createElement("iframe"); + frame.src = objectUrl; + frame.title = attachment.filename || "PDF preview"; + frame.referrerPolicy = "no-referrer"; + body.append(frame); + } else { + if (blob.size > 5 * 1024 * 1024) return toast("Text attachment is too large for inline preview", "warning"); + const pre = document.createElement("pre"); + pre.textContent = await blob.text(); + body.append(pre); + } + + const footer = el("footer", "dialog-actions"); + const download = el("button", "button quiet", "Download"); + download.type = "button"; + download.addEventListener("click", () => downloadAttachment(folder, uid, attachment)); + const done = el("button", "button primary", "Close"); + done.type = "button"; + const dismiss = () => { if (dialog.open) dialog.close(); }; + close.addEventListener("click", dismiss); + done.addEventListener("click", dismiss); + footer.append(download, done); + card.append(header, body, footer); + dialog.append(card); + document.body.append(dialog); + dialog.addEventListener("close", () => { if (objectUrl) URL.revokeObjectURL(objectUrl); dialog.remove(); }, { once: true }); + dialog.showModal(); + } catch (error) { toast(error.message, "error"); } + } + + function safeZipFilename(value, index) { + const cleaned = String(value || `attachment-${index + 1}`) + .replace(/[\x00-\x1f\x7f\\/:*?"<>|]/g, "_") + .replace(/^\.+$/, "attachment") + .slice(0, 180); + return cleaned || `attachment-${index + 1}`; + } + + let crcTable = null; + function crc32(bytes) { + if (!crcTable) { + crcTable = new Uint32Array(256); + for (let n = 0; n < 256; n += 1) { + let c = n; + for (let k = 0; k < 8; k += 1) c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1); + crcTable[n] = c >>> 0; + } + } + let crc = 0xffffffff; + for (const byte of bytes) crc = crcTable[(crc ^ byte) & 0xff] ^ (crc >>> 8); + return (crc ^ 0xffffffff) >>> 0; + } + + function zipDosDateTime(date = new Date()) { + const year = Math.max(1980, date.getFullYear()); + const dosTime = ((date.getHours() & 31) << 11) | ((date.getMinutes() & 63) << 5) | ((Math.floor(date.getSeconds() / 2)) & 31); + const dosDate = (((year - 1980) & 127) << 9) | (((date.getMonth() + 1) & 15) << 5) | (date.getDate() & 31); + return { dosTime, dosDate }; + } + + function zipRecord(size, fill) { + const bytes = new Uint8Array(size); + const view = new DataView(bytes.buffer); + fill(view); + return bytes; + } + + function concatBytes(parts) { + const total = parts.reduce((sum, part) => sum + part.byteLength, 0); + const out = new Uint8Array(total); + let offset = 0; + for (const part of parts) { out.set(part, offset); offset += part.byteLength; } + return out; + } + + async function downloadAllAttachmentsZip(detail = state.currentDetail) { + const attachments = detail?.attachments || []; + if (!attachments.length) return toast("This message has no attachments", "warning"); + if (attachments.length > 100) return toast("Too many attachments for a browser ZIP", "warning"); + try { + toast(`Preparing ZIP with ${attachments.length} attachment${attachments.length === 1 ? "" : "s"}…`, "info"); + const encoder = new TextEncoder(); + const localParts = []; + const centralParts = []; + let localOffset = 0; + let totalPayload = 0; + for (let index = 0; index < attachments.length; index += 1) { + const attachment = attachments[index]; + const blob = await fetchAttachmentBlob(detail.folder, detail.uid, attachment); + totalPayload += blob.size; + if (totalPayload > 120 * 1024 * 1024) throw new Error("Attachments exceed the 120 MB browser ZIP limit"); + const data = new Uint8Array(await blob.arrayBuffer()); + const name = encoder.encode(safeZipFilename(attachment.filename, index)); + const checksum = crc32(data); + const { dosTime, dosDate } = zipDosDateTime(); + const local = zipRecord(30, (view) => { + view.setUint32(0, 0x04034b50, true); view.setUint16(4, 20, true); view.setUint16(6, 0x0800, true); view.setUint16(8, 0, true); + view.setUint16(10, dosTime, true); view.setUint16(12, dosDate, true); view.setUint32(14, checksum, true); + view.setUint32(18, data.length, true); view.setUint32(22, data.length, true); view.setUint16(26, name.length, true); view.setUint16(28, 0, true); + }); + const central = zipRecord(46, (view) => { + view.setUint32(0, 0x02014b50, true); view.setUint16(4, 20, true); view.setUint16(6, 20, true); view.setUint16(8, 0x0800, true); view.setUint16(10, 0, true); + view.setUint16(12, dosTime, true); view.setUint16(14, dosDate, true); view.setUint32(16, checksum, true); + view.setUint32(20, data.length, true); view.setUint32(24, data.length, true); view.setUint16(28, name.length, true); view.setUint16(30, 0, true); + view.setUint16(32, 0, true); view.setUint16(34, 0, true); view.setUint16(36, 0, true); view.setUint32(38, 0, true); view.setUint32(42, localOffset, true); + }); + localParts.push(local, name, data); + centralParts.push(central, name); + localOffset += local.byteLength + name.byteLength + data.byteLength; + } + const centralDirectory = concatBytes(centralParts); + const eocd = zipRecord(22, (view) => { + view.setUint32(0, 0x06054b50, true); view.setUint16(4, 0, true); view.setUint16(6, 0, true); + view.setUint16(8, attachments.length, true); view.setUint16(10, attachments.length, true); + view.setUint32(12, centralDirectory.byteLength, true); view.setUint32(16, localOffset, true); view.setUint16(20, 0, true); + }); + const zip = new Blob([...localParts, centralDirectory, eocd], { type: "application/zip" }); + const url = URL.createObjectURL(zip); + const link = document.createElement("a"); + const stem = String(detail.subject || "attachments").replace(/[\x00-\x1f\x7f\\/:*?"<>|]/g, "_").slice(0, 80) || "attachments"; + link.href = url; link.download = `${stem}-attachments.zip`; document.body.append(link); link.click(); link.remove(); + setTimeout(() => URL.revokeObjectURL(url), 30000); + toast(`Downloaded ${attachments.length} attachment${attachments.length === 1 ? "" : "s"} as ZIP`, "success"); + } catch (error) { toast(error.message, "error"); } + } + + async function importCalendarAttachment(folder, uid, attachment) { + try { + const blob = await fetchAttachmentBlob(folder, uid, attachment); + if (blob.size > 10 * 1024 * 1024) throw new Error("ICS attachment is too large"); + const text = await blob.text(); + const headers = new Headers({ "Content-Type": "text/calendar; charset=utf-8", "X-Requested-With": "XMLHttpRequest" }); + if (state.accountId) headers.set("X-Active-Account", state.accountId); + const response = await fetch(apiPath("/calendar/events/import-ics"), { method: "POST", credentials: "include", headers, body: text }); + const data = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(data?.error?.message || `Calendar import failed (${response.status})`); + toast(`Calendar: ${data.created || 0} event(s) imported`); + } catch (error) { toast(error.message, "error"); } + } + + async function importVcardAttachment(folder, uid, attachment) { + try { + const blob = await fetchAttachmentBlob(folder, uid, attachment); + if (blob.size > 10 * 1024 * 1024) throw new Error("vCard attachment is too large"); + const form = new FormData(); + form.append("file", blob, attachment.filename || "contact.vcf"); + const result = await request("/contacts/import", { method: "POST", body: form }); + toast(`Contacts: ${result.created || 0} created, ${result.updated || 0} updated`); + } catch (error) { toast(error.message, "error"); } + } + + function closeReader() { + clearTimeout(state.markReadTimer); + state.markReadTimer = null; + $("#reader").classList.remove("mobile-open"); + $("#reader-empty").classList.remove("hidden"); + $("#reader-content").classList.add("hidden"); + } + + function parseRecipients(raw) { + return raw.split(",").map((value) => value.trim()).filter(Boolean); + } + + function recipientToken(input) { + const value = input.value || ""; + const comma = value.lastIndexOf(","); + return { prefix: comma >= 0 ? value.slice(0, comma + 1) : "", query: value.slice(comma + 1).trim() }; + } + + function bindRecipientAutocomplete(selector) { + const input = $(selector); + if (!input) return; + const host = input.closest("label") || input.parentElement; + host.classList.add("recipient-field"); + const menu = el("div", "recipient-suggestions hidden"); + menu.setAttribute("role", "listbox"); + host.append(menu); + let timer = null; + let activeIndex = -1; + let suggestions = []; + + const hide = () => { menu.classList.add("hidden"); menu.replaceChildren(); activeIndex = -1; suggestions = []; }; + const select = (item) => { + const { prefix } = recipientToken(input); + const formatted = item.name?.trim() ? `${item.name.trim()} <${item.email}>` : item.email; + input.value = `${prefix}${prefix ? " " : ""}${formatted}, `; + hide(); + input.focus(); + scheduleDraftSave(); + input.dispatchEvent(new Event("input", { bubbles: true })); + }; + const render = () => { + menu.replaceChildren(); + if (!suggestions.length) { hide(); return; } + suggestions.forEach((item, index) => { + const button = el("button", `recipient-suggestion ${index === activeIndex ? "active" : ""}`); + button.type = "button"; + button.setAttribute("role", "option"); + button.setAttribute("aria-selected", String(index === activeIndex)); + const primary = el("strong", "", item.name?.trim() || item.email); + const secondary = el("span", "", item.name?.trim() ? item.email : (item.source || "")); + button.append(primary, secondary); + button.addEventListener("mousedown", (event) => event.preventDefault()); + button.addEventListener("click", () => select(item)); + menu.append(button); + }); + menu.classList.remove("hidden"); + }; + const load = async () => { + const { query } = recipientToken(input); + if (query.length < 2 || query.includes(">")) { hide(); return; } + try { + const data = await request(`/contacts/autocomplete?q=${encodeURIComponent(query)}&limit=8`, { method: "GET" }); + suggestions = data.suggestions || []; + activeIndex = -1; + render(); + } catch (_) { hide(); } + }; + + input.addEventListener("input", () => { clearTimeout(timer); timer = setTimeout(load, 140); }); + input.addEventListener("blur", () => setTimeout(hide, 120)); + input.addEventListener("keydown", (event) => { + if (menu.classList.contains("hidden") || !suggestions.length) return; + if (event.key === "ArrowDown") { event.preventDefault(); activeIndex = (activeIndex + 1) % suggestions.length; render(); } + else if (event.key === "ArrowUp") { event.preventDefault(); activeIndex = (activeIndex - 1 + suggestions.length) % suggestions.length; render(); } + else if (event.key === "Enter" && activeIndex >= 0) { event.preventDefault(); select(suggestions[activeIndex]); } + else if (event.key === "Escape") { event.preventDefault(); hide(); } + }); + } + + function quoteTextAsHtml(text) { + return escapeHtml(cleanPlainMessageText(text || "")).replaceAll("\n", "
"); + } + + function ownAddressSet() { + return new Set(state.accounts.map((account) => String(account.email || "").trim().toLowerCase()).filter(Boolean)); + } + + function ensureDraftId() { + if (!state.composeDraftId) state.composeDraftId = crypto.randomUUID ? crypto.randomUUID() : `draft-${Date.now()}-${Math.random().toString(16).slice(2)}`; + return state.composeDraftId; + } + + function renderComposeAttachments() { + const box = $("#compose-attachments"); + box.replaceChildren(); + const visibleAttachments = state.composeAttachments.filter((attachment) => !state.inlineAttachmentIds.has(String(attachment.id))); + box.classList.toggle("hidden", !visibleAttachments.length); + for (const attachment of visibleAttachments) { + const item = el("div", "compose-attachment"); + item.innerHTML = `${svgIcon("paperclip")}${escapeHtml(attachment.filename)} · ${Math.max(1, Math.ceil((attachment.size || 0) / 1024))} KB`; + const remove = el("button", ""); + remove.innerHTML = svgIcon("x"); + remove.type = "button"; + remove.setAttribute("aria-label", `Remove ${attachment.filename}`); + remove.addEventListener("click", () => deleteComposeAttachment(attachment.id)); + item.append(remove); + box.append(item); + } + if ($("#compose-pgp-mode")) updateComposePgpUi(); + } + + function composeRecoveryKey() { + return `rustbox.composeRecovery.${state.accountId || "default"}`; + } + + function draftHasContent() { + return Boolean( + $("#compose-to")?.value.trim() || $("#compose-cc")?.value.trim() || $("#compose-bcc")?.value.trim() || + $("#compose-subject")?.value.trim() || $("#compose-editor")?.innerText.trim() || state.composeAttachments.length + ); + } + + function composeAttachmentContentUrl(draftId, attachmentId) { + const query = state.accountId ? `?account_id=${encodeURIComponent(state.accountId)}` : ""; + return `${apiPath(`/drafts/${encodeURIComponent(draftId)}/attachments/${encodeURIComponent(attachmentId)}/content`)}${query}`; + } + + function markdownInlineToSafeHtml(value) { + const source = String(value ?? ""); + const code = []; + let html = escapeHtml(source).replace(/`([^`\n]+)`/g, (_, body) => { + const token = `@@RUSTBOX_CODE_${code.length}@@`; + code.push(`${body}`); + return token; + }); + html = html.replace(/\[([^\]]+)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g, (_, label, rawUrl) => { + let url = rawUrl.replaceAll("&", "&"); + try { + const parsed = new URL(url, window.location.origin); + if (!["http:", "https:", "mailto:"].includes(parsed.protocol)) return label; + url = parsed.protocol === "mailto:" ? url : parsed.href; + } catch (_) { return label; } + return `${label}`; + }); + html = html + .replace(/\*\*([^*\n]+)\*\*/g, "$1") + .replace(/__([^_\n]+)__/g, "$1") + .replace(/~~([^~\n]+)~~/g, "$1") + .replace(/(^|[\s(])\*([^*\n]+)\*(?=$|[\s).,!?:;])/g, "$1$2") + .replace(/(^|[\s(])_([^_\n]+)_(?=$|[\s).,!?:;])/g, "$1$2"); + code.forEach((snippet, index) => { html = html.replace(`@@RUSTBOX_CODE_${index}@@`, snippet); }); + return html; + } + + function markdownToSafeHtml(markdown) { + const lines = String(markdown ?? "").replace(/\r\n?/g, "\n").split("\n"); + const out = []; + let paragraph = []; + let listType = null; + let inCode = false; + let codeLines = []; + const flushParagraph = () => { + if (!paragraph.length) return; + out.push(`

${paragraph.map(markdownInlineToSafeHtml).join("
")}

`); + paragraph = []; + }; + const closeList = () => { + if (!listType) return; + out.push(``); + listType = null; + }; + for (const line of lines) { + if (/^\s*```/.test(line)) { + flushParagraph(); closeList(); + if (inCode) { out.push(`
${escapeHtml(codeLines.join("\n"))}
`); codeLines = []; inCode = false; } + else { inCode = true; } + continue; + } + if (inCode) { codeLines.push(line); continue; } + if (!line.trim()) { flushParagraph(); closeList(); continue; } + const heading = line.match(/^(#{1,6})\s+(.+)$/); + if (heading) { flushParagraph(); closeList(); const level = heading[1].length; out.push(`${markdownInlineToSafeHtml(heading[2])}`); continue; } + if (/^\s*(?:---+|\*\*\*+)\s*$/.test(line)) { flushParagraph(); closeList(); out.push("
"); continue; } + const quote = line.match(/^>\s?(.*)$/); + if (quote) { flushParagraph(); closeList(); out.push(`
${markdownInlineToSafeHtml(quote[1])}
`); continue; } + const unordered = line.match(/^\s*[-+*]\s+(.+)$/); + const ordered = line.match(/^\s*\d+[.)]\s+(.+)$/); + if (unordered || ordered) { + flushParagraph(); + const wanted = unordered ? "ul" : "ol"; + if (listType !== wanted) { closeList(); listType = wanted; out.push(`<${wanted}>`); } + out.push(`
  • ${markdownInlineToSafeHtml((unordered || ordered)[1])}
  • `); + continue; + } + paragraph.push(line); + } + if (inCode) out.push(`
    ${escapeHtml(codeLines.join("\n"))}
    `); + flushParagraph(); closeList(); + return out.join("\n"); + } + + function composeFontCss(value = state.prefs.composeFontFamily) { + return ({ + system: '-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif', + humanist: '"Trebuchet MS","Segoe UI",sans-serif', + serif: 'Georgia,"Times New Roman",serif', + mono: 'ui-monospace,SFMono-Regular,Menlo,Consolas,monospace', + })[value] || '-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif'; + } + + function wrapComposeHtmlFont(html) { + if (!html) return null; + const family = composeFontCss().replace(/&/g, "&").replace(/"/g, """); + return `
    ${html}
    `; + } + + function serializeComposeHtml(editor = $("#compose-editor")) { + if (!editor) return null; + const mode = $("#compose-mode").value; + if (mode === "markdown") { + const markdown = editor.innerText.trim(); + return markdown ? wrapComposeHtmlFont(markdownToSafeHtml(markdown)) : null; + } + if (mode !== "html") return null; + const clone = editor.cloneNode(true); + clone.querySelectorAll(".rustbox-compose-font").forEach((wrapper) => wrapper.replaceWith(...wrapper.childNodes)); + clone.querySelectorAll("img[data-rustbox-cid]").forEach((image) => { + const cid = image.dataset.rustboxCid || ""; + if (cid) image.setAttribute("src", `cid:${cid}`); + image.removeAttribute("data-rustbox-cid"); + image.removeAttribute("data-rustbox-align"); + image.classList.remove("compose-inline-image-selected"); + if (!image.className) image.removeAttribute("class"); + }); + const html = clone.innerHTML.trim(); + return html ? wrapComposeHtmlFont(html) : null; + } + + function hydrateComposeHtml(html, attachments = state.composeAttachments, draftId = state.composeDraftId) { + const host = document.createElement("div"); + host.innerHTML = sanitizeMessageHtml(html || ""); + if (host.children.length === 1 && host.firstElementChild?.classList.contains("rustbox-compose-font")) { + host.innerHTML = host.firstElementChild.innerHTML; + } + if (!draftId) return host.innerHTML; + const ids = new Set((attachments || []).map((attachment) => String(attachment.id || "")).filter(Boolean)); + host.querySelectorAll("img[src]").forEach((image) => { + const src = image.getAttribute("src") || ""; + if (!src.toLowerCase().startsWith("cid:")) return; + const cid = src.slice(4).replace(/^<|>$/g, ""); + if (!ids.has(cid)) return; + state.inlineAttachmentIds.add(cid); + image.dataset.rustboxCid = cid; + image.setAttribute("src", composeAttachmentContentUrl(draftId, cid)); + }); + return host.innerHTML; + } + + function composeDraftPayload() { + const editor = $("#compose-editor"); + return { + id: ensureDraftId(), + to: $("#compose-to").value, + cc: $("#compose-cc").value, + bcc: $("#compose-bcc").value, + subject: $("#compose-subject").value, + text_body: editor.innerText, + html_body: serializeComposeHtml(editor), + in_reply_to: state.composeReplyMeta?.inReplyTo || null, + references: state.composeReplyMeta?.references || null, + request_mdn: Boolean($("#compose-mdn")?.checked), + priority: $("#compose-priority")?.value || "normal", + compose_format: $("#compose-mode")?.value || "html", + pgp_mode: $("#compose-pgp-mode")?.value || "none", + pgp_inline: Boolean($("#compose-pgp-inline")?.checked), + pgp_attach_public_key: Boolean($("#compose-pgp-attach-key")?.checked), + pgp_attach_recipient_keys: Boolean($("#compose-pgp-attach-recipient-keys")?.checked), + }; + } + + function readLocalDraftSnapshots() { + try { + const raw = localStorage.getItem(composeRecoveryKey()); + if (!raw) return []; + const parsed = JSON.parse(raw); + const drafts = Array.isArray(parsed) ? parsed : Array.isArray(parsed?.drafts) ? parsed.drafts : parsed?.id ? [parsed] : []; + return drafts.filter((draft) => draft && draft.id).sort((a, b) => String(b.saved_at || "").localeCompare(String(a.saved_at || ""))); + } catch (_) { return []; } + } + + function writeLocalDraftSnapshots(drafts) { + const kept = drafts.filter((draft) => draft?.id).slice(0, 10); + if (!kept.length) localStorage.removeItem(composeRecoveryKey()); + else localStorage.setItem(composeRecoveryKey(), JSON.stringify({ drafts: kept })); + } + + function storeLocalDraftSnapshot() { + if (!draftHasContent()) return; + try { + const payload = composeDraftPayload(); + const snapshot = { + ...payload, + compose_format: $("#compose-mode").value, + attachments: state.composeAttachments, + saved_at: new Date().toISOString(), + }; + const drafts = readLocalDraftSnapshots().filter((draft) => draft.id !== payload.id); + drafts.unshift(snapshot); + writeLocalDraftSnapshots(drafts); + } catch (_) { } + } + + function clearLocalDraftSnapshot(draftId = state.composeDraftId) { + if (!draftId) return; + writeLocalDraftSnapshots(readLocalDraftSnapshots().filter((draft) => draft.id !== draftId)); + } + + async function saveCurrentDraft(silent = false, force = false, syncImap = false) { + if (!force && !$("#compose-dialog").open) return false; + if (!force && !state.composeDirty) return true; + if (!draftHasContent()) { state.composeDirty = false; clearLocalDraftSnapshot(); return true; } + const payload = composeDraftPayload(); + payload.sync_imap = Boolean(syncImap); + try { + await request("/drafts", { method: "POST", body: JSON.stringify(payload) }); + state.composeDirty = false; + if (syncImap) state.composeNeedsRemoteSync = false; + clearLocalDraftSnapshot(); + $("#compose-status").textContent = `Draft saved ${new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`; + if (!silent) toast("Draft saved", "success"); + return true; + } catch (error) { + storeLocalDraftSnapshot(); + if (!silent) toast(error.message, "error"); + return false; + } + } + + function scheduleDraftSave() { + state.composeDirty = true; + state.composeNeedsRemoteSync = true; + storeLocalDraftSnapshot(); + clearTimeout(state.draftSaveTimer); + state.draftSaveTimer = setTimeout(() => saveCurrentDraft(true), Math.max(1000, Number(state.prefs.composeAutosaveMs || 3000))); + } + + function setComposeUploadProgress(visible, percent = 0, label = "Uploading…") { + const box = $("#compose-upload-progress"); + const bar = $("#compose-upload-bar"); + const text = $("#compose-upload-label"); + if (!box || !bar || !text) return; + const value = Math.max(0, Math.min(100, Number(percent) || 0)); + box.classList.toggle("hidden", !visible); + bar.style.width = `${value}%`; + text.textContent = label; + } + + async function uploadComposeFiles(files, options = {}) { + const list = [...(files || [])]; + const announce = options.announce !== false; + if (!list.length) return []; + if (state.composeUploading) { if (announce) toast("Another upload is already in progress", "warning"); return []; } + state.composeUploading = true; + setComposeUploadProgress(true, 0, `Uploading ${list.length} file${list.length === 1 ? "" : "s"}…`); + await saveCurrentDraft(true, true); + const id = ensureDraftId(); + const form = new FormData(); + list.forEach((file) => form.append("file", file, file.name)); + try { + const data = await uploadRequest(`/drafts/${encodeURIComponent(id)}/attachments`, form, (percent) => { + setComposeUploadProgress(true, percent, `Uploading ${list.length} file${list.length === 1 ? "" : "s"}… ${percent}%`); + }); + const added = data.attachments || []; + state.composeAttachments.push(...added); + renderComposeAttachments(); + state.composeDirty = true; + state.composeNeedsRemoteSync = true; + storeLocalDraftSnapshot(); + setComposeUploadProgress(true, 100, "Upload complete"); + if (announce) toast(`${added.length} attachment${added.length === 1 ? "" : "s"} added`, "success"); + setTimeout(() => setComposeUploadProgress(false), 450); + return added; + } catch (error) { + setComposeUploadProgress(false); + toast(error.message, "error"); + return []; + } finally { + state.composeUploading = false; + } + } + + function previewMarkdown() { + const editor = $("#compose-editor"); + const preview = $("#markdown-preview-content"); + if (!editor || !preview) return; + preview.innerHTML = markdownToSafeHtml(editor.innerText); + $("#markdown-preview-dialog").showModal(); + } + + function setComposeMode(mode, persist = false) { + const value = ["html", "text", "markdown"].includes(mode) ? mode : "html"; + const select = $("#compose-mode"); + if (select) select.value = value; + const editor = $("#compose-editor"); + editor.dataset.mode = value; + editor.classList.toggle("plain-mode", value !== "html"); + $$(".editor-toolbar [data-command], #editor-link, #editor-image, #editor-font, #editor-size, #editor-color").forEach((control) => { control.disabled = value !== "html"; }); + const imageEdit = $("#editor-image-edit"); + if (imageEdit) imageEdit.disabled = value !== "html" || !state.composeSelectedImage; + $("#compose-markdown-preview")?.classList.toggle("hidden", value !== "markdown"); + if (persist) savePreference("composeFormat", value); + } + + function captureEditorRange(editor = $("#compose-editor")) { + const selection = window.getSelection(); + if (!editor || !selection || !selection.rangeCount) return null; + const range = selection.getRangeAt(0); + const container = range.commonAncestorContainer.nodeType === Node.ELEMENT_NODE ? range.commonAncestorContainer : range.commonAncestorContainer.parentElement; + if (container !== editor && !editor.contains(container)) return null; + return range.cloneRange(); + } + + function restoreEditorRange(range, editor = $("#compose-editor")) { + editor.focus(); + if (!range) return; + try { + const selection = window.getSelection(); + selection.removeAllRanges(); + selection.addRange(range); + } catch (_) { } + } + + function setSelectedInlineImage(image) { + if (state.composeSelectedImage && state.composeSelectedImage !== image) { + state.composeSelectedImage.classList?.remove("compose-inline-image-selected"); + } + state.composeSelectedImage = image && image.isConnected ? image : null; + state.composeSelectedImage?.classList.add("compose-inline-image-selected"); + const edit = $("#editor-image-edit"); + if (edit) edit.disabled = $("#compose-mode")?.value !== "html" || !state.composeSelectedImage; + } + + function inlineImageAlignment(image) { + if (!image) return "inline"; + if (image.dataset.rustboxAlign) return image.dataset.rustboxAlign; + const left = image.style.marginLeft; + const right = image.style.marginRight; + if (left === "auto" && right === "auto") return "center"; + if (left === "auto" && right === "0px") return "right"; + if (left === "0px" && right === "auto") return "left"; + return "inline"; + } + + function applyInlineImageDisplay(image, widthPercent, alignment) { + if (!image) return; + const width = Math.max(10, Math.min(100, Number(widthPercent) || 100)); + image.style.width = `${width}%`; + image.style.maxWidth = "100%"; + image.style.height = "auto"; + image.style.float = "none"; + image.style.marginTop = "0"; + image.style.marginBottom = "0"; + image.dataset.rustboxAlign = alignment; + if (alignment === "center") { + image.style.display = "block"; + image.style.marginLeft = "auto"; + image.style.marginRight = "auto"; + } else if (alignment === "right") { + image.style.display = "block"; + image.style.marginLeft = "auto"; + image.style.marginRight = "0px"; + } else if (alignment === "left") { + image.style.display = "block"; + image.style.marginLeft = "0px"; + image.style.marginRight = "auto"; + } else { + image.style.display = "inline-block"; + image.style.marginLeft = "0"; + image.style.marginRight = "0"; + } + } + + function imageCropValues() { + const value = (id) => Math.max(0, Math.min(45, Number($(id)?.value) || 0)); + return { top: value("#image-crop-top"), right: value("#image-crop-right"), bottom: value("#image-crop-bottom"), left: value("#image-crop-left") }; + } + + function updateImageEditOutputs() { + const width = Number($("#image-edit-width")?.value || 100); + const widthOut = $("#image-edit-width-value"); + if (widthOut) widthOut.textContent = `${width}%`; + for (const side of ["top", "right", "bottom", "left"]) { + const input = $(`#image-crop-${side}`); + const output = $(`#image-crop-${side}-value`); + if (input && output) output.textContent = `${input.value}%`; + } + } + + function drawInlineImageCropPreview() { + const bitmap = state.imageEditorBitmap; + const canvas = $("#image-edit-preview"); + if (!bitmap || !canvas) return; + const crop = imageCropValues(); + const sx = Math.round(bitmap.width * crop.left / 100); + const sy = Math.round(bitmap.height * crop.top / 100); + const sw = Math.max(1, Math.round(bitmap.width * (100 - crop.left - crop.right) / 100)); + const sh = Math.max(1, Math.round(bitmap.height * (100 - crop.top - crop.bottom) / 100)); + const scale = Math.min(1, 760 / sw, 560 / sh); + canvas.width = Math.max(1, Math.round(sw * scale)); + canvas.height = Math.max(1, Math.round(sh * scale)); + const ctx = canvas.getContext("2d"); + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.drawImage(bitmap, sx, sy, sw, sh, 0, 0, canvas.width, canvas.height); + updateImageEditOutputs(); + } + + function resetImageCropControls() { + for (const side of ["top", "right", "bottom", "left"]) { + const input = $(`#image-crop-${side}`); + if (input) input.value = "0"; + } + drawInlineImageCropPreview(); + } + + async function decodeInlineImageBlob(blob) { + if (typeof createImageBitmap === "function") return createImageBitmap(blob); + const url = URL.createObjectURL(blob); + try { + const image = new Image(); + image.decoding = "async"; + await new Promise((resolve, reject) => { image.onload = resolve; image.onerror = () => reject(new Error("Could not decode image")); image.src = url; }); + return image; + } finally { + URL.revokeObjectURL(url); + } + } + + async function openInlineImageEditor(image = state.composeSelectedImage) { + if (!image?.dataset?.rustboxCid) return toast("Select an inline image first", "info"); + setSelectedInlineImage(image); + const cid = image.dataset.rustboxCid; + const attachment = state.composeAttachments.find((item) => String(item.id) === String(cid)); + if (!attachment) return toast("The inline image attachment is no longer available", "error"); + try { + state.imageEditorBitmap?.close?.(); + const response = await fetch(composeAttachmentContentUrl(ensureDraftId(), cid), { credentials: "same-origin", cache: "no-store" }); + if (!response.ok) throw new Error(`Could not load image (${response.status})`); + state.imageEditorBlob = await response.blob(); + state.imageEditorBitmap = await decodeInlineImageBlob(state.imageEditorBlob); + const editorWidth = Math.max(1, $("#compose-editor")?.clientWidth || image.parentElement?.clientWidth || image.clientWidth || 1); + const stylePercent = /^\d+(?:\.\d+)?%$/.test(image.style.width || "") ? Number.parseFloat(image.style.width) : null; + const measuredPercent = Math.round((image.getBoundingClientRect().width / editorWidth) * 100 / 5) * 5; + $("#image-edit-width").value = String(Math.max(10, Math.min(100, stylePercent || measuredPercent || 100))); + $("#image-edit-align").value = inlineImageAlignment(image); + resetImageCropControls(); + const cropControls = $("#image-crop-controls"); + const isGif = String(attachment.content_type || state.imageEditorBlob.type || "").toLowerCase() === "image/gif"; + cropControls?.classList.toggle("image-crop-disabled", isGif); + cropControls?.querySelectorAll("input").forEach((input) => { input.disabled = isGif; }); + const help = $("#image-edit-help"); + if (help) help.textContent = isGif + ? "Size and position are available. Cropping animated GIF files is disabled so the animation is not flattened." + : "Resizing changes only the displayed size. Cropping creates a new CID image and replaces the previous inline attachment."; + drawInlineImageCropPreview(); + $("#image-edit-dialog").showModal(); + } catch (error) { + toast(error.message || "Could not open the image editor", "error"); + } + } + + function croppedImageFilename(filename, mime) { + const base = String(filename || "image").replace(/\.[^.]+$/, "") || "image"; + const ext = mime === "image/jpeg" ? "jpg" : mime === "image/webp" ? "webp" : "png"; + return `${base}-crop.${ext}`; + } + + async function cropInlineImageToFile(bitmap, sourceBlob, crop, filename) { + const sx = Math.round(bitmap.width * crop.left / 100); + const sy = Math.round(bitmap.height * crop.top / 100); + const sw = Math.max(1, Math.round(bitmap.width * (100 - crop.left - crop.right) / 100)); + const sh = Math.max(1, Math.round(bitmap.height * (100 - crop.top - crop.bottom) / 100)); + if (sw * sh > 40_000_000) throw new Error("The crop is too large to process safely in the browser"); + const canvas = document.createElement("canvas"); + canvas.width = sw; + canvas.height = sh; + const ctx = canvas.getContext("2d"); + ctx.drawImage(bitmap, sx, sy, sw, sh, 0, 0, sw, sh); + const sourceType = String(sourceBlob?.type || "").toLowerCase(); + const outputType = ["image/jpeg", "image/webp", "image/png"].includes(sourceType) ? sourceType : "image/png"; + const blob = await new Promise((resolve, reject) => canvas.toBlob((value) => value ? resolve(value) : reject(new Error("Could not create cropped image")), outputType, outputType === "image/png" ? undefined : 0.92)); + return new File([blob], croppedImageFilename(filename, outputType), { type: outputType, lastModified: Date.now() }); + } + + async function applyInlineImageEditor(event) { + event.preventDefault(); + const image = state.composeSelectedImage; + const bitmap = state.imageEditorBitmap; + if (!image?.dataset?.rustboxCid || !bitmap) return; + const width = Number($("#image-edit-width").value || 100); + const alignment = $("#image-edit-align").value || "inline"; + const crop = imageCropValues(); + const hasCrop = Object.values(crop).some((value) => value > 0); + const oldCid = image.dataset.rustboxCid; + const oldAttachment = state.composeAttachments.find((item) => String(item.id) === String(oldCid)); + const applyButton = $("#image-edit-apply"); + setBusy(applyButton, true); + try { + if (hasCrop) { + const type = String(oldAttachment?.content_type || state.imageEditorBlob?.type || "").toLowerCase(); + if (type === "image/gif") throw new Error("Cropping animated GIF files is disabled to preserve animation"); + const file = await cropInlineImageToFile(bitmap, state.imageEditorBlob, crop, oldAttachment?.filename || image.alt || "image"); + const uploaded = await uploadComposeFiles([file], { announce: false }); + const replacement = uploaded[0]; + if (!replacement) throw new Error("Could not upload the cropped image"); + state.inlineAttachmentIds.add(String(replacement.id)); + image.dataset.rustboxCid = replacement.id; + image.src = composeAttachmentContentUrl(ensureDraftId(), replacement.id); + image.alt = replacement.filename || image.alt || "image"; + state.inlineAttachmentIds.delete(String(oldCid)); + await deleteComposeAttachment(oldCid); + } + applyInlineImageDisplay(image, width, alignment); + setSelectedInlineImage(image); + scheduleDraftSave(); + $("#image-edit-dialog").close(); + toast(hasCrop ? "Image cropped" : "Image layout updated", "success"); + } catch (error) { + toast(error.message || "Could not update image", "error"); + } finally { + setBusy(applyButton, false); + } + } + + function scheduleUnusedInlineAttachmentCleanup() { + clearTimeout(state.inlineImageCleanupTimer); + state.inlineImageCleanupTimer = setTimeout(cleanupUnusedInlineAttachments, 3500); + } + + async function cleanupUnusedInlineAttachments() { + if (!state.composeDraftId || !state.inlineAttachmentIds.size) return; + const used = new Set($$("#compose-editor img[data-rustbox-cid]").map((image) => String(image.dataset.rustboxCid || "")).filter(Boolean)); + const unused = [...state.inlineAttachmentIds].filter((id) => !used.has(String(id))); + if (!unused.length) return; + let changed = false; + for (const id of unused) { + try { + await request(`/drafts/${encodeURIComponent(state.composeDraftId)}/attachments/${encodeURIComponent(id)}`, { method: "DELETE" }); + state.composeAttachments = state.composeAttachments.filter((attachment) => String(attachment.id) !== String(id)); + state.inlineAttachmentIds.delete(String(id)); + changed = true; + } catch (error) { + console.warn("Could not remove unused inline attachment", id, error); + } + } + if (changed) renderComposeAttachments(); + } + + function editorRangeFromPoint(x, y) { + const editor = $("#compose-editor"); + if (!editor) return null; + if (document.caretRangeFromPoint) { + const range = document.caretRangeFromPoint(x, y); + if (range && editor.contains(range.commonAncestorContainer.nodeType === Node.ELEMENT_NODE ? range.commonAncestorContainer : range.commonAncestorContainer.parentElement)) return range; + } + if (document.caretPositionFromPoint) { + const position = document.caretPositionFromPoint(x, y); + if (position?.offsetNode) { + const range = document.createRange(); + range.setStart(position.offsetNode, position.offset); + range.collapse(true); + if (editor.contains(position.offsetNode.nodeType === Node.ELEMENT_NODE ? position.offsetNode : position.offsetNode.parentElement)) return range; + } + } + return captureEditorRange(editor); + } + + async function uploadInlineImage(files, insertionRange = null) { + const images = [...(files || [])].filter((item) => /^image\/(png|jpeg|gif|webp)$/i.test(item.type)); + if (!images.length) return toast("Choose a PNG, JPEG, GIF or WebP image", "error"); + const uploaded = await uploadComposeFiles(images); + if (!uploaded.length) return; + setComposeMode("html"); + const editor = $("#compose-editor"); + restoreEditorRange(insertionRange, editor); + const draftId = ensureDraftId(); + let lastInsertedId = null; + for (const attachment of uploaded) { + const previewUrl = composeAttachmentContentUrl(draftId, attachment.id); + state.inlineAttachmentIds.add(String(attachment.id)); + lastInsertedId = String(attachment.id); + const html = `${escapeHtml(attachment.filename || `; + document.execCommand("insertHTML", false, html); + } + if (lastInsertedId) { + const inserted = [...editor.querySelectorAll("img[data-rustbox-cid]")].reverse().find((image) => String(image.dataset.rustboxCid) === lastInsertedId); + if (inserted) setSelectedInlineImage(inserted); + } + scheduleDraftSave(); + } + + function applyRecoveredDraft(draft, sourceLabel = "Saved draft") { + state.composeSignatureGeneration += 1; + state.composeDraftId = draft.id || null; + state.composeAttachments = Array.isArray(draft.attachments) ? draft.attachments : []; + state.inlineAttachmentIds = new Set(); + setSelectedInlineImage(null); + state.composeReplyMeta = { + inReplyTo: draft.in_reply_to || null, + references: draft.references || null, + }; + $("#compose-to").value = draft.to || ""; + $("#compose-cc").value = draft.cc || ""; + $("#compose-bcc").value = draft.bcc || ""; + $("#compose-subject").value = draft.subject || ""; + $("#compose-priority").value = ["high", "normal", "low"].includes(draft.priority) ? draft.priority : "normal"; + $("#compose-mdn").checked = Boolean(draft.request_mdn); + $("#compose-pgp-mode").value = ["none", "sign", "encrypt", "sign-encrypt"].includes(draft.pgp_mode) ? draft.pgp_mode : "none"; + $("#compose-pgp-inline").checked = Boolean(draft.pgp_inline); + $("#compose-pgp-attach-key").checked = Boolean(draft.pgp_attach_public_key); + $("#compose-pgp-attach-recipient-keys").checked = Boolean(draft.pgp_attach_recipient_keys); + $("#compose-pgp-passphrase").value = ""; + updateComposePgpUi(); + const mode = draft.compose_format || (draft.html_body ? "html" : "text"); + setComposeMode(mode); + const editor = $("#compose-editor"); + if (mode === "html" && draft.html_body) editor.innerHTML = hydrateComposeHtml(draft.html_body, state.composeAttachments, state.composeDraftId); + else editor.textContent = draft.text_body || ""; + renderComposeAttachments(); + state.composeDirty = sourceLabel === "Browser recovery"; + state.composeNeedsRemoteSync = sourceLabel === "Browser recovery"; + if (state.composeDirty) storeLocalDraftSnapshot(); else clearLocalDraftSnapshot(); + $("#compose-title").textContent = "Recovered draft"; + $("#compose-status").textContent = `${sourceLabel}${draft.updated_at ? ` · ${draft.updated_at}` : ""}`; + setTimeout(() => editor.focus(), 20); + } + + async function recoverDraft() { + if (state.composeDirty && draftHasContent()) { + const saved = await saveCurrentDraft(true, true); + if (!saved) return toast("Save the current draft before recovering another one", "error"); + } + const localDrafts = readLocalDraftSnapshots(); + let serverDrafts = []; + try { + const data = await request("/drafts", { method: "GET" }); + serverDrafts = data.drafts || []; + } catch (error) { + if (!localDrafts.length) return toast(error.message, "error"); + } + const options = []; + for (const local of localDrafts) { + const localSubject = local.subject || "(no subject)"; + options.push({ value: `local:${local.id}`, label: `Browser recovery — ${localSubject}` }); + } + for (const draft of serverDrafts.slice(0, 50)) { + const subject = draft.subject || "(no subject)"; + const recipient = draft.to || "no recipient"; + options.push({ value: `server:${draft.id}`, label: `${subject} — ${recipient}` }); + } + if (!options.length) return toast("No recoverable drafts found", "info"); + const selected = await appSelect("Recover draft", options, { value: options[0].value, confirmLabel: "Recover" }); + if (!selected) return; + if (selected.startsWith("local:")) { + const id = selected.slice("local:".length); + const local = localDrafts.find((draft) => draft.id === id); + if (!local) return toast("Browser recovery snapshot is no longer available", "error"); + applyRecoveredDraft(local, "Browser recovery"); + toast("Recovered unsaved browser draft", "success"); + return; + } + const id = selected.slice("server:".length); + try { + const draft = await request(`/drafts/${encodeURIComponent(id)}`, { method: "GET" }); + applyRecoveredDraft(draft, "Saved draft"); + toast("Draft recovered", "success"); + } catch (error) { toast(error.message, "error"); } + } + + async function closeComposer() { + clearTimeout(state.draftSaveTimer); + clearTimeout(state.inlineImageCleanupTimer); + await cleanupUnusedInlineAttachments(); + if (state.composeUploading) return toast("Wait for the attachment upload to finish", "warning"); + if (state.composeNeedsRemoteSync && draftHasContent()) { + const saved = await saveCurrentDraft(true, true, true); + if (!saved) toast("Draft kept in this browser for recovery", "warning", 5200); + } else if (state.composeDirty && draftHasContent()) { + const saved = await saveCurrentDraft(true, true); + if (!saved) toast("Draft kept in this browser for recovery", "warning", 5200); + } + if ($("#compose-dialog").open) $("#compose-dialog").close(); + state.composeSignatureGeneration += 1; + } + + function attachmentReminderNeeded() { + if (state.prefs.attachmentReminder === false) return false; + if (state.composeAttachments.some((attachment) => !state.inlineAttachmentIds.has(String(attachment.id)))) return false; + const text = `${$("#compose-subject").value} ${$("#compose-editor").innerText}`.toLowerCase(); + return /\b(attach(ed|ment|ments)?|załącz(am|nik|niki|niku|one|ony)?|zalacz(am|nik|niki|niku|one|ony)?)\b/i.test(text); + } + + async function deleteComposeAttachment(id) { + if (!state.composeDraftId) return; + try { + await request(`/drafts/${encodeURIComponent(state.composeDraftId)}/attachments/${encodeURIComponent(id)}`, { method: "DELETE" }); + state.composeAttachments = state.composeAttachments.filter((a) => a.id !== id); + state.inlineAttachmentIds.delete(String(id)); + if (state.composeSelectedImage?.dataset?.rustboxCid === String(id)) setSelectedInlineImage(null); + $("#compose-editor")?.querySelectorAll("img[data-rustbox-cid]").forEach((image) => { + if (image.dataset.rustboxCid === id) image.remove(); + }); + renderComposeAttachments(); + scheduleDraftSave(); + } catch (error) { toast(error.message, "error"); } + } + + function selectIdentityForMessage(detail) { + const select = $("#compose-from"); + if (!select || !detail || !state.identities.length) return; + const addresses = new Set(); + for (const entry of [...(detail.to_addresses || []), ...(detail.cc_addresses || [])]) { + if (entry?.address) addresses.add(String(entry.address).trim().toLowerCase()); + } + const raw = String(detail.raw_headers || ""); + for (const match of raw.matchAll(/^(?:delivered-to|envelope-to):\s*(.+)$/gim)) { + const value = match[1] || ""; + const email = value.match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i)?.[0]; + if (email) addresses.add(email.toLowerCase()); + } + const identity = state.identities.find((candidate) => addresses.has(String(candidate.email || "").trim().toLowerCase())); + if (identity) select.value = String(identity.id); + } + + function setComposeFullscreen(enabled, persist = true) { + const dialog = $("#compose-dialog"); + const button = $("#compose-fullscreen"); + if (!dialog || !button) return; + dialog.classList.toggle("compose-fullscreen", Boolean(enabled)); + button.setAttribute("aria-pressed", enabled ? "true" : "false"); + button.title = enabled ? "Exit full screen" : "Full screen"; + button.textContent = enabled ? "↙" : "⛶"; + if (persist) { + try { localStorage.setItem("rustbox.composeFullscreen", enabled ? "1" : "0"); } catch (_) { } + } + } + + function toggleComposeFullscreen() { + setComposeFullscreen(!$("#compose-dialog").classList.contains("compose-fullscreen")); + } + + function openCompose(mode = "new") { + if (mode === "forward" && state.prefs.forwardMode === "attachment") { + void forwardAsAttachment(); + return; + } + const detail = state.currentDetail; + clearTimeout(state.draftSaveTimer); + $("#compose-form").reset(); + renderComposeIdentities(); + if (detail && ["reply", "reply-all", "forward"].includes(mode)) selectIdentityForMessage(detail); + state.composeDraftId = null; + state.composeAttachments = []; + state.composeSignatureGeneration += 1; + state.inlineAttachmentIds = new Set(); + setSelectedInlineImage(null); + state.composeDirty = false; + state.composeNeedsRemoteSync = false; + state.composeReplyMeta = null; + setComposeUploadProgress(false); + renderComposeAttachments(); + $("#compose-error").classList.add("hidden"); + $("#compose-status").textContent = ""; + $("#compose-title").textContent = mode === "reply" ? "Reply" : mode === "reply-all" ? "Reply all" : mode === "forward" ? "Forward" : "New message"; + const editor = $("#compose-editor"); + editor.replaceChildren(); + editor.spellcheck = state.prefs.spellcheckEnabled !== false; + setComposeMode(state.prefs.composeFormat || "html"); + $("#compose-priority").value = ["high", "normal", "low"].includes(state.prefs.defaultPriority) ? state.prefs.defaultPriority : "normal"; + $("#compose-mdn").checked = Boolean(state.prefs.requestMdnDefault); + state.pgpRecipientStatus = []; + $("#compose-pgp-passphrase").value = ""; + updateComposePgpUi(true); + + if (detail && (mode === "reply" || mode === "reply-all")) { + state.composeReplyMeta = { inReplyTo: detail.message_id || null, references: buildReferences(detail) }; + $("#compose-to").value = detail.from_address || ""; + if (mode === "reply-all") { + const own = ownAddressSet(); + const cc = [...(detail.to_addresses || []), ...(detail.cc_addresses || [])] + .map((entry) => entry.address || "") + .filter((address) => address && address.toLowerCase() !== String(detail.from_address || "").toLowerCase() && !own.has(address.toLowerCase())); + $("#compose-cc").value = [...new Set(cc)].join(", "); + } + $("#compose-subject").value = /^re:/i.test(detail.subject || "") ? detail.subject : `Re: ${detail.subject || ""}`; + const quoteStyle = state.prefs.quoteStyle || "standard"; + const quote = quoteStyle === "none" ? null : el("div", "compose-quote"); + if (quote) { + const compact = quoteStyle === "compact"; + if ($("#compose-mode").value === "html") { + const attribution = compact + ? `${escapeHtml(detail.from_address || "")} · ${escapeHtml(formatDate(detail.date, true))}` + : `On ${escapeHtml(formatDate(detail.date, true))}, ${escapeHtml(detail.from_address || "")} wrote:`; + quote.innerHTML = `
    ${attribution}
    ${quoteTextAsHtml(detail.text || "")}
    `; + } else { + const quoted = cleanPlainMessageText(detail.text || "").split("\n").map((line) => `> ${line}`).join("\n"); + const attribution = compact + ? `${detail.from_address || ""} · ${formatDate(detail.date, true)}` + : `On ${formatDate(detail.date, true)}, ${detail.from_address || ""} wrote:`; + quote.textContent = `\n\n${attribution}\n${quoted}`; + } + } + const entry = el("div", "compose-reply-entry"); + entry.innerHTML = "
    "; + if (state.prefs.replyPosition === "below" && quote) { editor.append(quote, entry); } + else { editor.append(entry); if (quote) editor.append(quote); } + } + if (detail && mode === "forward") { + $("#compose-subject").value = /^fwd:/i.test(detail.subject || "") ? detail.subject : `Fwd: ${detail.subject || ""}`; + const quote = el("div", "compose-quote"); + if ($("#compose-mode").value === "html") { + quote.innerHTML = `
    Forwarded message
    From: ${escapeHtml(detail.from_address || "")}
    Date: ${escapeHtml(formatDate(detail.date, true))}
    Subject: ${escapeHtml(detail.subject || "")}

    ${quoteTextAsHtml(detail.text || "")}
    `; + } else { + quote.textContent = `\n\nForwarded message\nFrom: ${detail.from_address || ""}\nDate: ${formatDate(detail.date, true)}\nSubject: ${detail.subject || ""}\n\n${cleanPlainMessageText(detail.text || "")}`; + } + editor.append(quote); + } + insertIdentitySignature(editor, mode); + let fullScreen = $("#compose-dialog").classList.contains("compose-fullscreen"); + try { fullScreen = localStorage.getItem("rustbox.composeFullscreen") === "1"; } catch (_) { } + setComposeFullscreen(fullScreen, false); + $("#compose-dialog").showModal(); + setTimeout(() => (mode === "new" ? $("#compose-to") : editor).focus(), 30); + } + + function buildReferences(detail) { + if (!detail) return null; + const values = [detail.references, detail.message_id].filter(Boolean); + return values.length ? values.join(" ") : null; + } + + async function sendMessage(event) { + event.preventDefault(); + if (state.composeUploading) return toast("Wait for the attachment upload to finish", "warning"); + clearTimeout(state.inlineImageCleanupTimer); + await cleanupUnusedInlineAttachments(); + const send = $("#send-btn"); + const errorBox = $("#compose-error"); + errorBox.classList.add("hidden"); + if (attachmentReminderNeeded() && !await appConfirm("The message mentions an attachment but no file is attached. Send anyway?", { title: "Missing attachment", confirmLabel: "Send anyway" })) return; + const pgpMode = $("#compose-pgp-mode").value || "none"; + const pgpInline = Boolean($("#compose-pgp-inline").checked); + if (pgpInline && state.composeAttachments.length) return toast("Inline OpenPGP cannot carry attachments. Disable inline mode to use PGP/MIME.", "warning"); + if (pgpInline && $("#compose-mode").value !== "text" && !await appConfirm("Inline OpenPGP sends the plain-text representation and drops HTML/Markdown formatting. Continue?", { title: "Inline OpenPGP", confirmLabel: "Continue" })) return; + if (pgpMode === "encrypt" || pgpMode === "sign-encrypt") { + const statuses = await checkComposePgpRecipients(false); + const missing = statuses.filter((item) => !item.hasKey); + const untrusted = statuses.filter((item) => item.hasKey && item.trust === "untrusted"); + const notTrusted = state.pgpPrefs.requireTrustedEncryption + ? statuses.filter((item) => item.hasKey && !["trusted", "verified"].includes(item.trust)) + : []; + if (missing.length) return toast(`Missing OpenPGP key for ${missing.map((item) => item.email).join(", ")}`, "warning"); + if (untrusted.length) return toast(`OpenPGP key marked untrusted for ${untrusted.map((item) => item.email).join(", ")}`, "warning"); + if (notTrusted.length) return toast(`OpenPGP policy requires trusted keys: ${notTrusted.map((item) => item.email).join(", ")}`, "warning"); + } + if ($("#compose-pgp-attach-recipient-keys").checked) { + const visibleRecipients = composePgpVisibleRecipients(); + if (!visibleRecipients.length) return toast("Add at least one To/Cc recipient before attaching recipient OpenPGP keys", "warning"); + const statuses = await checkComposePgpRecipients(false, visibleRecipients); + if (!statuses.some((item) => item.hasKey)) return toast("No OpenPGP public key is available for any To/Cc recipient", "warning"); + } + setBusy(send, true); + const editor = $("#compose-editor"); + try { + await request("/messages/send", { + method: "POST", + body: JSON.stringify({ + to: parseRecipients($("#compose-to").value), + cc: parseRecipients($("#compose-cc").value), + bcc: parseRecipients($("#compose-bcc").value), + subject: $("#compose-subject").value, + text_body: editor.innerText, + html_body: serializeComposeHtml(editor), + in_reply_to: state.composeReplyMeta?.inReplyTo || null, + references: state.composeReplyMeta?.references || null, + draft_id: state.composeDraftId, + from_identity_id: $("#compose-from").value ? Number($("#compose-from").value) : null, + request_mdn: Boolean($("#compose-mdn").checked), + priority: $("#compose-priority").value || "normal", + pgp_mode: $("#compose-pgp-mode").value || "none", + pgp_inline: Boolean($("#compose-pgp-inline").checked), + pgp_passphrase: $("#compose-pgp-passphrase").value || "", + pgp_attach_public_key: Boolean($("#compose-pgp-attach-key").checked), + pgp_attach_recipient_keys: Boolean($("#compose-pgp-attach-recipient-keys").checked), + }), + }); + $("#compose-dialog").close(); + clearLocalDraftSnapshot(state.composeDraftId); + state.composeDraftId = null; + state.composeAttachments = []; + state.inlineAttachmentIds = new Set(); + setSelectedInlineImage(null); + state.composeReplyMeta = null; + state.composeDirty = false; + state.composeNeedsRemoteSync = false; + state.pgpRecipientStatus = []; + $("#compose-pgp-passphrase").value = ""; + toast("Message sent", "success"); + await loadFolders(); + } catch (error) { + state.composeDirty = true; + state.composeNeedsRemoteSync = true; + storeLocalDraftSnapshot(); + await saveCurrentDraft(true, true, true); + errorBox.textContent = error.message; + errorBox.classList.remove("hidden"); + } finally { setBusy(send, false); } + } + + async function updateFlag(flag, add) { + if (!state.currentDetail) return; + const detail = state.currentDetail; + await request(`/messages/${encodeURIComponent(detail.folder)}/${detail.uid}/flags`, { + method: "PATCH", + body: JSON.stringify({ flags: [flag], add }), + }); + const flags = new Set(detail.flags || []); + add ? flags.add(flag) : flags.delete(flag); + detail.flags = [...flags]; + const item = state.messages.find((msg) => msg.uid === detail.uid && (msg.folder || state.folder) === detail.folder); + if (item) item.flags = detail.flags.join(" "); + renderMessages(); + renderMessageDetail(detail); + } + + async function toggleUnread() { + if (!state.currentDetail) return; + const seen = (state.currentDetail.flags || []).includes("\\Seen"); + try { await updateFlag("\\Seen", !seen); toast(seen ? "Marked unread" : "Marked read", "success"); } catch (error) { toast(error.message, "error"); } + } + + async function toggleFlagged() { + if (!state.currentDetail) return; + const flagged = (state.currentDetail.flags || []).includes("\\Flagged"); + try { await updateFlag("\\Flagged", !flagged); toast(flagged ? "Flag removed" : "Message flagged", "success"); } catch (error) { toast(error.message, "error"); } + } + + function moveUndoAction(result, label = "Undo") { + const undo = result?.undo_move; + if (!undo?.from_folder || !undo?.to_folder || !Number.isFinite(Number(undo.uid))) return null; + return { + label, + onClick: async () => { + await request("/messages/move", { + method: "POST", + body: JSON.stringify({ from_folder: undo.from_folder, to_folder: undo.to_folder, uid: Number(undo.uid) }), + }); + state.selected = null; + state.currentDetail = null; + state.selectedMessages.clear(); + closeReader(); + await refreshFolderListOnly(); + await refreshCurrentMessageView(); + toast("Move undone", "success"); + }, + }; + } + + function bulkMoveUndoAction(undoMoves) { + if (!Array.isArray(undoMoves) || !undoMoves.length) return null; + return { + label: "Undo", + onClick: async () => { + let restored = 0; + for (const undo of [...undoMoves].reverse()) { + await request("/messages/move", { + method: "POST", + body: JSON.stringify({ from_folder: undo.from_folder, to_folder: undo.to_folder, uid: Number(undo.uid) }), + }); + restored += 1; + } + state.selected = null; + state.currentDetail = null; + state.selectedMessages.clear(); + closeReader(); + await refreshFolderListOnly(); + await refreshCurrentMessageView(); + toast(`Undo completed: ${restored} message${restored === 1 ? "" : "s"}`, "success"); + }, + }; + } + + async function moveMessageByDrag(fromFolder, uid, targetFolder) { + if (!targetFolder || targetFolder === fromFolder) return; + const result = await request("/messages/move", { method: "POST", body: JSON.stringify({ from_folder: fromFolder, to_folder: targetFolder, uid }) }); + const wasCurrentFolder = state.folder === fromFolder; + if (wasCurrentFolder) { + state.messages = state.messages.filter((message) => !(message.uid === uid && (message.folder || state.folder) === fromFolder)); + state.selectedMessages.delete(`${fromFolder}\u0000${uid}`); + state.total = Math.max(0, state.total - 1); + if (state.currentDetail?.uid === uid && state.currentDetail?.folder === fromFolder) { + state.selected = null; + state.currentDetail = null; + closeReader(); + } + renderMessages(); + } + await refreshFolderListOnly(); + toast(`Moved to ${targetFolder}`, "success", 9000, moveUndoAction(result)); + } + + async function moveCurrentTo(targetFolder, successMessage = null) { + if (!state.currentDetail || !targetFolder || targetFolder === state.currentDetail.folder) return; + const detail = state.currentDetail; + try { + const result = await request("/messages/move", { + method: "POST", + body: JSON.stringify({ from_folder: detail.folder, to_folder: targetFolder, uid: detail.uid }), + }); + state.messages = state.messages.filter((msg) => !(msg.uid === detail.uid && (msg.folder || state.folder) === detail.folder)); + state.selectedMessages.delete(`${detail.folder}\u0000${detail.uid}`); + state.total = Math.max(0, state.total - 1); + state.selected = null; + state.currentDetail = null; + closeReader(); + renderMessages(); + await refreshFolderListOnly(); + toast(successMessage || `Moved to ${targetFolder}`, "success", 9000, moveUndoAction(result)); + } catch (error) { toast(error.message, "error"); } + } + + async function copyCurrentTo(targetFolder) { + if (!state.currentDetail || !targetFolder || targetFolder === state.currentDetail.folder) return; + const detail = state.currentDetail; + try { + await request("/messages/copy", { method: "POST", body: JSON.stringify({ from_folder: detail.folder, to_folder: targetFolder, uid: detail.uid }) }); + await refreshFolderListOnly(); + toast(`Copied to ${targetFolder}`); + } catch (error) { toast(error.message, "error"); } + } + + const SPAM_DEFAULT_FOLDER_KEY = "rustbox.defaultSpamFolder"; + + function configuredBrowserSpamFolder() { + const preferred = (localStorage.getItem(SPAM_DEFAULT_FOLDER_KEY) || "").trim(); + if (!preferred) return null; + const match = state.folders.find((folder) => String(folder.name || "").toLowerCase() === preferred.toLowerCase()); + return match?.name || null; + } + + function autoDetectSpecialFolder(role) { + const attr = { sent: "\\Sent", drafts: "\\Drafts", trash: "\\Trash", junk: "\\Junk", archive: "\\Archive" }[role]; + if (attr) { + const byAttr = state.folders.find((folder) => (folder.attributes || []).some((value) => String(value).toLowerCase() === attr.toLowerCase())); + if (byAttr) return byAttr.name; + } + const names = { sent: ["sent"], drafts: ["draft"], trash: ["trash", "deleted"], junk: ["junk", "spam"], archive: ["archive"] }[role] || []; + return state.folders.find((folder) => names.some((name) => String(folder.name || "").toLowerCase().includes(name)))?.name || null; + } + + function detectSpecialFolder(role) { + const configured = state.specialFolders?.[role]; + if (configured && state.folders.some((folder) => folder.name === configured)) return configured; + if (role === "junk") { + const browserDefault = configuredBrowserSpamFolder(); + if (browserDefault) return browserDefault; + } + return autoDetectSpecialFolder(role); + } + + function isJunkFolder(folder) { + const junk = detectSpecialFolder("junk"); + return Boolean(junk && String(folder || "").toLowerCase() === junk.toLowerCase()); + } + + function inboxFolder() { + return state.folders.find((folder) => String(folder.name || "").toUpperCase() === "INBOX")?.name || "INBOX"; + } + + async function archiveCurrent() { + const archive = detectSpecialFolder("archive"); + if (!archive) return toast("Configure an Archive folder in Settings → Folders", "error"); + await moveCurrentTo(archive, "Message archived"); + } + + async function junkCurrent() { + if (!state.currentDetail) return; + const junk = detectSpecialFolder("junk"); + if (!junk) return toast("Configure a Junk folder in Settings → Folders", "error"); + if (isJunkFolder(state.currentDetail.folder)) { + await moveCurrentTo(inboxFolder(), tr("marked_not_spam", "Marked as not spam")); + } else { + await moveCurrentTo(junk, tr("marked_spam", "Marked as spam")); + } + } + + async function deleteCurrent() { + if (!state.selected) return; + const { folder, uid } = state.selected; + const trash = detectSpecialFolder("trash"); + if (trash && folder !== trash) { + await moveCurrentTo(trash); + return; + } + if (!await appConfirm("Permanently delete this message?", { title: "Delete message permanently", confirmLabel: "Delete permanently", danger: true })) return; + try { + await request(`/messages/${encodeURIComponent(folder)}/${uid}`, { method: "DELETE" }); + state.messages = state.messages.filter((msg) => !(msg.uid === uid && (msg.folder || state.folder) === folder)); + state.selectedMessages.delete(`${folder}\u0000${uid}`); + state.total = Math.max(0, state.total - 1); + state.selected = null; + state.currentDetail = null; + closeReader(); + renderMessages(); + await refreshFolderListOnly(); + toast("Message deleted", "success"); + } catch (error) { toast(error.message, "error"); } + } + + async function performBulk(action, targetFolder = null) { + const items = selectedMessages(); + if (!items.length) return; + const bulkJunkToInbox = action === "junk" && items.every((message) => isJunkFolder(message.folder || state.folder)); + if (action === "archive" && !detectSpecialFolder("archive")) { + toast("Configure an Archive folder in Settings → Folders", "error"); + return; + } + if (action === "junk" && !bulkJunkToInbox && !detectSpecialFolder("junk")) { + toast("Configure a Junk folder in Settings → Folders", "error"); + return; + } + const permanentDelete = action === "delete" && items.every((message) => (message.folder || state.folder) === detectSpecialFolder("trash")); + if (permanentDelete && !await appConfirm(`Permanently delete ${items.length} selected messages?`, { title: "Delete selected messages", confirmLabel: "Delete permanently", danger: true })) return; + let completed = 0; + const undoMoves = []; + let movedOperations = 0; + try { + for (const message of items) { + const folder = message.folder || state.folder; + if (action === "read" || action === "unread" || action === "flag" || action === "unflag") { + const flag = action === "read" || action === "unread" ? "\\Seen" : "\\Flagged"; + const add = action === "read" || action === "flag"; + await request(`/messages/${encodeURIComponent(folder)}/${message.uid}/flags`, { method: "PATCH", body: JSON.stringify({ flags: [flag], add }) }); + } else if (action === "move" || action === "archive" || action === "junk" || action === "delete") { + let destination = targetFolder; + if (action === "archive") destination = detectSpecialFolder("archive"); + if (action === "junk") destination = bulkJunkToInbox ? inboxFolder() : detectSpecialFolder("junk"); + if (action === "delete") destination = detectSpecialFolder("trash"); + if (destination && destination !== folder) { + movedOperations += 1; + const result = await request("/messages/move", { method: "POST", body: JSON.stringify({ from_folder: folder, to_folder: destination, uid: message.uid }) }); + if (result?.undo_move) undoMoves.push(result.undo_move); + } else if (action === "delete") { + await request(`/messages/${encodeURIComponent(folder)}/${message.uid}`, { method: "DELETE" }); + } + } else if (action === "copy" && targetFolder && targetFolder !== folder) { + await request("/messages/copy", { method: "POST", body: JSON.stringify({ from_folder: folder, to_folder: targetFolder, uid: message.uid }) }); + } + completed += 1; + $("#bulk-count").textContent = `${completed}/${items.length}`; + } + state.selectedMessages.clear(); + state.searchAllResults = null; + await refreshFolderListOnly(); + await refreshCurrentMessageView(); + const bulkMessages = { junk: bulkJunkToInbox ? tr("marked_not_spam", "Marked as not spam") : tr("marked_spam", "Marked as spam"), archive: "Archived", read: "Marked read", unread: "Marked unread", flag: "Flagged", unflag: "Unflagged", delete: "Deleted", move: "Moved", copy: "Copied" }; + const undoAction = movedOperations > 0 && undoMoves.length === movedOperations ? bulkMoveUndoAction(undoMoves) : null; + toast(`${bulkMessages[action] || "Updated"}: ${completed} message${completed === 1 ? "" : "s"}`, "success", undoAction ? 10000 : null, undoAction); + } catch (error) { + toast(`${completed}/${items.length} completed: ${error.message}`, "error"); + state.searchAllResults = null; + await refreshCurrentMessageView(); + } + } + + async function refreshFolderListOnly() { + try { + const data = await request("/folders", { method: "GET" }); + state.folders = data.folders || []; + updateUnreadAppBadge(); + renderFolders(); + } catch (_) { } + } + + async function refreshCurrentMessageView() { + if (state.searchContext) { + await runSearch(new URLSearchParams(state.searchContext.params), state.searchContext.label, { restoreScrollTop: state.searchContext.scrollTop }); + } else { + await loadMessages(true); + } + } + + async function runSearch(params, label = "Search", options = {}) { + const cleanParams = new URLSearchParams(params.toString()); + cleanParams.delete("all"); + const queryString = cleanParams.toString(); + if (!queryString) return; + $("#folder-title").textContent = label; + $("#message-list").innerHTML = `
    Searching…
    `; + state.selectedMessages.clear(); + state.searchAllResults = null; + state.searchContext = { params: queryString, label, scrollTop: Number(options.restoreScrollTop) || 0 }; + saveActiveSearchContext(); + renderBulkToolbar(); + try { + const data = await request(`/search?${queryString}`, { method: "GET" }); + state.messages = data.results || []; + state.total = data.total_count || state.messages.length; + renderMessages(); + $("#folder-title").textContent = label; + $("#message-count").textContent = `${state.total} result${state.total === 1 ? "" : "s"}`; + const restoreScrollTop = Number(options.restoreScrollTop) || 0; + if (restoreScrollTop > 0) requestAnimationFrame(() => { $("#message-list").scrollTop = restoreScrollTop; }); + } catch (error) { toast(error.message, "error"); } + } + + async function searchMail(event) { + event.preventDefault(); + const query = $("#search-input").value.trim(); + if (!query) { + clearActiveSearchContext(); + return loadMessages(true); + } + const params = new URLSearchParams({ q: query, limit: "100" }); + await runSearch(params, `Search: ${query}`); + } + + function populateAdvancedSearchFolders() { + const select = $("#search-folder"); + const current = select.value; + select.replaceChildren(new Option("All folders", "")); + for (const folder of orderedFolders()) select.add(new Option(folder.name, folder.name)); + if ([...select.options].some((option) => option.value === current)) select.value = current; + } + + function buildAdvancedSearchParams() { + const params = new URLSearchParams(); + const set = (name, value) => { if (String(value || "").trim()) params.set(name, String(value).trim()); }; + set("q", $("#search-text").value); + set("folder", $("#search-folder").value); + set("from", $("#search-from").value); + set("to", $("#search-to").value); + set("subject", $("#search-subject").value); + const dateFrom = $("#search-date-from").value; + const dateTo = $("#search-date-to").value; + if (dateFrom) params.set("date_from", String(Math.floor(new Date(`${dateFrom}T00:00:00`).getTime() / 1000))); + if (dateTo) params.set("date_to", String(Math.floor(new Date(`${dateTo}T23:59:59`).getTime() / 1000))); + const read = $("#search-read").value; + if (read) params.set("unread", read === "unread" ? "true" : "false"); + set("flagged", $("#search-flagged").value); + set("has_attachment", $("#search-attachment").value); + params.set("sort", $("#search-sort").value || "date_desc"); + params.set("limit", "250"); + return params; + } + + function resetAdvancedSearch() { + $("#advanced-search-form").reset(); + $("#search-sort").value = "date_desc"; + populateAdvancedSearchFolders(); + } + + async function openAdvancedSearch() { + populateAdvancedSearchFolders(); + $("#advanced-search-dialog").showModal(); + await loadSavedSearches(); + } + + async function submitAdvancedSearch(event) { + event.preventDefault(); + const params = buildAdvancedSearchParams(); + const meaningful = [...params.keys()].some((key) => !["sort", "limit"].includes(key)); + if (!meaningful) return toast("Choose at least one search criterion", "error"); + $("#advanced-search-dialog").close(); + await runSearch(params, "Advanced search"); + } + + async function loadSavedSearches() { + try { + const data = await request("/search/saved", { method: "GET" }); + state.savedSearches = data.searches || []; + renderSavedSearches(); + } catch (error) { toast(error.message, "error"); } + } + + function renderSavedSearches() { + const list = $("#saved-search-list"); + list.replaceChildren(); + if (!state.savedSearches.length) { list.append(el("div", "empty-list", "No saved searches")); return; } + for (const saved of state.savedSearches) { + const row = el("div", "saved-search-item"); + const name = el("strong", "", saved.name); + const run = el("button", "button quiet", "Run"); run.type = "button"; + const remove = el("button", "button quiet danger", "Delete"); remove.type = "button"; + run.addEventListener("click", async () => { $("#advanced-search-dialog").close(); await runSearch(new URLSearchParams(saved.query_string), `Search: ${saved.name}`); }); + remove.addEventListener("click", async () => { + if (!await appConfirm(`Delete saved search “${saved.name}”?`, { title: "Delete saved search", confirmLabel: "Delete", danger: true })) return; + try { + await request(`/search/saved/${saved.id}`, { method: "DELETE" }); + await loadSavedSearches(); + toast("Saved search deleted", "success"); + } catch (error) { toast(error.message, "error"); } + }); + row.append(name, run, remove); list.append(row); + } + } + + async function saveAdvancedSearch() { + const params = buildAdvancedSearchParams(); + const meaningful = [...params.keys()].some((key) => !["sort", "limit"].includes(key)); + if (!meaningful) return toast("Choose at least one search criterion", "error"); + const name = await appPrompt("Save search", { message: "Name this search so you can run it again later.", placeholder: "Search name", confirmLabel: "Save" }); + if (!name?.trim()) return; + try { + await request("/search/saved", { method: "POST", body: JSON.stringify({ name: name.trim(), query_string: params.toString() }) }); + await loadSavedSearches(); + toast("Search saved"); + } catch (error) { toast(error.message, "error"); } + } + + function openFolderDialog() { + $("#current-folder-name").value = state.folder; + $("#new-folder-name").value = ""; + const current = state.folders.find((folder) => folder.name === state.folder); + $("#subscribe-folder-btn").textContent = current?.is_subscribed ? "Unsubscribe" : "Subscribe"; + $("#folder-dialog").showModal(); + } + + async function createFolder() { + const name = $("#new-folder-name").value.trim(); + if (!name) return; + try { + await request("/folders", { method: "POST", body: JSON.stringify({ name }) }); + $("#new-folder-name").value = ""; + await refreshFolderListOnly(); + toast("Folder created"); + } catch (error) { toast(error.message, "error"); } + } + + async function renameFolder() { + const current = state.folder; + const next = await appPrompt("Rename folder", { value: current, confirmLabel: "Rename", required: true }); + if (!next || next === current) return; + try { + await request(`/folders/${encodeURIComponent(current)}`, { method: "PATCH", body: JSON.stringify({ new_name: next }) }); + state.folder = next; + $("#current-folder-name").value = next; + await loadFolders(); + toast("Folder renamed"); + } catch (error) { toast(error.message, "error"); } + } + + async function deleteFolder() { + const current = state.folder; + if (!await appConfirm(`Delete folder “${current}”?`, { title: "Delete folder", confirmLabel: "Delete", danger: true })) return; + try { + await request(`/folders/${encodeURIComponent(current)}`, { method: "DELETE" }); + $("#folder-dialog").close(); + state.folder = "INBOX"; + await loadFolders(); + toast("Folder deleted"); + } catch (error) { toast(error.message, "error"); } + } + + function hideContextMenu() { + const menu = $("#context-menu"); + menu.classList.add("hidden"); + menu.replaceChildren(); + } + + function showContextMenu(x, y, items) { + const menu = $("#context-menu"); + menu.replaceChildren(); + for (const item of items) { + if (item === "separator") { menu.append(el("div", "separator")); continue; } + const button = el("button", item.danger ? "danger" : "", item.label); + button.type = "button"; + button.addEventListener("click", async () => { hideContextMenu(); await item.action(); }); + menu.append(button); + } + menu.classList.remove("hidden"); + const rect = menu.getBoundingClientRect(); + menu.style.left = `${Math.max(6, Math.min(x, innerWidth - rect.width - 6))}px`; + menu.style.top = `${Math.max(6, Math.min(y, innerHeight - rect.height - 6))}px`; + } + + async function ensureMessageOpen(message) { + const folder = message.folder || state.folder; + if (!state.currentDetail || state.currentDetail.uid !== message.uid || state.currentDetail.folder !== folder) { + await openMessage(folder, message.uid); + } + } + + async function promptFolder(label, exclude = null) { + const options = orderedFolders().map((folder) => folder.name).filter((name) => name !== exclude); + if (!options.length) return null; + return appSelect(label, options.map((name) => ({ value: name, label: name })), { value: options[0], confirmLabel: "Choose folder" }); + } + + function openMessageContextMenu(x, y, message) { + const flags = Array.isArray(message.flags) ? message.flags.join(" ") : String(message.flags || ""); + const seen = flags.includes("\\Seen"); + const flagged = flags.includes("\\Flagged"); + showContextMenu(x, y, [ + { label: "Open", action: () => openMessage(message.folder || state.folder, message.uid) }, + { label: "Reply", action: async () => { await ensureMessageOpen(message); openCompose("reply"); } }, + { label: "Reply all", action: async () => { await ensureMessageOpen(message); openCompose("reply-all"); } }, + { label: "Forward", action: async () => { await ensureMessageOpen(message); openCompose("forward"); } }, + { label: "Forward as attachment", action: async () => { await ensureMessageOpen(message); await forwardAsAttachment(); } }, + "separator", + { label: seen ? "Mark unread" : "Mark read", action: async () => { await ensureMessageOpen(message); await updateFlag("\\Seen", !seen); } }, + { label: flagged ? "Unflag" : "Flag", action: async () => { await ensureMessageOpen(message); await updateFlag("\\Flagged", !flagged); } }, + "separator", + { label: "Archive", action: async () => { await ensureMessageOpen(message); await archiveCurrent(); } }, + { label: "Move to…", action: async () => { await ensureMessageOpen(message); const target = await promptFolder("Move to folder", message.folder || state.folder); if (target) await moveCurrentTo(target); } }, + { label: "Copy to…", action: async () => { await ensureMessageOpen(message); const target = await promptFolder("Copy to folder", message.folder || state.folder); if (target) await copyCurrentTo(target); } }, + { label: "Add label…", action: async () => { const tag = await promptTag("Add label"); if (tag) await addTagToMessage(message, tag); } }, + { label: "Remove label…", action: async () => { const attached = state.tags.filter((tag) => (message.tags || []).some((item) => item.id === tag.id)); const tag = await promptTag("Remove label", attached); if (tag) await removeTagFromMessage(message, tag); } }, + { label: isJunkFolder(message.folder || state.folder) ? tr("mark_not_spam", "Mark as not spam") : tr("mark_spam", "Mark as spam"), action: async () => { await ensureMessageOpen(message); await junkCurrent(); } }, + { label: "Create filter from sender", action: async () => { await ensureMessageOpen(message); await createFilterFromCurrentMessage("sender"); } }, + { label: "Create filter from domain", action: async () => { await ensureMessageOpen(message); await createFilterFromCurrentMessage("domain"); } }, + { label: "Create filter from subject", action: async () => { await ensureMessageOpen(message); await createFilterFromCurrentMessage("subject"); } }, + { label: "Add sender to contacts", action: async () => { await ensureMessageOpen(message); await addSenderToContacts(); } }, + { label: "Search messages from sender", action: async () => { await ensureMessageOpen(message); await searchFromCurrentSender(); } }, + { label: "Unsubscribe from mailing list", action: async () => { await ensureMessageOpen(message); await openListUnsubscribe(); } }, + "separator", + { label: "Show headers", action: async () => { await ensureMessageOpen(message); showHeaders(); } }, + { label: "Show source", action: async () => { await ensureMessageOpen(message); await showMessageSource(); } }, + { label: "Copy Message-ID", action: async () => { await ensureMessageOpen(message); await copyMessageId(); } }, + { label: "Copy sender address", action: async () => { await ensureMessageOpen(message); await copySenderAddress(); } }, + { label: "Download .eml", action: async () => { await ensureMessageOpen(message); downloadEml(); } }, + { label: "Print", action: async () => { await ensureMessageOpen(message); printCurrentMessage(); } }, + { label: "Delete", danger: true, action: async () => { await ensureMessageOpen(message); await deleteCurrent(); } }, + ]); + } + + async function configureFolderFlaggedOnTop(folder) { + try { + const current = await request(`/folders/${encodeURIComponent(folder.name)}/preferences`, { method: "GET" }); + const value = current.flagged_on_top === true ? "on" : current.flagged_on_top === false ? "off" : "inherit"; + const choice = await appSelect("Flagged messages on top", [ + { value: "inherit", label: `Use mailbox default (${current.effective_flagged_on_top ? "on" : "off"})` }, + { value: "on", label: "Always on for this folder" }, + { value: "off", label: "Always off for this folder" }, + ], { message: `Folder: ${folder.name}`, value, confirmLabel: "Save" }); + if (choice === null) return; + const flagged_on_top = choice === "inherit" ? null : choice === "on"; + await request(`/folders/${encodeURIComponent(folder.name)}/preferences`, { method: "PUT", body: JSON.stringify({ flagged_on_top }) }); + if (state.folder === folder.name && !state.searchContext) await loadMessages(true); + toast("Folder list preference saved", "success"); + } catch (error) { toast(error.message, "error"); } + } + + function openFolderContextMenu(x, y, folder) { + const subscribed = Boolean(folder.is_subscribed); + showContextMenu(x, y, [ + { label: "Open", action: async () => { state.folder = folder.name; renderFolders(); await loadMessages(true); } }, + { + label: "Create subfolder", action: async () => { + const delimiter = folder.delimiter || "/"; + const child = await appPrompt("Create subfolder", { message: `Parent folder: ${folder.name}`, placeholder: "Subfolder name", confirmLabel: "Create", required: true }); + if (!child) return; + try { + await request("/folders", { method: "POST", body: JSON.stringify({ name: `${folder.name}${delimiter}${child}` }) }); + await refreshFolderListOnly(); + toast("Subfolder created", "success"); + } catch (error) { toast(error.message, "error"); } + } + }, + { label: subscribed ? "Unsubscribe" : "Subscribe", action: () => setFolderSubscription(folder.name, !subscribed) }, + { label: "Flagged on top…", action: () => configureFolderFlaggedOnTop(folder) }, + { label: "Mark all read", action: () => markFolderAllRead(folder.name) }, + { label: "Import .eml…", action: () => importEmlToFolder(folder.name) }, + { label: "Empty folder", danger: true, action: () => emptyFolder(folder.name) }, + "separator", + { label: "Properties", action: () => showFolderProperties(folder) }, + { label: "Rename", action: async () => { state.folder = folder.name; await renameFolder(); } }, + { label: "Delete", danger: true, action: async () => { state.folder = folder.name; await deleteFolder(); } }, + ]); + } + + function importEmlToFolder(folderName) { + const input = document.createElement("input"); + input.type = "file"; + input.accept = ".eml,message/rfc822"; + input.multiple = false; + input.addEventListener("change", async () => { + const file = input.files?.[0]; + if (!file) return; + if (file.size > 50 * 1024 * 1024) return toast(".eml exceeds the 50 MB import limit", "error"); + const form = new FormData(); + form.append("file", file, file.name || "message.eml"); + try { + await request(`/folders/${encodeURIComponent(folderName)}/import-eml`, { method: "POST", body: form }); + if (state.folder === folderName) await loadMessages(true); + await refreshFolderListOnly(); + toast(`Imported ${file.name || "message.eml"}`); + } catch (error) { toast(error.message, "error"); } + }, { once: true }); + input.click(); + } + + function showFolderProperties(folder) { + const roles = Object.entries(state.specialFolders || {}).filter(([, value]) => value === folder.name).map(([key]) => key); + const dialog = document.createElement("dialog"); + dialog.className = "dialog"; + const card = el("div", "dialog-card folder-properties-card"); + const header = el("header", "dialog-header"); + header.append(el("strong", "", "Folder properties")); + const close = el("button", "button quiet", "Close"); close.type = "button"; close.addEventListener("click", () => dialog.close()); + header.append(close); + const body = el("div", "folder-properties-list"); + const rows = [ + ["Name", folder.name || ""], + ["Subscribed", folder.is_subscribed ? "Yes" : "No"], + ["Messages", String(folder.total_count ?? folder.exists ?? 0)], + ["Unread", String(folder.unread_count ?? 0)], + ["Delimiter", folder.delimiter || "(none)"], + ["Special role", roles.length ? roles.join(", ") : "None"], + ["Attributes", Array.isArray(folder.attributes) ? folder.attributes.join(" ") : String(folder.attributes || "None")], + ]; + for (const [label, value] of rows) { const row = el("div", "folder-property-row"); row.append(el("span", "muted", label), el("strong", "", value)); body.append(row); } + card.append(header, body); dialog.append(card); document.body.append(dialog); + dialog.addEventListener("close", () => dialog.remove(), { once: true }); + dialog.showModal(); + } + + async function markFolderAllRead(name) { + try { + await request(`/folders/${encodeURIComponent(name)}/mark-all-read`, { method: "POST", body: "{}" }); + if (state.folder === name) await loadMessages(true); + await refreshFolderListOnly(); + toast("All messages marked read"); + } catch (error) { toast(error.message, "error"); } + } + + async function emptyFolder(name) { + if (!await appConfirm(`Permanently remove every message from “${name}”?`, { title: "Empty folder", confirmLabel: "Empty folder", danger: true })) return; + try { + await request(`/folders/${encodeURIComponent(name)}/empty`, { method: "POST", body: "{}" }); + if (state.folder === name) { state.selected = null; state.currentDetail = null; closeReader(); await loadMessages(true); } + await refreshFolderListOnly(); + toast("Folder emptied"); + } catch (error) { toast(error.message, "error"); } + } + + async function setFolderSubscription(name, subscribed) { + try { + await request(`/folders/${encodeURIComponent(name)}/subscribe`, { method: "PATCH", body: JSON.stringify({ subscribed }) }); + await refreshFolderListOnly(); + toast(subscribed ? "Folder subscribed" : "Folder unsubscribed"); + } catch (error) { toast(error.message, "error"); } + } + + async function toggleCurrentFolderSubscription() { + const folder = state.folders.find((item) => item.name === state.folder); + if (!folder) return; + await setFolderSubscription(folder.name, !folder.is_subscribed); + } + + function rawHeaderValue(name, raw = state.currentDetail?.raw_headers || "") { + if (!raw) return ""; + const unfolded = raw.replace(/\r?\n[ \t]+/g, " "); + const prefix = `${String(name).toLowerCase()}:`; + const line = unfolded.split(/\r?\n/).find((item) => item.toLowerCase().startsWith(prefix)); + return line ? line.slice(line.indexOf(":") + 1).trim() : ""; + } + + async function openListUnsubscribe() { + const detail = state.currentDetail; + if (!detail) return; + const value = rawHeaderValue("List-Unsubscribe"); + if (!value) return toast("This message does not provide List-Unsubscribe", "error"); + const targets = [...value.matchAll(/<([^>]+)>/g)].map((match) => match[1].trim()).filter(Boolean); + const httpsTarget = targets.find((item) => /^https:\/\//i.test(item)); + const mailtoTarget = targets.find((item) => /^mailto:/i.test(item)); + const oneClick = rawHeaderValue("List-Unsubscribe-Post") + .split(",") + .some((item) => item.trim().toLowerCase() === "list-unsubscribe=one-click"); + + if (oneClick && httpsTarget) { + const confirmed = await appConfirm("Unsubscribe from this mailing list?", { + title: "One-click unsubscribe", + confirmLabel: "Unsubscribe", + danger: true, + }); + if (!confirmed) return; + try { + await request(`/messages/${encodeURIComponent(detail.folder)}/${detail.uid}/unsubscribe`, { method: "POST" }); + toast("Unsubscribe request sent", "success"); + } catch (error) { + toast(error.message, "error"); + } + return; + } + + const target = httpsTarget || mailtoTarget; + if (!target) return toast("No supported HTTPS or mailto unsubscribe target", "error"); + if (/^https:\/\//i.test(target)) { + const confirmed = await appConfirm("This mailing list does not support one-click unsubscribe. Open its external unsubscribe page?", { + title: "Open unsubscribe page", + confirmLabel: "Open page", + }); + if (confirmed) window.open(target, "_blank", "noopener,noreferrer"); + } else { + window.location.href = target; + } + } + + function showHeaders() { + const detail = state.currentDetail; + if (!detail) return; + const summary = $("#headers-summary"); + summary.replaceChildren(); + const to = (detail.to_addresses || []).map((entry) => entry.name ? `${entry.name} <${entry.address}>` : entry.address).join(", "); + const cc = (detail.cc_addresses || []).map((entry) => entry.name ? `${entry.name} <${entry.address}>` : entry.address).join(", "); + const fields = [ + ["From", detail.from_name ? `${detail.from_name} <${detail.from_address}>` : detail.from_address], + ["To", to], ["Cc", cc], ["Date", detail.date ? formatDate(detail.date, true) : rawHeaderValue("Date")], + ["Message-ID", detail.message_id || rawHeaderValue("Message-ID")], + ["In-Reply-To", detail.in_reply_to || rawHeaderValue("In-Reply-To")], + ["References", detail.references || rawHeaderValue("References")], + ["Return-Path", rawHeaderValue("Return-Path")], ["Delivered-To", rawHeaderValue("Delivered-To")], + ["List-ID", rawHeaderValue("List-ID")], ["List-Unsubscribe", rawHeaderValue("List-Unsubscribe")], + ["List-Unsubscribe-Post", rawHeaderValue("List-Unsubscribe-Post")], + ["User-Agent", rawHeaderValue("User-Agent") || rawHeaderValue("X-Mailer")], + ["Authentication-Results", rawHeaderValue("Authentication-Results")], ["Content-Type", rawHeaderValue("Content-Type")], + ]; + for (const [label, value] of fields) { + if (!String(value || "").trim()) continue; + const row = el("div", "header-summary-row"); + row.append(el("span", "muted", label), el("code", "", String(value))); + summary.append(row); + } + $("#headers-content").textContent = detail.raw_headers || "No raw headers available."; + $("#headers-dialog").showModal(); + } + + async function fetchEmlBlob(detail = state.currentDetail) { + if (!detail) throw new Error("No message selected"); + const headers = new Headers(); + if (state.accountId) headers.set("X-Active-Account", state.accountId); + const response = await fetch(apiPath(`/messages/${encodeURIComponent(detail.folder)}/${detail.uid}/source`), { credentials: "include", headers }); + if (!response.ok) throw new Error(`Message source failed (${response.status})`); + return response.blob(); + } + + async function showMessageSource() { + const detail = state.currentDetail; + if (!detail) return; + try { + const blob = await fetchEmlBlob(detail); + const source = await blob.text(); + const dialog = document.createElement("dialog"); + dialog.className = "dialog"; + const card = el("div", "dialog-card source-card"); + const header = el("header", "dialog-header"); + header.append(el("strong", "", "Message source")); + const close = el("button", "button quiet", "Close"); close.type = "button"; close.addEventListener("click", () => dialog.close()); + header.append(close); + const pre = el("pre", "message-source-content"); pre.textContent = source; + const actions = el("footer", "dialog-actions"); + const copy = el("button", "button quiet", "Copy source"); copy.type = "button"; + copy.addEventListener("click", async () => { try { await navigator.clipboard.writeText(source); toast("Message source copied"); } catch (_) { toast("Could not copy source", "error"); } }); + const download = el("button", "button primary", "Download .eml"); download.type = "button"; download.addEventListener("click", downloadEml); + actions.append(copy, download); + card.append(header, pre, actions); dialog.append(card); document.body.append(dialog); + dialog.addEventListener("close", () => dialog.remove(), { once: true }); + dialog.showModal(); + } catch (error) { toast(error.message, "error"); } + } + + function printCurrentMessage() { + const detail = state.currentDetail; + if (!detail) return toast("Select a message before printing", "warning"); + const to = (detail.to_addresses || []).map((entry) => entry.name ? `${entry.name} <${entry.address}>` : entry.address).join(", "); + const body = detail.html ? sanitizeMessageHtml(detail.html) : `
    ${escapeHtml(cleanPlainMessageText(detail.text || ""))}
    `; + const frame = document.createElement("iframe"); + frame.className = "print-frame"; + frame.setAttribute("aria-hidden", "true"); + const html = `${escapeHtml(detail.subject || "Message")}

    ${escapeHtml(detail.subject || "(no subject)")}

    From${escapeHtml(detail.from_name ? `${detail.from_name} <${detail.from_address}>` : detail.from_address || "")}
    To${escapeHtml(to)}
    Date${escapeHtml(formatDate(detail.date, true))}
    ${body}`; + frame.addEventListener("load", () => { + const cleanup = () => frame.remove(); + try { + frame.contentWindow?.addEventListener("afterprint", cleanup, { once: true }); + frame.contentWindow?.focus(); + frame.contentWindow?.print(); + toast("Print dialog opened", "info"); + setTimeout(cleanup, 60000); + } catch (_) { + cleanup(); + toast("Could not open the print dialog", "error"); + } + }, { once: true }); + frame.srcdoc = html; + document.body.append(frame); + } + + async function copyMessageId() { + const value = state.currentDetail?.message_id; + if (!value) return toast("Message-ID is not available", "error"); + try { await navigator.clipboard.writeText(value); toast("Message-ID copied"); } + catch (_) { toast("Could not copy Message-ID", "error"); } + } + + async function copySenderAddress() { + const value = state.currentDetail?.from_address; + if (!value) return; + try { await navigator.clipboard.writeText(value); toast("Sender address copied"); } + catch (_) { toast("Could not copy address", "error"); } + } + + async function downloadEml() { + const detail = state.currentDetail; + if (!detail) return; + try { + const blob = await fetchEmlBlob(detail); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = `message-${detail.uid}.eml`; + link.click(); + setTimeout(() => URL.revokeObjectURL(url), 30000); + toast("Downloading .eml", "info"); + } catch (error) { toast(error.message, "error"); } + } + + async function forwardAsAttachment() { + const detail = state.currentDetail; + if (!detail) return; + try { + const blob = await fetchEmlBlob(detail); + openCompose("new"); + $("#compose-title").textContent = "Forward as attachment"; + $("#compose-subject").value = /^fwd:/i.test(detail.subject || "") ? detail.subject : `Fwd: ${detail.subject || ""}`; + const safe = String(detail.subject || `message-${detail.uid}`).replace(/[\\/:*?"<>|]+/g, "_").slice(0, 120) || `message-${detail.uid}`; + const file = new File([blob], `${safe}.eml`, { type: "message/rfc822" }); + await uploadComposeFiles([file]); + } catch (error) { toast(error.message, "error"); } + } + + + function openCurrentSenderMenu(x, y) { + const detail = state.currentDetail; + if (!detail?.from_address) return; + showContextMenu(x, y, [ + { label: "Compose to sender", action: () => { openCompose("new"); $("#compose-to").value = detail.from_name ? `${detail.from_name} <${detail.from_address}>` : detail.from_address; $("#compose-subject").value = ""; } }, + { label: "Add to contacts", action: addSenderToContacts }, + { label: "Copy address", action: copySenderAddress }, + { label: "Search messages", action: searchFromCurrentSender }, + "separator", + { label: "Create filter", action: async () => createFilterFromCurrentMessage("sender") }, + ]); + } + + async function addSenderToContacts() { + const detail = state.currentDetail; + if (!detail?.from_address) return; + try { + await request("/contacts", { method: "POST", body: JSON.stringify({ id: null, email: detail.from_address, name: detail.from_name || "", company: "", notes: "", is_favorite: false, source: "message" }) }); + toast("Sender added to contacts"); + } catch (error) { toast(error.message, "error"); } + } + + async function searchFromCurrentSender() { + const detail = state.currentDetail; + if (!detail?.from_address) return; + const query = `from:${detail.from_address}`; + $("#search-input").value = query; + await runSearch(new URLSearchParams({ q: query, limit: "100" }), `From: ${detail.from_address}`); + } + + async function copyHeaders() { + if (!state.currentDetail?.raw_headers) return; + try { await navigator.clipboard.writeText(state.currentDetail.raw_headers); toast("Headers copied"); } + catch (_) { toast("Could not copy headers", "error"); } + } + + function quickFilterSource(detail, kind) { + const sender = String(detail?.from_address || "").trim(); + const domain = sender.includes("@") ? sender.split("@").pop() : sender; + const subject = String(detail?.subject || "").trim(); + if (kind === "domain") return { kind: "domain", value: domain, match: "is" }; + if (kind === "subject") return { kind: "subject", value: subject, match: "contains" }; + return { kind: "sender", value: sender, match: "is" }; + } + + function buildQuickFilterRule({ kind, value, match, action, target, stop, markRead = false }) { + if (!value.trim()) throw new Error("Filter value is required"); + const matchToken = match === "contains" ? ":contains" : ":is"; + let condition; + if (kind === "domain") condition = `address :domain ${matchToken} "from" "${sieveQuote(value)}"`; + else if (kind === "subject") condition = `header ${matchToken} "subject" "${sieveQuote(value)}"`; + else condition = `address ${matchToken} "from" "${sieveQuote(value)}"`; + + const requirements = new Set(); + let command; + if (action === "move") { + if (!target) throw new Error("Choose a destination folder"); + requirements.add("fileinto"); + command = `fileinto "${sieveQuote(target)}";`; + } else if (action === "copy") { + if (!target) throw new Error("Choose a destination folder"); + requirements.add("fileinto"); + requirements.add("copy"); + command = `fileinto :copy "${sieveQuote(target)}";`; + } else if (action === "mark-read") { + requirements.add("imap4flags"); + command = 'addflag "\\Seen";'; + } else if (action === "flag") { + requirements.add("imap4flags"); + command = 'addflag "\\Flagged";'; + } else if (action === "discard") command = "discard;"; + else command = "keep;"; + + if (markRead && action !== "mark-read") requirements.add("imap4flags"); + const ruleId = newSieveVisualRuleId(); + const commands = [command, markRead && action !== "mark-read" ? 'addflag "\\Seen";' : "", stop ? "stop;" : ""].filter(Boolean).join("\n "); + return { + requirements: [...requirements], + rule: `# RUSTBOX-RULE-BEGIN ${ruleId}\n# Created from message\nif ${condition} {\n ${commands}\n}\n# RUSTBOX-RULE-END`, + }; + } + + async function prepareQuickFilterAdvanced(config) { + if ($("#settings-dialog").open) $("#settings-dialog").close(); + selectSettingsTab("sieve"); + $("#settings-dialog").showModal(); + await loadSieve(); + const scripts = state.sieveScripts || []; + const targetScript = config.script || scripts.find((script) => script.active)?.name || scripts.find((script) => script.name === "rustbox")?.name || "rustbox"; + if (scripts.some((script) => script.name === targetScript)) await openSieveScript(targetScript); + else { + $("#sieve-name").value = targetScript; + $("#sieve-editor").value = ""; + renderManagedSieveRules(); + } + state.sieveEditingRuleId = null; + const suggestedName = config.kind === "subject" ? "subject_filter" : config.kind === "domain" ? "sender_domain" : "sender_filter"; + $("#sieve-builder-name").value = suggestedName; + $("#sieve-builder-enabled").checked = true; + $("#sieve-builder-stop").checked = Boolean(config.stop); + $("#sieve-builder-condition").value = config.kind === "domain" ? "from-domain" : config.kind === "subject" ? "subject" : "from"; + $("#sieve-builder-match").value = config.match; + $("#sieve-builder-value").value = config.value; + $("#sieve-builder-action").value = config.action === "flag" ? "add-flag" : config.action; + $("#sieve-builder-target").value = config.action === "flag" ? "\\Flagged" : config.target || ""; + $("#sieve-builder-condition-2").value = ""; + $("#sieve-builder-condition-3").value = ""; + $("#sieve-builder-extra-conditions").replaceChildren(); + $("#sieve-builder-action-2").value = config.markRead && config.action !== "mark-read" ? "mark-read" : ""; + $("#sieve-builder-extra-actions").replaceChildren(); + updateSieveBuilder(); + $("#sieve-result").textContent = "Quick-filter values loaded. Review the rule, add it, validate, then save."; + showSieveRuleDialog("new"); + } + + async function createFilterFromCurrentMessage(kind = "sender") { + const detail = state.currentDetail; + if (!detail) return; + + let sieveInfo; + let scripts; + try { + [sieveInfo, scripts] = await Promise.all([ + request("/sieve/status", { method: "GET" }), + request("/sieve/scripts", { method: "GET" }), + ]); + state.sieveInfo = sieveInfo; + state.sieveScripts = scripts.scripts || []; + } catch (error) { + toast(`ManageSieve unavailable: ${error.message}`, "error"); + return; + } + + const source = quickFilterSource(detail, kind); + const activeScript = state.sieveScripts.find((script) => script.active); + const defaultScript = activeScript?.name || state.sieveScripts.find((script) => script.name === "rustbox")?.name || "rustbox"; + const defaultTarget = detectSpecialFolder("archive") || orderedFolders().find((folder) => folder.name !== (detail.folder || state.folder))?.name || ""; + const extensions = new Set((sieveInfo.extensions || []).map((value) => String(value).toLowerCase())); + + const dialog = el("dialog", "dialog quick-filter-dialog"); + const form = el("form", "dialog-card quick-filter-card"); + form.method = "dialog"; + const header = el("header", "dialog-header"); + header.append(el("strong", "", "Create filter from message")); + const close = el("button", "icon-button"); close.type = "button"; close.innerHTML = svgIcon("x"); close.setAttribute("aria-label", "Close"); + header.append(close); + const body = el("div", "quick-filter-body"); + const grid = el("div", "quick-filter-grid"); + + const makeField = (labelText, control) => { const label = el("label"); label.append(el("span", "", labelText), control); return label; }; + const condition = document.createElement("select"); + condition.add(new Option("Sender address", "sender")); + condition.add(new Option("Sender domain", "domain")); + condition.add(new Option("Subject", "subject")); + condition.value = source.kind; + const match = document.createElement("select"); + match.add(new Option("is exactly", "is")); + match.add(new Option("contains", "contains")); + match.value = source.match; + const value = document.createElement("input"); value.value = source.value; value.required = true; + const action = document.createElement("select"); + [ + ["Move to folder", "move", ["fileinto"]], + ["Copy to folder", "copy", ["fileinto", "copy"]], + ["Mark as read", "mark-read", ["imap4flags"]], + ["Flag message", "flag", ["imap4flags"]], + ["Discard", "discard", []], + ["Keep", "keep", []], + ].forEach(([label, actionValue, requires]) => { + const option = new Option(label, actionValue); + option.disabled = requires.some((requirement) => !extensions.has(requirement)); + action.add(option); + }); + if (action.options[0]?.disabled) action.value = [...action.options].find((option) => !option.disabled)?.value || "keep"; + const target = document.createElement("select"); + target.replaceChildren(...orderedFolders().map((folder) => new Option(folder.name, folder.name))); + target.value = defaultTarget; + const script = document.createElement("select"); + const scriptNames = new Set(state.sieveScripts.map((item) => item.name)); + for (const item of state.sieveScripts) script.add(new Option(`${item.name}${item.active ? " · active" : ""}`, item.name)); + if (!scriptNames.has("rustbox")) script.add(new Option("rustbox · new", "rustbox")); + script.value = defaultScript; + const stop = document.createElement("input"); stop.type = "checkbox"; stop.checked = true; + const markRead = document.createElement("input"); markRead.type = "checkbox"; markRead.disabled = !extensions.has("imap4flags"); + const activate = document.createElement("input"); activate.type = "checkbox"; activate.checked = !activeScript; + + const targetField = makeField("Destination folder", target); + grid.append( + makeField("Match", condition), makeField("Comparison", match), + makeField("Value", value), makeField("Action", action), + targetField, makeField("Sieve script", script) + ); + body.append(grid); + const toggles = el("div", "appearance-toggles quick-filter-toggles"); + const stopLabel = el("label", "checkbox-row"); stopLabel.append(stop, el("span", "", "Stop processing after this rule")); + const readLabel = el("label", "checkbox-row"); readLabel.append(markRead, el("span", "", "Also mark matching mail as read")); + if (markRead.disabled) readLabel.title = "Server does not advertise imap4flags"; + const activateLabel = el("label", "checkbox-row"); activateLabel.append(activate, el("span", "", "Activate selected script after saving")); + toggles.append(stopLabel, readLabel, activateLabel); + body.append(toggles); + const preview = el("div", "quick-filter-preview"); body.append(preview); + const hint = el("div", "muted quick-filter-hint"); body.append(hint); + + const actions = el("footer", "dialog-actions"); + const cancel = el("button", "button quiet", "Cancel"); cancel.type = "button"; + const advanced = el("button", "button quiet", "Advanced editor"); advanced.type = "button"; + const save = el("button", "button primary", "Create filter"); save.type = "submit"; + actions.append(cancel, advanced, save); + form.append(header, body, actions); dialog.append(form); document.body.append(dialog); + + const sourceValues = { + sender: quickFilterSource(detail, "sender"), + domain: quickFilterSource(detail, "domain"), + subject: quickFilterSource(detail, "subject"), + }; + const update = () => { + const needsFolder = ["move", "copy"].includes(action.value); + targetField.classList.toggle("hidden", !needsFolder); + const requires = action.value === "copy" ? ["fileinto", "copy"] : action.value === "move" ? ["fileinto"] : ["mark-read", "flag"].includes(action.value) ? ["imap4flags"] : []; + const missing = requires.filter((item) => !extensions.has(item)); + const conditionLabel = condition.value === "domain" ? "sender domain" : condition.value === "subject" ? "subject" : "sender"; + const actionLabel = action.selectedOptions[0]?.textContent || action.value; + const displayValue = value.value.trim() || "…"; + preview.textContent = `If ${conditionLabel} ${match.value === "contains" ? "contains" : "is"} “${displayValue}” → ${actionLabel}${needsFolder && target.value ? ` “${target.value}”` : ""}${markRead.checked && action.value !== "mark-read" ? " + mark as read" : ""}.`; + hint.textContent = missing.length + ? `Server does not advertise: ${missing.join(", ")}` + : `Will add this rule to “${script.value}”${activate.checked ? " and activate the script" : ""}. It applies to new incoming mail; existing messages are not changed.`; + save.disabled = missing.length > 0; + }; + condition.addEventListener("change", () => { const next = sourceValues[condition.value]; value.value = next.value; match.value = next.match; update(); }); + match.addEventListener("change", update); + value.addEventListener("input", update); + action.addEventListener("change", update); + target.addEventListener("change", update); + script.addEventListener("change", update); + activate.addEventListener("change", update); + markRead.addEventListener("change", update); + + let settled = false; + const cleanup = () => { if (settled) return; settled = true; if (dialog.open) dialog.close(); else dialog.remove(); }; + close.addEventListener("click", cleanup); + cancel.addEventListener("click", cleanup); + dialog.addEventListener("cancel", (event) => { event.preventDefault(); cleanup(); }); + dialog.addEventListener("close", () => dialog.remove(), { once: true }); + advanced.addEventListener("click", async () => { + const config = { kind: condition.value, match: match.value, value: value.value.trim(), action: action.value, target: target.value, script: script.value, markRead: markRead.checked }; + cleanup(); + await prepareQuickFilterAdvanced(config); + }); + form.addEventListener("submit", async (event) => { + event.preventDefault(); + if (!form.reportValidity()) return; + setBusy(save, true); + try { + const config = { kind: condition.value, match: match.value, value: value.value.trim(), action: action.value, target: target.value, stop: stop.checked, markRead: markRead.checked }; + const built = buildQuickFilterRule(config); + const existing = state.sieveScripts.find((item) => item.name === script.value); + let current = ""; + if (existing) current = (await request(`/sieve/scripts/${encodeURIComponent(script.value)}`, { method: "GET" })).content || ""; + const content = mergeSieveRequirements([current.trim(), built.rule].filter(Boolean).join("\n\n"), built.requirements); + const validation = await request("/sieve/check", { method: "POST", body: JSON.stringify({ content }) }); + if (!validation.valid) throw new Error(validation.error || "Filter validation failed"); + await request(`/sieve/scripts/${encodeURIComponent(script.value)}`, { method: "PUT", body: JSON.stringify({ content }) }); + if (activate.checked || existing?.active) await request(`/sieve/scripts/${encodeURIComponent(script.value)}/activate`, { method: "POST", body: "{}" }); + cleanup(); + toast(`Filter added to ${script.value}`, "success"); + } catch (error) { + hint.textContent = error.message; + toast(error.message, "error"); + } finally { setBusy(save, false); } + }); + + update(); + dialog.showModal(); + setTimeout(() => value.focus(), 20); + } + + async function openContacts() { + $("#contacts-dialog").showModal(); + await loadContactGroups(); + await loadContacts(); + } + + async function loadContactGroups() { + try { + const data = await request("/contact-groups", { method: "GET" }); + state.contactGroups = data.groups || []; + renderContactGroupFilter(); + renderContactGroupMembership($("#contact-id").value || null, []); + } catch (error) { toast(error.message, "error"); } + } + + function renderContactGroupFilter() { + const select = $("#contact-group-filter"); + const current = select.value; + select.replaceChildren(new Option("All contacts", "")); + for (const group of state.contactGroups) select.add(new Option(`${group.name} (${group.member_count || 0})`, group.id)); + if ([...select.options].some((option) => option.value === current)) select.value = current; + const hasGroup = Boolean(select.value); + $("#contact-group-rename").disabled = !hasGroup; + $("#contact-group-delete").disabled = !hasGroup; + } + + async function downloadContactExport() { + try { + const headers = new Headers(); + if (state.accountId) headers.set("X-Active-Account", state.accountId); + const response = await fetch(apiPath("/contacts/export"), { credentials: "include", headers }); + if (!response.ok) throw new Error(`Export failed (${response.status})`); + const blob = await response.blob(); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = "contacts.vcf"; + document.body.append(link); + link.click(); + link.remove(); + setTimeout(() => URL.revokeObjectURL(url), 1000); + toast("Calendar exported", "success"); + } catch (error) { toast(error.message, "error"); } + } + + async function importContactVcard(files) { + const file = files?.[0]; + if (!file) return; + if (file.size > 10 * 1024 * 1024) return toast("vCard file is too large", "error"); + const form = new FormData(); + form.append("file", file, file.name || "contacts.vcf"); + try { + const data = await request("/contacts/import", { method: "POST", body: form }); + toast(`vCard import: ${data.created || 0} created, ${data.updated || 0} updated, ${data.skipped || 0} skipped`); + await loadContacts($("#contact-search").value.trim()); + } catch (error) { toast(error.message, "error"); } + finally { $("#contact-import-file").value = ""; } + } + + async function showContactDuplicates() { + try { + const data = await request("/contacts/duplicates", { method: "GET" }); + const groups = data.duplicates || []; + const list = $("#contact-duplicate-list"); + list.replaceChildren(); + if (!groups.length) { + list.append(el("div", "empty-list", "No duplicate contacts found")); + } + for (const group of groups) { + const card = el("section", "duplicate-contact-group"); + card.append(el("strong", "", group.email)); + const rows = el("div", "duplicate-contact-options"); + for (const contact of group.contacts || []) { + const row = el("div", "duplicate-contact-row"); + const summary = el("div", "duplicate-contact-summary"); + summary.append(el("strong", "", contact.name || contact.email)); + summary.append(el("small", "muted", [contact.email, contact.company, contact.phone].filter(Boolean).join(" · "))); + const keep = el("button", "button quiet", "Keep this"); + keep.type = "button"; + keep.addEventListener("click", async () => { + const sources = (group.contacts || []).filter((item) => item.id !== contact.id); + if (!sources.length) return; + if (!await appConfirm(`Merge ${sources.length} duplicate contact(s) into ${contact.name || contact.email}?`, { title: "Merge duplicate contacts", confirmLabel: "Merge" })) return; + try { + let target = contact; + for (const source of sources) { + target = await request("/contacts/merge", { method: "POST", body: JSON.stringify({ target_id: target.id, source_id: source.id }) }); + } + toast("Contacts merged"); + await loadContacts($("#contact-search").value.trim()); + await showContactDuplicates(); + editContact(target); + } catch (error) { toast(error.message, "error"); } + }); + row.append(summary, keep); rows.append(row); + } + card.append(rows); list.append(card); + } + if (!$("#contact-duplicates-dialog").open) $("#contact-duplicates-dialog").showModal(); + } catch (error) { toast(error.message, "error"); } + } + + async function loadContacts(query = "") { + try { + const groupId = $("#contact-group-filter")?.value || ""; + let contacts; + if (groupId) { + const data = await request(`/contact-groups/${encodeURIComponent(groupId)}/members`, { method: "GET" }); + contacts = data.members || []; + if (query) { const needle = query.toLowerCase(); contacts = contacts.filter((contact) => `${contact.name} ${contact.email} ${contact.company}`.toLowerCase().includes(needle)); } + } else { + const data = await request(`/contacts?limit=500&offset=0${query ? `&q=${encodeURIComponent(query)}` : ""}`, { method: "GET" }); + contacts = data.contacts || []; + } + state.contacts = contacts; + renderContacts(); + } catch (error) { toast(error.message, "error"); } + } + + async function createContactGroup() { + const name = await appPrompt("New contact group", { placeholder: "Group name", confirmLabel: "Create", required: true }); + if (!name?.trim()) return; + try { await request("/contact-groups", { method: "POST", body: JSON.stringify({ name: name.trim() }) }); await loadContactGroups(); toast("Group created"); } + catch (error) { toast(error.message, "error"); } + } + + async function renameContactGroup() { + const id = $("#contact-group-filter").value; + const group = state.contactGroups.find((item) => item.id === id); + if (!group) return; + const name = await appPrompt("Rename contact group", { value: group.name, confirmLabel: "Rename", required: true }); + if (!name?.trim() || name.trim() === group.name) return; + try { await request(`/contact-groups/${encodeURIComponent(id)}`, { method: "PUT", body: JSON.stringify({ name: name.trim() }) }); await loadContactGroups(); toast("Group renamed"); } + catch (error) { toast(error.message, "error"); } + } + + async function deleteContactGroup() { + const id = $("#contact-group-filter").value; + const group = state.contactGroups.find((item) => item.id === id); + if (!group || !await appConfirm(`Delete group “${group.name}”? Contacts will be kept.`, { title: "Delete contact group", confirmLabel: "Delete", danger: true })) return; + try { await request(`/contact-groups/${encodeURIComponent(id)}`, { method: "DELETE" }); $("#contact-group-filter").value = ""; await loadContactGroups(); await loadContacts($("#contact-search").value.trim()); toast("Group deleted"); } + catch (error) { toast(error.message, "error"); } + } + + async function loadContactMembership(contactId) { + if (!contactId) return renderContactGroupMembership(null, []); + try { + const data = await request(`/contacts/${encodeURIComponent(contactId)}/groups`, { method: "GET" }); + renderContactGroupMembership(contactId, (data.groups || []).map((group) => group.id)); + } catch (error) { toast(error.message, "error"); } + } + + function renderContactGroupMembership(contactId, selectedIds) { + const list = $("#contact-group-membership"); + if (!list) return; + const selected = new Set(selectedIds || []); + list.replaceChildren(); + if (!state.contactGroups.length) { list.append(el("span", "muted", "No groups")); return; } + for (const group of state.contactGroups) { + const label = el("label", "checkbox-row"); + const input = document.createElement("input"); input.type = "checkbox"; input.checked = selected.has(group.id); input.disabled = !contactId; + input.addEventListener("change", async () => { + try { + if (input.checked) await request(`/contact-groups/${encodeURIComponent(group.id)}/members`, { method: "POST", body: JSON.stringify({ contact_id: contactId }) }); + else await request(`/contact-groups/${encodeURIComponent(group.id)}/members/${encodeURIComponent(contactId)}`, { method: "DELETE" }); + await loadContactGroups(); + } catch (error) { input.checked = !input.checked; toast(error.message, "error"); } + }); + label.append(input, el("span", "", group.name)); list.append(label); + } + } + + function renderContacts() { + const list = $("#contact-list"); + list.replaceChildren(); + if (!state.contacts.length) list.append(el("div", "empty-list", "No contacts")); + for (const contact of state.contacts) { + const button = el("button", "contact-item"); + button.type = "button"; + button.innerHTML = `${escapeHtml(contact.name || contact.email)}${escapeHtml(contact.email)}${contact.company ? ` · ${escapeHtml(contact.company)}` : ""}${contact.phone ? ` · ${escapeHtml(contact.phone)}` : ""}`; + button.addEventListener("click", () => editContact(contact)); + list.append(button); + } + } + + function editContact(contact = null) { + $("#contact-id").value = contact?.id || ""; + $("#contact-name").value = contact?.name || ""; + $("#contact-email").value = contact?.email || ""; + $("#contact-emails").value = (contact?.emails || []).join("\n"); + $("#contact-company").value = contact?.company || ""; + $("#contact-phone").value = contact?.phone || ""; + $("#contact-phones").value = (contact?.phones || []).join("\n"); + $("#contact-job-title").value = contact?.job_title || ""; + $("#contact-website").value = contact?.website || ""; + $("#contact-birthday").value = contact?.birthday || ""; + $("#contact-address").value = contact?.address || ""; + $("#contact-addresses").value = (contact?.addresses || []).join("\n"); + $("#contact-notes").value = contact?.notes || ""; + $("#contact-favorite").checked = Boolean(contact?.is_favorite); + $("#contact-delete").disabled = !contact; + $("#contact-compose").disabled = !contact?.email; + $("#contact-photo-upload").disabled = !contact?.id; + $("#contact-photo-delete").disabled = !contact?.id || !contact?.has_photo; + renderContactPhoto(contact); + loadContactMembership(contact?.id || null); + } + + function clearContactPhotoObjectUrl() { + if (state.contactPhotoUrl) URL.revokeObjectURL(state.contactPhotoUrl); + state.contactPhotoUrl = null; + } + + async function renderContactPhoto(contact) { + clearContactPhotoObjectUrl(); + const image = $("#contact-photo-preview"); + const placeholder = $("#contact-photo-placeholder"); + image.classList.add("hidden"); + image.removeAttribute("src"); + placeholder.classList.remove("hidden"); + if (!contact?.id || !contact?.has_photo) return; + try { + const headers = new Headers(); + if (state.accountId) headers.set("X-Active-Account", state.accountId); + const response = await fetch(apiPath(`/contacts/${encodeURIComponent(contact.id)}/photo`), { credentials: "include", headers }); + if (!response.ok) return; + const blob = await response.blob(); + state.contactPhotoUrl = URL.createObjectURL(blob); + image.src = state.contactPhotoUrl; + image.classList.remove("hidden"); + placeholder.classList.add("hidden"); + } catch (_) { } + } + + async function uploadContactPhoto(files) { + const id = $("#contact-id").value; + const file = files?.[0]; + if (!id || !file) return; + if (file.size > 2 * 1024 * 1024) { toast("Contact photo exceeds the 2 MB limit", "error"); $("#contact-photo-file").value = ""; return; } + const form = new FormData(); + form.append("file", file, file.name || "photo"); + try { + await request(`/contacts/${encodeURIComponent(id)}/photo`, { method: "POST", body: form }); + const contact = state.contacts.find((item) => item.id === id); + if (contact) contact.has_photo = true; + $("#contact-photo-delete").disabled = false; + await renderContactPhoto({ id, has_photo: true }); + toast("Contact photo saved"); + } catch (error) { toast(error.message, "error"); } + finally { $("#contact-photo-file").value = ""; } + } + + async function deleteContactPhoto() { + const id = $("#contact-id").value; + if (!id) return; + try { + await request(`/contacts/${encodeURIComponent(id)}/photo`, { method: "DELETE" }); + const contact = state.contacts.find((item) => item.id === id); + if (contact) contact.has_photo = false; + $("#contact-photo-delete").disabled = true; + await renderContactPhoto({ id, has_photo: false }); + toast("Contact photo removed"); + } catch (error) { toast(error.message, "error"); } + } + + async function saveContact(event) { + event.preventDefault(); + const payload = { + id: $("#contact-id").value || null, + name: $("#contact-name").value.trim(), email: $("#contact-email").value.trim(), emails: $("#contact-emails").value.split(/\r?\n/).map((value) => value.trim()).filter(Boolean), company: $("#contact-company").value.trim(), + phone: $("#contact-phone").value.trim(), phones: $("#contact-phones").value.split(/\r?\n/).map((value) => value.trim()).filter(Boolean), job_title: $("#contact-job-title").value.trim(), website: $("#contact-website").value.trim(), + birthday: $("#contact-birthday").value, address: $("#contact-address").value.trim(), addresses: $("#contact-addresses").value.split(/\r?\n/).map((value) => value.trim()).filter(Boolean), notes: $("#contact-notes").value, + is_favorite: $("#contact-favorite").checked, source: "manual", + }; + try { const saved = await request("/contacts", { method: "POST", body: JSON.stringify(payload) }); await loadContacts($("#contact-search").value.trim()); editContact(saved); toast("Contact saved"); } + catch (error) { toast(error.message, "error"); } + } + + async function deleteContact() { + const id = $("#contact-id").value; + if (!id || !await appConfirm("Delete this contact?", { title: "Delete contact", confirmLabel: "Delete", danger: true })) return; + try { await request(`/contacts/${encodeURIComponent(id)}`, { method: "DELETE" }); editContact(null); await loadContacts($("#contact-search").value.trim()); toast("Contact deleted"); } + catch (error) { toast(error.message, "error"); } + } + + function composeToContact() { + const email = $("#contact-email").value.trim(); + if (!email) return; + $("#contacts-dialog").close(); + openCompose("new"); + $("#compose-to").value = email; + } + + function startOfCalendarDay(date) { + return new Date(date.getFullYear(), date.getMonth(), date.getDate()); + } + + function addCalendarDays(date, count) { + const next = new Date(date); + next.setDate(next.getDate() + count); + return next; + } + + function startOfCalendarWeek(date) { + const start = startOfCalendarDay(date); + const day = start.getDay(); + const mondayOffset = day === 0 ? -6 : 1 - day; + return addCalendarDays(start, mondayOffset); + } + + function calendarRange(date, view = state.calendarView) { + if (view === "week") { + const start = startOfCalendarWeek(date); + return { start, end: addCalendarDays(start, 7) }; + } + if (view === "day") { + const start = startOfCalendarDay(date); + return { start, end: addCalendarDays(start, 1) }; + } + if (view === "agenda") { + const start = startOfCalendarDay(date); + return { start, end: addCalendarDays(start, 30) }; + } + const first = new Date(date.getFullYear(), date.getMonth(), 1); + const start = startOfCalendarWeek(first); + return { start, end: addCalendarDays(start, 42) }; + } + + function calendarDayKey(value) { + const date = value instanceof Date ? value : new Date(value); + if (Number.isNaN(date.getTime())) return ""; + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`; + } + + function calendarRangeLabel() { + const cursor = state.calendarCursor; + if (state.calendarView === "month") return cursor.toLocaleDateString([], { month: "long", year: "numeric" }); + if (state.calendarView === "day") return cursor.toLocaleDateString([], { weekday: "long", year: "numeric", month: "long", day: "numeric" }); + const { start, end } = calendarRange(cursor); + const last = addCalendarDays(end, -1); + if (state.calendarView === "agenda") return `${start.toLocaleDateString()} – ${last.toLocaleDateString()}`; + return `${start.toLocaleDateString([], { month: "short", day: "numeric" })} – ${last.toLocaleDateString([], { month: "short", day: "numeric", year: "numeric" })}`; + } + + function shiftCalendarCursor(direction) { + const cursor = new Date(state.calendarCursor); + if (state.calendarView === "month") cursor.setMonth(cursor.getMonth() + direction); + else if (state.calendarView === "week") cursor.setDate(cursor.getDate() + (7 * direction)); + else if (state.calendarView === "agenda") cursor.setDate(cursor.getDate() + (30 * direction)); + else cursor.setDate(cursor.getDate() + direction); + state.calendarCursor = cursor; + } + + function toLocalInput(value) { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return ""; + const local = new Date(date.getTime() - date.getTimezoneOffset() * 60000); + return local.toISOString().slice(0, 16); + } + + async function importCalendarIcs(files) { + const file = files?.[0]; + if (!file) return; + if (file.size > 10 * 1024 * 1024) return toast("ICS file is too large", "error"); + try { + const text = await file.text(); + const headers = new Headers({ "Content-Type": "text/calendar; charset=utf-8", "X-Requested-With": "XMLHttpRequest" }); + if (state.accountId) headers.set("X-Active-Account", state.accountId); + const response = await fetch(apiPath("/calendar/events/import-ics"), { method: "POST", credentials: "include", headers, body: text }); + const data = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(data?.error?.message || `ICS import failed (${response.status})`); + toast(`ICS import: ${data.created || 0} created, ${data.skipped || 0} skipped`); + await loadCalendar(); + } catch (error) { toast(error.message, "error"); } + finally { $("#calendar-import-file").value = ""; } + } + + async function exportCalendarIcs() { + try { + const headers = new Headers(); + if (state.accountId) headers.set("X-Active-Account", state.accountId); + const response = await fetch(apiPath("/calendar/events/export-ics"), { credentials: "include", headers }); + if (!response.ok) throw new Error(`Calendar export failed (${response.status})`); + const blob = await response.blob(); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = "rustbox-calendar.ics"; + document.body.append(link); + link.click(); + link.remove(); + setTimeout(() => URL.revokeObjectURL(url), 1000); + } catch (error) { toast(error.message, "error"); } + } + + async function openCalendar() { + $("#calendar-dialog").showModal(); + await loadCalendar(); + } + + async function loadCalendar() { + const { start, end } = calendarRange(state.calendarCursor); + $("#calendar-view").value = state.calendarView; + $("#calendar-range").textContent = calendarRangeLabel(); + try { + const data = await request(`/calendar/events?start=${encodeURIComponent(start.toISOString())}&end=${encodeURIComponent(end.toISOString())}`, { method: "GET" }); + state.calendarEvents = data.events || []; + renderCalendarEvents(); + } catch (error) { toast(error.message, "error"); } + } + + function createCalendarEventButton(event, compact = false) { + const button = el("button", compact ? "calendar-event-chip" : "calendar-event"); + button.type = "button"; + const color = /^#[0-9a-f]{6}$/i.test(String(event.color || "")) ? event.color : "var(--accent)"; + button.style.setProperty("--event-color", color); + if (compact) { + const time = event.all_day ? "All day" : new Date(event.start_time).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); + button.innerHTML = `${escapeHtml(time)}${escapeHtml(event.title)}`; + } else { + button.innerHTML = `
    ${escapeHtml(event.title)}${escapeHtml(event.location || "")}
    ${escapeHtml(event.status || "")}`; + } + button.addEventListener("click", () => editCalendarEvent(event)); + return button; + } + + function renderCalendarMonthOrWeek(view) { + const host = $("#calendar-events"); + const { start, end } = calendarRange(state.calendarCursor, view); + const days = view === "week" ? 7 : 42; + const grid = el("div", `calendar-grid calendar-${view}-grid`); + for (const label of ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]) grid.append(el("div", "calendar-weekday", label)); + const byDay = new Map(); + for (const event of state.calendarEvents) { + const key = calendarDayKey(event.start_time); + if (!byDay.has(key)) byDay.set(key, []); + byDay.get(key).push(event); + } + for (let i = 0; i < days; i++) { + const date = addCalendarDays(start, i); + const cell = el("section", "calendar-day-cell"); + if (view === "month" && date.getMonth() !== state.calendarCursor.getMonth()) cell.classList.add("outside-month"); + if (calendarDayKey(date) === calendarDayKey(new Date())) cell.classList.add("today"); + const heading = el("button", "calendar-day-number", String(date.getDate())); + heading.type = "button"; + heading.title = "Create event on this day"; + heading.addEventListener("click", () => { + const startDate = new Date(date); startDate.setHours(9, 0, 0, 0); + const endDate = new Date(startDate.getTime() + 3600000); + editCalendarEvent(null); + $("#calendar-start").value = toLocalInput(startDate); + $("#calendar-end").value = toLocalInput(endDate); + }); + cell.append(heading); + const dayEvents = (byDay.get(calendarDayKey(date)) || []).sort((a, b) => new Date(a.start_time) - new Date(b.start_time)); + for (const event of dayEvents.slice(0, view === "month" ? 4 : 12)) cell.append(createCalendarEventButton(event, true)); + if (view === "month" && dayEvents.length > 4) cell.append(el("span", "calendar-more", `+${dayEvents.length - 4} more`)); + grid.append(cell); + } + host.append(grid); + } + + function renderCalendarAgenda() { + const host = $("#calendar-events"); + const groups = new Map(); + for (const event of state.calendarEvents) { + const key = calendarDayKey(event.start_time); + if (!groups.has(key)) groups.set(key, []); + groups.get(key).push(event); + } + if (!groups.size) { host.append(el("div", "empty-list", "No events in this range")); return; } + for (const [key, events] of [...groups.entries()].sort(([a], [b]) => a.localeCompare(b))) { + const date = new Date(`${key}T12:00:00`); + const section = el("section", "calendar-agenda-day"); + section.append(el("h4", "", date.toLocaleDateString([], { weekday: "long", month: "long", day: "numeric", year: "numeric" }))); + for (const event of events.sort((a, b) => new Date(a.start_time) - new Date(b.start_time))) section.append(createCalendarEventButton(event)); + host.append(section); + } + } + + function renderCalendarEvents() { + const list = $("#calendar-events"); + list.replaceChildren(); + if (state.calendarView === "month" || state.calendarView === "week") { + renderCalendarMonthOrWeek(state.calendarView); + return; + } + if (!state.calendarEvents.length) { + list.append(el("div", "empty-list", state.calendarView === "day" ? "No events today" : "No events in this range")); + return; + } + renderCalendarAgenda(); + } + + function updateCalendarRecurrenceUi() { + $("#calendar-rrule-wrap").classList.toggle("hidden", $("#calendar-recurrence").value !== "custom"); + } + + function editCalendarEvent(event = null) { + $("#calendar-form").classList.remove("hidden"); + $("#calendar-event-id").value = event?.id || ""; + $("#calendar-title").value = event?.title || ""; + $("#calendar-start").value = event ? toLocalInput(event.start_time) : toLocalInput(new Date()); + $("#calendar-end").value = event ? toLocalInput(event.end_time) : toLocalInput(new Date(Date.now() + 3600000)); + $("#calendar-all-day").checked = Boolean(event?.all_day); + const rule = event?.recurrence_rule || ""; + const commonRules = new Set(["", "FREQ=DAILY", "FREQ=WEEKLY", "FREQ=MONTHLY", "FREQ=YEARLY"]); + $("#calendar-recurrence").value = commonRules.has(rule) ? rule : "custom"; + $("#calendar-rrule").value = commonRules.has(rule) ? "" : rule; + updateCalendarRecurrenceUi(); + let attendees = []; + try { attendees = JSON.parse(event?.attendees || "[]"); } catch (_) { } + $("#calendar-attendees").value = Array.isArray(attendees) ? attendees.join(", ") : ""; + $("#calendar-organizer").value = event?.organizer || state.accounts.find((item) => item.id === state.accountId)?.email || ""; + $("#calendar-status").value = String(event?.status || "confirmed").toLowerCase(); + $("#calendar-location").value = event?.location || ""; + $("#calendar-meeting-url").value = event?.meeting_url || ""; + $("#calendar-description").value = event?.description || ""; + const color = String(event?.color || ""); + $("#calendar-color").value = /^#[0-9a-f]{6}$/i.test(color) ? color : "#6b7280"; + const reminder = event?.reminder_minutes == null ? "" : String(event.reminder_minutes); + $("#calendar-reminder").value = Array.from($("#calendar-reminder").options).some((option) => option.value === reminder) ? reminder : ""; + $("#calendar-delete").disabled = !event; + } + + async function saveCalendarEvent(event) { + event.preventDefault(); + const id = $("#calendar-event-id").value; + const recurrenceChoice = $("#calendar-recurrence").value; + const recurrenceRule = recurrenceChoice === "custom" ? $("#calendar-rrule").value.trim() : recurrenceChoice; + const attendees = $("#calendar-attendees").value.split(",").map((value) => value.trim()).filter(Boolean); + const startValue = $("#calendar-start").value; + const endValue = $("#calendar-end").value; + if (!startValue || !endValue) return toast("Start and end are required", "error"); + const base = { + title: $("#calendar-title").value.trim(), + description: $("#calendar-description").value, + location: $("#calendar-location").value.trim(), + start_time: new Date(startValue).toISOString(), + end_time: new Date(endValue).toISOString(), + all_day: $("#calendar-all-day").checked, + recurrence_rule: recurrenceRule || null, + attendees: JSON.stringify(attendees), + organizer: $("#calendar-organizer").value.trim(), + status: $("#calendar-status").value || "confirmed", + meeting_url: $("#calendar-meeting-url").value.trim() || null, + color: $("#calendar-color").value || null, + reminder_minutes: $("#calendar-reminder").value === "" ? null : Number($("#calendar-reminder").value), + }; + const payload = id ? base : { ...base, source: "manual", source_uid: null, source_message_uid: null, source_message_folder: null }; + try { await request(id ? `/calendar/events/${encodeURIComponent(id)}` : "/calendar/events", { method: id ? "PUT" : "POST", body: JSON.stringify(payload) }); $("#calendar-form").classList.add("hidden"); await loadCalendar(); toast("Event saved"); } + catch (error) { toast(error.message, "error"); } + } + + async function deleteCalendarEvent() { + const id = $("#calendar-event-id").value; + if (!id || !await appConfirm("Delete this event?", { title: "Delete event", confirmLabel: "Delete", danger: true })) return; + try { await request(`/calendar/events/${encodeURIComponent(id)}`, { method: "DELETE" }); $("#calendar-form").classList.add("hidden"); await loadCalendar(); toast("Event deleted"); } + catch (error) { toast(error.message, "error"); } + } + + async function loadOpenPgp() { + try { + const [keys, prefs] = await Promise.all([ + request("/pgp/keys", { method: "GET" }), + request("/pgp/preferences", { method: "GET" }), + ]); + state.pgpKeys = Array.isArray(keys) ? keys : []; + state.pgpPrefs = { ...state.pgpPrefs, ...(prefs || {}) }; + renderOpenPgp(); + renderIdentityPgpOptions(); + updateComposePgpUi(); + if (state.currentDetail) renderPgpReaderState(state.currentDetail); + } catch (error) { + state.pgpKeys = []; + updateComposePgpUi(); + if (state.currentDetail) renderPgpReaderState(state.currentDetail); + toast(error.message, "error"); + } + } + + function pgpFingerprintLabel(value) { + const fp = String(value || "").replace(/\s+/g, "").toUpperCase(); + return fp ? fp.replace(/(.{4})/g, "$1 ").trim() : ""; + } + + function renderOpenPgp() { + if (!$("#pgp-key-list")) return; + $("#pgp-pref-wkd").checked = Boolean(state.pgpPrefs.wkdEnabled); + $("#pgp-pref-hkp").checked = Boolean(state.pgpPrefs.hkpEnabled); + $("#pgp-pref-trusted").checked = Boolean(state.pgpPrefs.requireTrustedEncryption); + const discoveryEnabled = Boolean(state.pgpPrefs.wkdEnabled || state.pgpPrefs.hkpEnabled); + if ($("#pgp-discover")) { + $("#pgp-discover").disabled = !discoveryEnabled; + $("#pgp-discover").title = discoveryEnabled ? "" : "Enable WKD or the HTTPS key server first"; + } + $("#pgp-key-summary").textContent = `${state.pgpKeys.length} key${state.pgpKeys.length === 1 ? "" : "s"} · ${state.pgpKeys.filter((key) => key.hasSecret).length} private`; + const list = $("#pgp-key-list"); + list.replaceChildren(); + if (!state.pgpKeys.length) list.append(el("div", "empty-list", "No OpenPGP keys yet")); + for (const key of state.pgpKeys) { + const card = el("article", "pgp-key-card"); + const head = el("div", "pgp-key-head"); + const title = el("div"); + title.innerHTML = `${escapeHtml(key.userId || key.email || key.keyId)}${escapeHtml(key.email || "No email")} · ${key.hasSecret ? "private + public" : "public"} · ${escapeHtml(key.source || "import")}`; + const trust = document.createElement("select"); + trust.setAttribute("aria-label", `Trust for ${key.email || key.keyId}`); + for (const value of ["unknown", "verified", "trusted", "untrusted"]) trust.add(new Option(value[0].toUpperCase() + value.slice(1), value)); + trust.value = key.trust || "unknown"; + trust.addEventListener("change", async () => { + try { await request(`/pgp/keys/${encodeURIComponent(key.fingerprint)}/trust`, { method: "PATCH", body: JSON.stringify({ trust: trust.value }) }); await loadOpenPgp(); toast("OpenPGP trust updated", "success"); } + catch (error) { toast(error.message, "error"); } + }); + head.append(title, trust); + const fp = el("code", "pgp-fingerprint", pgpFingerprintLabel(key.fingerprint)); + fp.title = "Fingerprint"; + const actions = el("div", "inline-actions"); + const pub = el("button", "button quiet small", "Export public"); pub.type = "button"; pub.addEventListener("click", () => downloadPgpKey(key, false)); actions.append(pub); + if (key.hasSecret) { + const test = el("button", "button quiet small", "Test passphrase"); test.type = "button"; test.addEventListener("click", async () => { + const passphrase = await appPrompt("Test OpenPGP passphrase", { message: "Enter the passphrase for this private key. It is used only for this test and is not stored.", type: "password", confirmLabel: "Test" }); + if (passphrase === null) return; + setBusy(test, true); + try { await request(`/pgp/keys/${encodeURIComponent(key.fingerprint)}/test-passphrase`, { method: "POST", body: JSON.stringify({ passphrase }) }); toast("OpenPGP private key unlocked successfully", "success"); } + catch (error) { toast(error.message, "error"); } finally { setBusy(test, false); } + }); actions.append(test); + const change = el("button", "button quiet small", "Change passphrase"); change.type = "button"; change.addEventListener("click", async () => { + const currentPassphrase = await appPrompt("Current OpenPGP passphrase", { message: "Enter the current passphrase. Leave it empty if the key is currently unprotected.", type: "password", confirmLabel: "Continue" }); + if (currentPassphrase === null) return; + const newPassphrase = await appPrompt("New OpenPGP passphrase", { message: "Enter the new passphrase. Leave it empty only if you intentionally want to remove passphrase protection.", type: "password", confirmLabel: "Change" }); + if (newPassphrase === null) return; + if (newPassphrase === "" && !await appConfirm("Remove passphrase protection from this private key? The key file will still be protected by server filesystem permissions, but anyone who obtains it could use it immediately.", { title: "Remove OpenPGP passphrase", confirmLabel: "Remove passphrase", danger: true })) return; + setBusy(change, true); + try { await request(`/pgp/keys/${encodeURIComponent(key.fingerprint)}/change-passphrase`, { method: "POST", body: JSON.stringify({ currentPassphrase, newPassphrase }) }); toast("OpenPGP passphrase changed", "success"); } + catch (error) { toast(error.message, "error"); } finally { setBusy(change, false); } + }); actions.append(change); + const sec = el("button", "button quiet small", "Export private"); sec.type = "button"; sec.addEventListener("click", async () => { if (await appConfirm("Export the private OpenPGP key? Anyone with this file and its passphrase may use your identity.", { title: "Export private key", confirmLabel: "Export" })) downloadPgpKey(key, true); }); actions.append(sec); + } + const del = el("button", "button quiet small danger", "Delete"); del.type = "button"; del.addEventListener("click", async () => { if (!await appConfirm(`Delete OpenPGP key ${key.keyId}?`, { title: "Delete OpenPGP key", confirmLabel: "Delete", danger: true })) return; try { await request(`/pgp/keys/${encodeURIComponent(key.fingerprint)}`, { method: "DELETE" }); await loadOpenPgp(); await loadIdentities(); toast("OpenPGP key deleted", "success"); } catch (error) { toast(error.message, "error"); } }); actions.append(del); + card.append(head, fp, actions); + list.append(card); + } + } + + async function downloadPgpKey(key, secret) { + try { + const headers = new Headers(); + if (state.accountId) headers.set("X-Active-Account", state.accountId); + const response = await fetch(apiPath(`/pgp/keys/${encodeURIComponent(key.fingerprint)}/export?secret=${secret ? "true" : "false"}`), { credentials: "include", headers }); + if (!response.ok) throw new Error(await response.text() || `Export failed (${response.status})`); + const blob = await response.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); a.href = url; a.download = `openpgp-${key.keyId}-${secret ? "private" : "public"}.asc`; a.click(); + setTimeout(() => URL.revokeObjectURL(url), 30000); + } catch (error) { toast(error.message, "error"); } + } + + async function savePgpPreferences() { + try { + state.pgpPrefs = await request("/pgp/preferences", { method: "PUT", body: JSON.stringify({ wkdEnabled: $("#pgp-pref-wkd").checked, hkpEnabled: $("#pgp-pref-hkp").checked, requireTrustedEncryption: $("#pgp-pref-trusted").checked }) }); + renderOpenPgp(); + updateComposePgpUi(); + if (state.currentDetail) renderPgpReaderState(state.currentDetail); + toast("OpenPGP policy saved", "success"); + } catch (error) { toast(error.message, "error"); } + } + + async function generatePgpKey() { + const button = $("#pgp-generate"); setBusy(button, true); + try { + const key = await request("/pgp/keys/generate", { method: "POST", body: JSON.stringify({ name: $("#pgp-generate-name").value.trim(), email: $("#pgp-generate-email").value.trim(), passphrase: $("#pgp-generate-passphrase").value }) }); + $("#pgp-generate-passphrase").value = ""; await loadOpenPgp(); toast(`Generated OpenPGP key ${key.keyId}`, "success"); + } catch (error) { toast(error.message, "error"); } finally { setBusy(button, false); } + } + + async function importPgpText(text = $("#pgp-import-data").value) { + if (!String(text || "").trim()) return toast("Paste or choose an OpenPGP key first", "warning"); + try { const key = await request("/pgp/keys", { method: "POST", body: JSON.stringify({ keyData: String(text), trust: $("#pgp-import-trust").value || "unknown" }) }); $("#pgp-import-data").value = ""; await loadOpenPgp(); toast(`Imported OpenPGP key ${key.keyId}`, "success"); } + catch (error) { toast(error.message, "error"); } + } + + function bytesToBase64(bytes) { + let binary = ""; + const chunkSize = 0x8000; + for (let offset = 0; offset < bytes.length; offset += chunkSize) { + binary += String.fromCharCode(...bytes.subarray(offset, Math.min(offset + chunkSize, bytes.length))); + } + return btoa(binary); + } + + async function importPgpFile(file) { + if (!file) return; + try { + const bytes = new Uint8Array(await file.arrayBuffer()); + if (!bytes.length) return toast("The selected OpenPGP key file is empty", "warning"); + const key = await request("/pgp/keys", { + method: "POST", + body: JSON.stringify({ keyDataBase64: bytesToBase64(bytes), trust: $("#pgp-import-trust").value || "unknown" }), + }); + $("#pgp-import-data").value = ""; + await loadOpenPgp(); + toast(`Imported OpenPGP key ${key.keyId}`, "success"); + } catch (error) { toast(error.message, "error"); } + } + + async function discoverPgpEmail(email = $("#pgp-discover-email").value.trim(), quiet = false) { + if (!email) return null; + try { + const result = await request("/pgp/discover", { method: "POST", body: JSON.stringify({ email }) }); + if (result.found) { await loadOpenPgp(); if (!quiet) toast(`OpenPGP key discovered via ${(result.source || "discovery").toUpperCase()}`, "success"); return result.key; } + if (!quiet) toast(`No OpenPGP key found for ${email}`, "warning"); + return null; + } catch (error) { if (!quiet) toast(error.message, "error"); return null; } + } + + function renderIdentityPgpOptions(selectedFingerprint = null) { + const select = $("#identity-pgp-key"); + if (!select) return; + const value = selectedFingerprint ?? select.value; + select.replaceChildren(new Option("Automatic / none", "")); + for (const key of state.pgpKeys.filter((item) => item.hasSecret)) { + select.add(new Option(`${key.email || key.userId || key.keyId} · ${key.keyId}`, key.fingerprint)); + } + select.value = value || ""; + } + + function pgpModeForIdentity(identity) { + if (!identity) return "none"; + if (identity.pgp_sign_default && identity.pgp_encrypt_default) return "sign-encrypt"; + if (identity.pgp_encrypt_default) return "encrypt"; + if (identity.pgp_sign_default) return "sign"; + return "none"; + } + + function updateComposePgpUi(applyDefaults = false) { + const modeSelect = $("#compose-pgp-mode"); + const panel = $("#compose-pgp-panel"); + const option = $("#compose-pgp-option"); + if (!modeSelect || !panel) return; + const configured = state.pgpKeys.length > 0; + option?.classList.toggle("hidden", !configured); + panel.classList.toggle("hidden", !configured); + if (!configured) { + panel.open = false; + modeSelect.value = "none"; + $("#compose-pgp-inline").checked = false; + $("#compose-pgp-attach-key").checked = false; + $("#compose-pgp-attach-recipient-keys").checked = false; + state.pgpRecipientStatus = []; + return; + } + + const identity = selectedIdentity(); + const accountEmail = state.accounts.find((item) => item.id === state.accountId)?.email || ""; + const senderEmail = identity?.email || accountEmail; + const selectedKey = identity?.pgp_fingerprint ? state.pgpKeys.find((key) => key.fingerprint === identity.pgp_fingerprint) : null; + const senderPublicKey = selectedKey || state.pgpKeys.find((key) => key.email?.toLowerCase() === senderEmail.toLowerCase()); + const signingKey = selectedKey?.hasSecret ? selectedKey : state.pgpKeys.find((key) => key.hasSecret && key.email?.toLowerCase() === senderEmail.toLowerCase()); + if (applyDefaults) { + modeSelect.value = pgpModeForIdentity(identity); + $("#compose-pgp-inline").checked = Boolean(identity?.pgp_inline_default); + panel.open = false; + } + + const hasSigningKey = Boolean(signingKey?.hasSecret); + for (const value of ["sign", "sign-encrypt"]) { + const modeOption = [...modeSelect.options].find((item) => item.value === value); + if (modeOption) modeOption.disabled = !hasSigningKey; + } + if (!hasSigningKey && ["sign", "sign-encrypt"].includes(modeSelect.value)) modeSelect.value = modeSelect.value === "sign-encrypt" ? "encrypt" : "none"; + + const activeMode = modeSelect.value || "none"; + const needsSigning = activeMode === "sign" || activeMode === "sign-encrypt"; + $("#compose-pgp-passphrase-row").classList.toggle("hidden", !needsSigning); + const inline = $("#compose-pgp-inline"); + if (inline) inline.disabled = activeMode === "none" || Boolean(state.composeAttachments.length); + if (inline?.disabled) inline.checked = false; + + const senderRow = $("#compose-pgp-attach-sender-row"); + senderRow?.classList.toggle("hidden", !senderPublicKey); + if (!senderPublicKey) $("#compose-pgp-attach-key").checked = false; + + const discoveryEnabled = Boolean(state.pgpPrefs.wkdEnabled || state.pgpPrefs.hkpEnabled); + const discoverButton = $("#compose-pgp-discover"); + if (discoverButton) { + discoverButton.disabled = !discoveryEnabled; + discoverButton.title = discoveryEnabled ? "" : "Enable WKD or the HTTPS key server in OpenPGP settings first"; + } + + const recipientAttach = $("#compose-pgp-attach-recipient-keys"); + const parts = []; + if (needsSigning) parts.push(`Signing key ${signingKey.keyId}`); + if (activeMode.includes("encrypt")) parts.push(state.pgpRecipientStatus.length ? recipientPgpSummary(state.pgpRecipientStatus) : "Recipient keys not checked yet"); + if (recipientAttach?.checked) { + const available = state.pgpRecipientStatus.filter((item) => item.hasKey).length; + parts.push(state.pgpRecipientStatus.length ? `${available} visible recipient key${available === 1 ? "" : "s"} will be attached` : "Recipient keys to attach not checked yet"); + } + if (!parts.length) parts.push(senderPublicKey ? `OpenPGP ready · ${senderPublicKey.keyId}` : "OpenPGP public keys available"); + $("#compose-pgp-status").textContent = parts.join(" · "); + } + + function recipientPgpSummary(statuses) { + const missing = statuses.filter((item) => !item.hasKey); + const untrusted = statuses.filter((item) => item.hasKey && item.trust === "untrusted"); + const notTrusted = state.pgpPrefs.requireTrustedEncryption + ? statuses.filter((item) => item.hasKey && !["trusted", "verified"].includes(item.trust)) + : []; + if (missing.length) return `${missing.length} recipient key${missing.length === 1 ? "" : "s"} missing`; + if (untrusted.length) return `${untrusted.length} recipient key${untrusted.length === 1 ? "" : "s"} marked untrusted`; + if (notTrusted.length) return `${notTrusted.length} recipient key${notTrusted.length === 1 ? " is" : "s are"} not trusted by policy`; + return `Keys available for ${statuses.length} recipient${statuses.length === 1 ? "" : "s"}`; + } + + function pgpRecipientStatusHasWarning(item) { + if (!item.hasKey || item.trust === "untrusted") return true; + return Boolean(state.pgpPrefs.requireTrustedEncryption && !["trusted", "verified"].includes(item.trust)); + } + + function composePgpVisibleRecipients() { + return [...parseRecipients($("#compose-to").value), ...parseRecipients($("#compose-cc").value)]; + } + + function composePgpRecipients() { + return [...composePgpVisibleRecipients(), ...parseRecipients($("#compose-bcc").value)]; + } + + async function checkComposePgpRecipients(showToast = true, emails = composePgpRecipients()) { + if (!emails.length) { state.pgpRecipientStatus = []; updateComposePgpUi(); return []; } + try { + const statuses = await request("/pgp/recipients", { method: "POST", body: JSON.stringify({ emails }) }); + state.pgpRecipientStatus = statuses || []; + updateComposePgpUi(); + if (showToast) toast(recipientPgpSummary(state.pgpRecipientStatus), state.pgpRecipientStatus.some(pgpRecipientStatusHasWarning) ? "warning" : "success"); + return state.pgpRecipientStatus; + } catch (error) { if (showToast) toast(error.message, "error"); return []; } + } + + async function discoverComposePgpRecipients() { + const statuses = await checkComposePgpRecipients(false); + const missing = statuses.filter((item) => !item.hasKey).map((item) => item.email); + if (!missing.length) return toast("All recipient OpenPGP keys are already available", "success"); + let found = 0; + for (const email of missing) if (await discoverPgpEmail(email, true)) found += 1; + await checkComposePgpRecipients(false); + toast(found ? `Discovered ${found} OpenPGP key${found === 1 ? "" : "s"}` : "No missing OpenPGP keys were discovered", found ? "success" : "warning"); + } + + async function loadIdentities() { + try { + const data = await request("/identities", { method: "GET" }); + state.identities = data.identities || []; + renderIdentities(); + renderComposeIdentities(); + renderIdentityPgpOptions(); + updateComposePgpUi(); + } catch (error) { toast(error.message, "error"); } + } + + function renderComposeIdentities() { + const select = $("#compose-from"); + if (!select) return; + select.replaceChildren(new Option("Account default", "")); + for (const identity of state.identities) { + const label = identity.display_name ? `${identity.display_name} <${identity.email}>` : identity.email; + const option = new Option(label, String(identity.id)); + if (identity.is_default) option.selected = true; + select.add(option); + } + } + + function renderIdentities() { + const list = $("#identity-list"); + if (!list) return; + list.replaceChildren(); + if (!state.identities.length) list.append(el("div", "empty-list", "No identities yet")); + for (const identity of state.identities) { + const button = el("button", "identity-item"); + button.type = "button"; + button.innerHTML = `${escapeHtml(identity.display_name || identity.email)}${escapeHtml(identity.email)}${identity.is_default ? " · default" : ""}`; + button.addEventListener("click", () => editIdentity(identity)); + list.append(button); + } + } + + function editIdentity(identity = null) { + $("#identity-id").value = identity?.id || ""; + $("#identity-name").value = identity?.display_name || ""; + $("#identity-email").value = identity?.email || ""; + $("#identity-reply-to").value = identity?.reply_to || ""; + $("#identity-bcc").value = identity?.bcc || ""; + $("#identity-organization").value = identity?.organization || ""; + $("#identity-signature-text").value = identity?.signature_text || ""; + $("#identity-signature").innerHTML = sanitizeMessageHtml(identity?.signature_html || ""); + $("#identity-signature-position").value = identity?.signature_position || "above"; + renderIdentityPgpOptions(identity?.pgp_fingerprint || ""); + $("#identity-pgp-sign").checked = Boolean(identity?.pgp_sign_default); + $("#identity-pgp-encrypt").checked = Boolean(identity?.pgp_encrypt_default); + $("#identity-pgp-inline").checked = Boolean(identity?.pgp_inline_default); + $("#identity-signature-new").checked = identity?.signature_new !== false; + $("#identity-signature-reply").checked = identity?.signature_reply !== false; + $("#identity-signature-forward").checked = identity?.signature_forward !== false; + $("#identity-default").checked = Boolean(identity?.is_default); + $("#identity-delete").disabled = !identity; + $("#identity-dialog-title").textContent = identity ? `Edit identity · ${identity.email}` : "New identity & signature"; + const pgpConfigured = state.pgpKeys.length > 0; + $("#identity-pgp-key-row")?.classList.toggle("hidden", !pgpConfigured); + $$(".identity-pgp-default").forEach((row) => row.classList.toggle("hidden", !pgpConfigured)); + const dialog = $("#identity-dialog"); + if (!dialog.open) dialog.showModal(); + setTimeout(() => (identity ? $("#identity-name") : $("#identity-email"))?.focus(), 20); + } + + async function saveIdentity(event) { + event.preventDefault(); + const id = $("#identity-id").value; + const signatureHtml = sanitizeMessageHtml($("#identity-signature").innerHTML); + if (new TextEncoder().encode(signatureHtml).length > 3 * 1024 * 1024) return toast("HTML signature is too large. Reduce imported image sizes.", "warning"); + const payload = { + display_name: $("#identity-name").value.trim(), + email: $("#identity-email").value.trim(), + reply_to: $("#identity-reply-to").value.trim(), + bcc: $("#identity-bcc").value.trim(), + organization: $("#identity-organization").value.trim(), + signature_text: $("#identity-signature-text").value, + signature_html: signatureHtml, + signature_position: $("#identity-signature-position").value, + pgp_fingerprint: $("#identity-pgp-key").value, + pgp_sign_default: $("#identity-pgp-sign").checked, + pgp_encrypt_default: $("#identity-pgp-encrypt").checked, + pgp_inline_default: $("#identity-pgp-inline").checked, + signature_new: $("#identity-signature-new").checked, + signature_reply: $("#identity-signature-reply").checked, + signature_forward: $("#identity-signature-forward").checked, + is_default: $("#identity-default").checked, + }; + try { + await request(id ? `/identities/${id}` : "/identities", { method: id ? "PUT" : "POST", body: JSON.stringify(payload) }); + await loadIdentities(); + const saved = state.identities.find((item) => item.email === payload.email); + if (saved) { + $("#identity-id").value = saved.id || ""; + } + if ($("#identity-dialog").open) $("#identity-dialog").close(); + toast("Identity saved"); + } catch (error) { toast(error.message, "error"); } + } + + async function deleteIdentity() { + const id = $("#identity-id").value; + if (!id || !await appConfirm("Delete this identity?", { title: "Delete identity", confirmLabel: "Delete", danger: true })) return; + try { + await request(`/identities/${id}`, { method: "DELETE" }); + await loadIdentities(); + if ($("#identity-dialog").open) $("#identity-dialog").close(); + toast("Identity deleted"); + } catch (error) { toast(error.message, "error"); } + } + + function selectedIdentity() { + const id = Number($("#compose-from")?.value || 0); + return state.identities.find((identity) => identity.id === id) || state.identities.find((identity) => identity.is_default) || null; + } + + function composeActionFromTitle() { + const title = $("#compose-title").textContent; + if (title === "Reply" || title === "Reply all") return "reply"; + if (title === "Forward") return "forward"; + return "new"; + } + + function dataUrlToSignatureFile(src, index = 0) { + const match = /^data:(image\/(?:png|jpeg|gif|webp));base64,([A-Za-z0-9+/=\r\n]+)$/i.exec(String(src || "")); + if (!match) return null; + try { + const binary = atob(match[2].replace(/\s+/g, "")); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i); + const type = match[1].toLowerCase(); + const ext = type === "image/jpeg" ? "jpg" : type.slice("image/".length); + return new File([bytes], `signature-${index + 1}.${ext}`, { type }); + } catch (_) { + return null; + } + } + + async function materializeSignatureImages(signature, generation) { + const images = [...signature.querySelectorAll('img[src^="data:image/"]')]; + if (!images.length) return; + const pairs = images.map((image, index) => ({ image, file: dataUrlToSignatureFile(image.getAttribute("src"), index) })).filter((item) => item.file); + if (!pairs.length) return; + const uploaded = await uploadComposeFiles(pairs.map((item) => item.file), { announce: false }); + if (!uploaded.length) return; + if (generation !== state.composeSignatureGeneration || !signature.isConnected) { + for (const attachment of uploaded) state.inlineAttachmentIds.add(String(attachment.id)); + renderComposeAttachments(); + scheduleUnusedInlineAttachmentCleanup(); + return; + } + const draftId = ensureDraftId(); + for (let index = 0; index < Math.min(pairs.length, uploaded.length); index += 1) { + const { image } = pairs[index]; + const attachment = uploaded[index]; + state.inlineAttachmentIds.add(String(attachment.id)); + image.dataset.rustboxCid = String(attachment.id); + image.dataset.rustboxSignatureImage = "true"; + image.setAttribute("src", composeAttachmentContentUrl(draftId, attachment.id)); + } + renderComposeAttachments(); + scheduleDraftSave(); + } + + function insertIdentitySignature(editor, action = composeActionFromTitle()) { + state.composeSignatureGeneration += 1; + const generation = state.composeSignatureGeneration; + editor.querySelectorAll(".compose-signature").forEach((node) => node.remove()); + scheduleUnusedInlineAttachmentCleanup(); + const identity = selectedIdentity(); + if (!identity) return; + const enabled = action === "reply" ? identity.signature_reply !== false : action === "forward" ? identity.signature_forward !== false : identity.signature_new !== false; + if (!enabled) return; + const format = $("#compose-mode").value; + const signature = el("div", "compose-signature"); + if (format === "html") { + if (!identity.signature_html) return; + signature.innerHTML = sanitizeMessageHtml(identity.signature_html); + } else { + const text = identity.signature_text || ""; + if (!text) return; + signature.textContent = `\n-- \n${text}\n`; + } + const quote = editor.querySelector(".compose-quote"); + if (quote && identity.signature_position !== "below") editor.insertBefore(signature, quote); + else editor.append(signature); + if (format === "html") void materializeSignatureImages(signature, generation); + } + + function readFileAsDataUrl(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(String(reader.result || "")); + reader.onerror = () => reject(reader.error || new Error("Could not read image")); + reader.readAsDataURL(file); + }); + } + + async function insertIdentitySignatureImage(file, range = state.identitySignatureRange) { + state.identitySignatureRange = null; + if (!file) return; + if (!/^image\/(png|jpeg|gif|webp)$/i.test(file.type || "")) return toast("Choose a PNG, JPEG, GIF or WebP image", "warning"); + if (file.size > 2 * 1024 * 1024) return toast("Signature image must be 2 MB or smaller", "warning"); + const editor = $("#identity-signature"); + try { + const src = await readFileAsDataUrl(file); + editor.focus(); + restoreEditorRange(range, editor); + const alt = escapeHtml((file.name || "signature image").replace(/\.[^.]+$/, "")); + document.execCommand("insertHTML", false, `${alt}`); + } catch (error) { + toast(error.message || "Could not import signature image", "error"); + } + } + + + function populateSpecialFolderSelects() { + const roles = ["sent", "drafts", "trash", "junk", "archive"]; + for (const role of roles) { + const select = $(`#special-${role}`); + if (!select) continue; + const automaticLabel = role === "junk" ? "Use default / auto-detect" : "Automatic / not set"; + select.replaceChildren(new Option(automaticLabel, "")); + for (const folder of orderedFolders()) select.add(new Option(folder.name, folder.name)); + select.value = state.specialFolders?.[role] || ""; + } + populateSpamDefaultFolderSelect(); + } + + function populateSpamDefaultFolderSelect() { + const select = $("#spam-default-folder"); + if (!select) return; + const stored = (localStorage.getItem(SPAM_DEFAULT_FOLDER_KEY) || "").trim(); + select.replaceChildren(new Option("Automatic IMAP Junk/Spam", "")); + for (const folder of orderedFolders()) select.add(new Option(folder.name, folder.name)); + if (stored && !state.folders.some((folder) => folder.name === stored)) { + const stale = new Option(`${stored} (not available in this account)`, stored); + stale.disabled = true; + select.add(stale); + } + select.value = stored; + } + + async function loadSpecialFolders() { + try { state.specialFolders = { ...state.specialFolders, ...(await request("/settings/special-folders", { method: "GET" })) }; } + catch (error) { console.warn("Could not load special folders", error); } + populateSpecialFolderSelects(); + } + + async function saveSpecialFolders() { + const value = {}; + for (const role of ["sent", "drafts", "trash", "junk", "archive"]) value[role] = $(`#special-${role}`).value || null; + const browserSpamDefault = $("#spam-default-folder")?.value || ""; + try { + state.specialFolders = await request("/settings/special-folders", { method: "PUT", body: JSON.stringify(value) }); + if (browserSpamDefault) localStorage.setItem(SPAM_DEFAULT_FOLDER_KEY, browserSpamDefault); + else localStorage.removeItem(SPAM_DEFAULT_FOLDER_KEY); + populateSpecialFolderSelects(); + toast("Folder settings saved"); + } catch (error) { toast(error.message, "error"); } + } + + async function autoDetectSpecialFolders() { + for (const role of ["sent", "drafts", "trash", "junk", "archive"]) { + const select = $(`#special-${role}`); + if (select) select.value = autoDetectSpecialFolder(role) || ""; + } + await saveSpecialFolders(); + } + + async function loadQuota() { + const summary = $("#quota-summary span"); + const bar = $("#quota-bar"); + if (!summary || !bar) return; + summary.textContent = "Loading quota…"; + bar.style.width = "0%"; + try { + const quota = await request("/quota", { method: "GET" }); + const used = Number(quota.usage_bytes || 0); + const limit = Number(quota.limit_bytes || 0); + if (limit > 0) { + const percent = Math.max(0, Math.min(100, (used / limit) * 100)); + summary.textContent = `${formatBytes(used)} of ${formatBytes(limit)} (${percent.toFixed(1)}%)`; + bar.style.width = `${percent}%`; + const quotaBox = $("#quota-summary"); + const level = percent >= 95 ? "danger" : percent >= 80 ? "warning" : "normal"; + quotaBox?.classList.toggle("quota-warning", level === "warning"); + quotaBox?.classList.toggle("quota-danger", level === "danger"); + if (level !== "normal" && state.quotaWarningLevel !== `${state.accountId}:${level}`) { + state.quotaWarningLevel = `${state.accountId}:${level}`; + toast(level === "danger" ? `Mailbox storage is ${percent.toFixed(0)}% full` : `Mailbox storage is ${percent.toFixed(0)}% used`, level === "danger" ? "error" : "warning"); + } + } else { + summary.textContent = `${formatBytes(used)} used · server limit unavailable`; + } + } catch (error) { summary.textContent = `Quota unavailable: ${error.message}`; } + } + + function openSettings(tab = "appearance") { + selectSettingsTab(tab); + $("#settings-dialog").showModal(); + if (tab === "sieve") loadSieve(); + if (tab === "identities") { loadIdentities(); loadOpenPgp(); } + if (tab === "openpgp") loadOpenPgp(); + if (tab === "notifications") loadNotificationPreferences(); + if (tab === "mailbox") loadMailPreferences(); + if (tab === "diagnostics") loadDiagnostics(); + if (tab === "account") { renderAccountSettings(); loadActiveSessions(); } + if (tab === "folders") { loadSpecialFolders(); loadQuota(); } + } + + function selectSettingsTab(name) { + $$(".settings-tab").forEach((button) => button.classList.toggle("active", button.dataset.tab === name)); + $$(".settings-panel").forEach((panel) => panel.classList.toggle("hidden", panel.dataset.panel !== name)); + } + + async function loadNotificationPreferences() { + try { + const prefs = await request("/settings/notifications", { method: "GET" }); + state.notificationPrefs = { ...state.notificationPrefs, ...prefs }; + $("#notify-enabled").checked = Boolean(prefs.enabled); + $("#notify-sound").checked = Boolean(prefs.sound); + $("#notify-dnd-enabled").checked = Boolean(prefs.dnd_enabled); + $("#notify-dnd-start").value = prefs.dnd_start || "22:00"; + $("#notify-dnd-end").value = prefs.dnd_end || "07:00"; + renderNotificationFolders(prefs.folders || ["INBOX"]); + } catch (error) { toast(error.message, "error"); } + } + + function renderNotificationFolders(selected) { + const set = new Set(selected || []); + const list = $("#notify-folders"); + list.replaceChildren(); + for (const folder of orderedFolders()) { + const label = el("label", "checkbox-row"); + const input = document.createElement("input"); + input.type = "checkbox"; + input.value = folder.name; + input.checked = set.has(folder.name); + label.append(input, el("span", "", folder.name)); + list.append(label); + } + } + + async function saveNotificationPreferences() { + const folders = $$('#notify-folders input[type="checkbox"]:checked').map((input) => input.value); + try { + const prefs = await request("/settings/notifications", { method: "PUT", body: JSON.stringify({ enabled: $("#notify-enabled").checked, sound: $("#notify-sound").checked, folders, dnd_enabled: $("#notify-dnd-enabled").checked, dnd_start: $("#notify-dnd-start").value || "22:00", dnd_end: $("#notify-dnd-end").value || "07:00" }) }); + state.notificationPrefs = { ...state.notificationPrefs, ...prefs }; + toast("Notification settings saved"); + } catch (error) { toast(error.message, "error"); } + } + + async function requestNotificationPermission() { + if (!("Notification" in window)) return toast("Notifications are not supported by this browser", "error"); + const result = await Notification.requestPermission(); + toast(`Notification permission: ${result}`); + } + + function dndActive(prefs = state.notificationPrefs) { + if (!prefs?.dnd_enabled) return false; + const toMinutes = (value) => { + const [h, m] = String(value || "00:00").split(":").map(Number); + return h * 60 + m; + }; + const now = new Date(); + const current = now.getHours() * 60 + now.getMinutes(); + const start = toMinutes(prefs.dnd_start); + const end = toMinutes(prefs.dnd_end); + if (start === end) return true; + return start < end ? current >= start && current < end : current >= start || current < end; + } + + function playNotificationSound() { + try { + const AudioContext = window.AudioContext || window.webkitAudioContext; + if (!AudioContext) return; + const context = new AudioContext(); + const oscillator = context.createOscillator(); + const gain = context.createGain(); + oscillator.frequency.value = 620; + gain.gain.setValueAtTime(0.0001, context.currentTime); + gain.gain.exponentialRampToValueAtTime(0.035, context.currentTime + 0.015); + gain.gain.exponentialRampToValueAtTime(0.0001, context.currentTime + 0.16); + oscillator.connect(gain); gain.connect(context.destination); + oscillator.start(); oscillator.stop(context.currentTime + 0.17); + oscillator.onended = () => context.close().catch(() => { }); + } catch (_) { } + } + + function notifyNewMail(data, accountId) { + const prefs = state.notificationPrefs || {}; + if (!prefs.enabled || dndActive(prefs)) return; + const folders = prefs.folders || []; + if (folders.length && !folders.includes(data.folder)) return; + if ("Notification" in window && Notification.permission === "granted") { + const account = state.accounts.find((item) => item.id === accountId); + const title = data.latest_sender || account?.email || "New mail"; + const body = data.latest_subject || `${data.count || 1} new message(s) in ${data.folder}`; + const notification = new Notification(title, { body, tag: `rustbox-${accountId}-${data.folder}`, renotify: true }); + notification.onclick = () => { window.focus(); notification.close(); }; + } + if (prefs.sound) playNotificationSound(); + } + + function scheduleRealtimeRefresh(event, accountId) { + if (accountId !== state.accountId) return; + clearTimeout(state.realtimeRefreshTimer); + state.realtimeRefreshTimer = setTimeout(async () => { + await refreshFolderListOnly(); + if (["NewMessages", "FlagsChanged"].includes(event.type) && event.data?.folder === state.folder) { + if (state.searchContext) await runSearch(new URLSearchParams(state.searchContext.params), state.searchContext.label, { restoreScrollTop: state.searchContext.scrollTop }); + else await loadMessages(true); + } + }, 250); + } + + function connectRealtime() { + if (!state.accounts.length) return; + if (state.ws && [WebSocket.OPEN, WebSocket.CONNECTING].includes(state.ws.readyState)) return; + clearTimeout(state.wsRetryTimer); + const protocol = location.protocol === "https:" ? "wss:" : "ws:"; + const socket = new WebSocket(`${protocol}//${location.host}${BASE}/api/ws`); + state.ws = socket; + socket.onmessage = (message) => { + try { + const payload = JSON.parse(message.data); + const event = payload.event || {}; + if (event.type === "NewMessages") notifyNewMail(event.data || {}, payload.accountId); + if (["NewMessages", "FlagsChanged", "FolderUpdated"].includes(event.type)) scheduleRealtimeRefresh(event, payload.accountId); + } catch (error) { console.warn("Invalid realtime event", error); } + }; + socket.onclose = () => { + if (state.ws === socket) state.ws = null; + if (state.accounts.length) state.wsRetryTimer = setTimeout(connectRealtime, 2500); + }; + socket.onerror = () => socket.close(); + } + + function disconnectRealtime() { + clearTimeout(state.wsRetryTimer); + clearTimeout(state.realtimeRefreshTimer); + state.wsRetryTimer = null; + state.realtimeRefreshTimer = null; + const socket = state.ws; + state.ws = null; + if (socket && socket.readyState < WebSocket.CLOSING) socket.close(); + } + + function capabilityBadge(label, supported) { + return `${escapeHtml(label)}${supported ? "yes" : "no"}`; + } + + async function loadDiagnostics() { + const imapStatus = $("#diagnostic-imap-status"); + const smtpStatus = $("#diagnostic-smtp-status"); + const sieveStatus = $("#diagnostic-sieve-status"); + const wsStatus = $("#diagnostic-ws-status"); + const syncStatus = $("#diagnostic-sync-status"); + const searchStatus = $("#diagnostic-search-status"); + imapStatus.textContent = "Checking"; + smtpStatus.textContent = "Checking"; + sieveStatus.textContent = "Checking"; + syncStatus.textContent = "Checking"; + searchStatus.textContent = "Checking"; + wsStatus.textContent = state.ws?.readyState === WebSocket.OPEN ? "Connected" : "Disconnected"; + try { + const info = await request("/diagnostics/imap", { method: "GET" }); + imapStatus.textContent = info.connected ? "Connected" : "Unavailable"; + $("#diagnostic-imap-summary").innerHTML = [ + ["IDLE", info.idle], ["MOVE", info.moveExtension], ["UIDPLUS", info.uidplus], ["CONDSTORE", info.condstore], + ["QRESYNC", info.qresync], ["SORT", info.sort], ["THREAD", info.thread], ["NAMESPACE", info.namespace], + ["SPECIAL-USE", info.specialUse], ["QUOTA", info.quota], ["ACL", info.acl], ["LIST-EXTENDED", info.listExtended], ["UTF8=ACCEPT", info.utf8Accept], ["CUSTOM KEYWORDS", info.customKeywords], + ].map(([label, supported]) => capabilityBadge(label, supported)).join(""); + $("#diagnostic-imap-caps").textContent = (info.capabilities || []).join(" · ") || "No capabilities reported"; + } catch (error) { + imapStatus.textContent = "Unavailable"; + $("#diagnostic-imap-summary").textContent = error.message; + $("#diagnostic-imap-caps").textContent = ""; + } + try { + const info = await request("/diagnostics/smtp", { method: "GET" }); + smtpStatus.textContent = info.connected ? "Connected" : "Unavailable"; + $("#diagnostic-smtp-summary").textContent = info.connected ? (info.secureTransport ? "Authenticated connection · TLS enabled" : "Authenticated connection · plaintext transport") : "SMTP unavailable"; + } catch (error) { + smtpStatus.textContent = "Unavailable"; + $("#diagnostic-smtp-summary").textContent = error.message; + } + try { + const info = await request("/sieve/status", { method: "GET" }); + sieveStatus.textContent = info.connected ? "Connected" : "Unavailable"; + $("#diagnostic-sieve-summary").textContent = `${info.tlsMode || ""}${info.implementation ? ` · ${info.implementation}` : ""}${info.extensions?.length ? ` · ${info.extensions.join(", ")}` : ""}`; + } catch (error) { + sieveStatus.textContent = "Unavailable"; + $("#diagnostic-sieve-summary").textContent = error.message; + } + try { + const info = await request("/diagnostics/sync", { method: "GET" }); + syncStatus.textContent = info.unsyncedFolders ? "Partial" : "Ready"; + const folderSync = info.latestFolderListSync ? formatDate(info.latestFolderListSync, true) : "never"; + const messageSync = info.latestMessageSync ? formatDate(info.latestMessageSync, true) : "never"; + $("#diagnostic-sync-summary").textContent = `${info.cachedFolders} cached folders · ${info.cachedMessages} cached messages · folder list ${folderSync} · messages ${messageSync}${info.unsyncedFolders ? ` · ${info.unsyncedFolders} folders awaiting message sync` : ""} · ${info.idleTasks || 0} IDLE task(s) / ${info.idleSubscribers || 0} subscriber(s)`; + } catch (error) { + syncStatus.textContent = "Unavailable"; + $("#diagnostic-sync-summary").textContent = error.message; + } + try { + const info = await request("/diagnostics/search", { method: "GET" }); + searchStatus.textContent = "Ready"; + $("#diagnostic-search-summary").textContent = `${info.indexedDocuments} indexed · ${info.cachedMessages} cached messages · ${info.cachedBodies} cached bodies${info.excludedMessages ? ` · ${info.excludedMessages} excluded` : ""}`; + } catch (error) { + searchStatus.textContent = "Unavailable"; + $("#diagnostic-search-summary").textContent = error.message; + } + } + + async function reconnectImap() { + const button = $("#diagnostic-imap-reconnect"); + if (button) button.disabled = true; + try { + const result = await request("/diagnostics/imap/reconnect", { method: "POST", body: "{}" }); + toast(`IMAP reconnected · ${result.capabilityCount || 0} capabilities`); + await loadDiagnostics(); + await loadFolders(false); + } catch (error) { + toast(error.message, "error"); + } finally { + if (button) button.disabled = false; + } + } + + async function rebuildSearchIndex() { + const button = $("#diagnostic-search-rebuild"); + if (!await appConfirm("Rebuild the search index from the local message cache?", { title: "Rebuild search index", confirmLabel: "Rebuild" })) return; + button.disabled = true; + try { + const result = await request("/diagnostics/search/rebuild", { method: "POST", body: "{}" }); + toast(`Search index rebuilt: ${result.indexedDocuments} messages`); + await loadDiagnostics(); + } catch (error) { + toast(error.message, "error"); + } finally { + button.disabled = false; + } + } + + async function loadSieve() { + const status = $("#sieve-status"); + status.textContent = "Connecting…"; + try { + const [info, scripts] = await Promise.all([request("/sieve/status", { method: "GET" }), request("/sieve/scripts", { method: "GET" })]); + status.textContent = `${info.host}:${info.port} · ${info.tlsMode} · ${info.implementation || "ManageSieve"}`; + state.sieveInfo = info; + state.sieveScripts = scripts.scripts || []; + renderSieveScripts(); + const selectedName = $("#sieve-name")?.value.trim(); + const target = state.sieveScripts.find((script) => script.name === selectedName) + || state.sieveScripts.find((script) => script.active) + || state.sieveScripts[0]; + if (target) await openSieveScript(target.name); + else { + $("#sieve-editor").value = ""; + clearSieveRuleBuilder(); + updateSieveBuilder(); + } + } catch (error) { + status.textContent = error.message; + toast(error.message, "error"); + } + } + + function sieveQuote(value) { + return String(value ?? "").replaceAll("\\", "\\\\").replaceAll('"', '\\"').replace(/[\r\n]+/g, " "); + } + + function sieveBuilderRequirements(condition, action) { + const requirements = new Set(); + if (["move", "copy"].includes(action)) requirements.add("fileinto"); + if (["copy", "redirect-copy"].includes(action)) requirements.add("copy"); + if (["mark-read", "add-flag", "set-flags"].includes(action)) requirements.add("imap4flags"); + return [...requirements]; + } + + function mergeSieveRequirements(script, requirements) { + const found = new Set(requirements); + const without = String(script || "").replace(/^\s*require\s+(?:\[([^\]]*)\]|"([^"]+)")\s*;\s*$/gim, (_line, list, single) => { + const raw = single ? [`"${single}"`] : (list || "").split(","); + for (const item of raw) { + const match = item.match(/"([^"]+)"/); + if (match) found.add(match[1]); + } + return ""; + }).trim(); + const header = found.size ? `require [${[...found].sort().map((item) => `"${sieveQuote(item)}"`).join(",")}];` : ""; + return [header, without].filter(Boolean).join("\n\n").trim() + "\n"; + } + + function sieveMatchLabel(match) { + return match === "is" ? "is exactly" : match === "matches" ? "matches pattern" : "contains"; + } + + function sieveConditionLabel(condition, header = "") { + if (condition === "from") return "From address"; + if (condition === "from-domain") return "From domain"; + if (condition === "to") return "To / Cc address"; + if (condition === "header-from") return "From header"; + if (condition === "header-to") return "To header"; + if (condition === "subject") return "Subject"; + if (condition === "list-id") return "List-ID"; + return header || "Header"; + } + + function buildSieveCondition(suffix = "", required = true) { + const condition = $(`#sieve-builder-condition${suffix}`).value; + const value = $(`#sieve-builder-value${suffix}`).value.trim(); + if (!condition || !value) { + if (required) throw new Error("Rule value is required"); + return null; + } + const match = $(`#sieve-builder-match${suffix}`).value; + const matchToken = match === "is" ? ":is" : match === "matches" ? ":matches" : ":contains"; + let test; + let header = ""; + if (condition === "from") test = `address ${matchToken} "from" "${sieveQuote(value)}"`; + else if (condition === "from-domain") test = `address :domain ${matchToken} "from" "${sieveQuote(value)}"`; + else if (condition === "to") test = `address ${matchToken} ["to", "cc"] "${sieveQuote(value)}"`; + else if (condition === "header-from") test = `header ${matchToken} "from" "${sieveQuote(value)}"`; + else if (condition === "header-to") test = `header ${matchToken} "to" "${sieveQuote(value)}"`; + else if (condition === "subject") test = `header ${matchToken} "subject" "${sieveQuote(value)}"`; + else if (condition === "list-id") test = `header ${matchToken} "list-id" "${sieveQuote(value)}"`; + else { + header = $(`#sieve-builder-header${suffix}`).value.trim(); + if (!/^[A-Za-z0-9-]+$/.test(header)) { + const label = suffix === "-3" ? "Third custom" : suffix === "-2" ? "Second custom" : "Custom"; + throw new Error(`${label} header name is invalid`); + } + test = `header ${matchToken} "${sieveQuote(header)}" "${sieveQuote(value)}"`; + } + const negate = $(`#sieve-builder-negate${suffix}`).checked; + if (negate) test = `not ${test}`; + const summary = `${negate ? "NOT " : ""}${sieveConditionLabel(condition, header)} ${sieveMatchLabel(match)} “${value}”`; + return { test, condition, match, value, header, negate, summary }; + } + + function sieveFlagValues(target) { + return String(target || "").split(",").map((value) => value.trim()).filter(Boolean); + } + + function buildSieveActionFromValues(action, target, label = "Action") { + let command; + let summary; + if (action === "move") { + if (!target) throw new Error(`${label} target folder is required`); + command = `fileinto "${sieveQuote(target)}";`; + summary = `Move to “${target}”`; + } else if (action === "copy") { + if (!target) throw new Error(`${label} target folder is required`); + command = `fileinto :copy "${sieveQuote(target)}";`; + summary = `Copy to “${target}”`; + } else if (action === "redirect" || action === "redirect-copy") { + if (!target || !target.includes("@")) throw new Error(`${label} redirect address is required`); + command = `redirect${action === "redirect-copy" ? " :copy" : ""} "${sieveQuote(target)}";`; + summary = action === "redirect-copy" ? `Redirect a copy to ${target}` : `Redirect to ${target}`; + } else if (action === "discard") { + command = "discard;"; + summary = "Discard the message"; + } else if (action === "mark-read") { + command = 'addflag "\\Seen";'; + summary = "Mark as read"; + } else if (action === "add-flag") { + if (!target) throw new Error(`${label} IMAP flag is required`); + command = `addflag "${sieveQuote(target)}";`; + summary = `Add IMAP flag ${target}`; + } else if (action === "set-flags") { + const flags = sieveFlagValues(target); + if (!flags.length) throw new Error(`${label} IMAP flags are required`); + command = `setflag [${flags.map((flag) => `"${sieveQuote(flag)}"`).join(",")}];`; + summary = `Set IMAP flags ${flags.join(", ")}`; + } else { + command = "keep;"; + summary = "Keep in the inbox"; + } + return { action, target, command, summary, requirements: sieveBuilderRequirements("", action) }; + } + + function buildSieveAction(suffix = "", required = true) { + const action = $(`#sieve-builder-action${suffix}`).value; + const target = $(`#sieve-builder-target${suffix}`)?.value.trim() || ""; + if (!action) { + if (required) throw new Error("Action is required"); + return null; + } + return buildSieveActionFromValues(action, target, suffix ? "Second action" : "Action"); + } + + function newSieveVisualRuleId() { + const random = globalThis.crypto?.randomUUID?.() || `${Date.now()}-${Math.random().toString(16).slice(2)}`; + return `rule-${random.replace(/[^A-Za-z0-9_-]/g, "")}`; + } + + function sieveUnescape(value) { + return String(value || "").replace(/\\([\\"])/g, "$1"); + } + + function sieveQuotedValues(value) { + const values = []; + const pattern = /"((?:\\.|[^"\\])*)"/g; + let match; + while ((match = pattern.exec(String(value || "")))) values.push(sieveUnescape(match[1])); + return values; + } + + function splitSieveTopLevel(value) { + const result = []; + let start = 0; + let depth = 0; + let bracket = 0; + let quoted = false; + let escaped = false; + const text = String(value || ""); + for (let index = 0; index < text.length; index += 1) { + const char = text[index]; + if (quoted) { + if (escaped) escaped = false; + else if (char === "\\") escaped = true; + else if (char === '"') quoted = false; + continue; + } + if (char === '"') { quoted = true; continue; } + if (char === "(") depth += 1; + else if (char === ")") depth = Math.max(0, depth - 1); + else if (char === "[") bracket += 1; + else if (char === "]") bracket = Math.max(0, bracket - 1); + else if (char === "," && depth === 0 && bracket === 0) { + result.push(text.slice(start, index).trim()); + start = index + 1; + } + } + result.push(text.slice(start).trim()); + return result.filter(Boolean); + } + + function splitSieveStatements(value) { + const result = []; + let start = 0; + let quoted = false; + let escaped = false; + const text = String(value || ""); + for (let index = 0; index < text.length; index += 1) { + const char = text[index]; + if (quoted) { + if (escaped) escaped = false; + else if (char === "\\") escaped = true; + else if (char === '"') quoted = false; + continue; + } + if (char === '"') { quoted = true; continue; } + if (char === "#") { + while (index < text.length && text[index] !== "\n") index += 1; + continue; + } + if (char === ";") { + const statement = text.slice(start, index + 1).trim(); + if (statement) result.push(statement); + start = index + 1; + } + } + return result; + } + + function findSieveClosingBrace(source, openIndex) { + let depth = 0; + let quoted = false; + let escaped = false; + for (let index = openIndex; index < source.length; index += 1) { + const char = source[index]; + if (quoted) { + if (escaped) escaped = false; + else if (char === "\\") escaped = true; + else if (char === '"') quoted = false; + continue; + } + if (char === '"') { quoted = true; continue; } + if (char === "#") { + while (index < source.length && source[index] !== "\n") index += 1; + continue; + } + if (char === "{") depth += 1; + else if (char === "}") { + depth -= 1; + if (depth === 0) return index; + } + } + return -1; + } + + function parseSieveConditionToken(value) { + let raw = String(value || "").trim(); + const negate = /^not\s+/i.test(raw); + if (negate) raw = raw.replace(/^not\s+/i, "").trim(); + const match = raw.match(/^(header|address)\s+(?:(:domain)\s+)?:(contains|is|matches)\s+([\s\S]+)$/i); + if (!match) return { raw: value, supported: false, summary: String(value || "").trim() || "Unsupported condition" }; + const values = sieveQuotedValues(match[4]); + if (values.length < 2) return { raw: value, supported: false, summary: String(value || "").trim() }; + const targetValue = values.at(-1); + const fields = values.slice(0, -1).map((item) => item.toLowerCase()); + const primaryField = fields[0] || ""; + let kind = "header"; + let header = primaryField; + const testType = match[1].toLowerCase(); + if (testType === "address" && primaryField === "from") kind = match[2] ? "from-domain" : "from"; + else if (testType === "address" && (fields.includes("to") || fields.includes("cc"))) kind = "to"; + else if (testType === "header" && primaryField === "from") kind = "header-from"; + else if (testType === "header" && primaryField === "to" && fields.length === 1) kind = "header-to"; + else if (primaryField === "subject") kind = "subject"; + else if (primaryField === "list-id") kind = "list-id"; + const comparator = match[3].toLowerCase(); + const summary = `${negate ? "NOT " : ""}${sieveConditionLabel(kind, header)} ${sieveMatchLabel(comparator)} “${targetValue}”`; + return { raw: value, supported: true, kind, header, match: comparator, value: targetValue, negate, summary }; + } + + function parseSieveConditionExpression(value) { + const raw = String(value || "").trim(); + const group = raw.match(/^(allof|anyof)\s*\(([\s\S]*)\)$/i); + if (!group) { + const condition = parseSieveConditionToken(raw); + return { logic: "allof", conditions: [condition], supported: condition.supported, mixed: false, summary: condition.summary }; + } + const logic = group[1].toLowerCase(); + const parts = splitSieveTopLevel(group[2]); + const conditions = []; + let supported = true; + let mixed = false; + const summaries = []; + for (const part of parts) { + const parsed = parseSieveConditionExpression(part); + if (parsed.conditions.length > 1 && parsed.logic !== logic) mixed = true; + conditions.push(...parsed.conditions); + supported = supported && parsed.supported; + mixed = mixed || parsed.mixed; + summaries.push(parsed.summary); + } + return { logic, conditions, supported, mixed, summary: summaries.join(logic === "anyof" ? " OR " : " AND ") }; + } + + function parseSieveActionStatement(statement) { + const raw = String(statement || "").trim().replace(/;\s*$/, ""); + let match = raw.match(/^fileinto\s+(:copy\s+)?"((?:\\.|[^"\\])*)"$/i); + if (match) { + const target = sieveUnescape(match[2]); + const action = match[1] ? "copy" : "move"; + return { action, target, supported: true, summary: action === "copy" ? `Copy to “${target}”` : `Move to “${target}”` }; + } + match = raw.match(/^redirect\s+(:copy\s+)?"((?:\\.|[^"\\])*)"$/i); + if (match) { + const target = sieveUnescape(match[2]); + const action = match[1] ? "redirect-copy" : "redirect"; + return { action, target, supported: true, summary: action === "redirect-copy" ? `Redirect a copy to ${target}` : `Redirect to ${target}` }; + } + match = raw.match(/^addflag\s+([\s\S]+)$/i); + if (match) { + const flags = sieveQuotedValues(match[1]); + const target = flags.join(", "); + const markRead = flags.length === 1 && flags[0] === "\\Seen"; + return { action: markRead ? "mark-read" : "add-flag", target: markRead ? "" : target, supported: Boolean(flags.length), summary: markRead ? "Mark as read" : `Add IMAP flag ${target}` }; + } + match = raw.match(/^setflag\s+([\s\S]+)$/i); + if (match) { + const flags = sieveQuotedValues(match[1]); + const target = flags.join(", "); + return { action: "set-flags", target, supported: Boolean(flags.length), summary: `Set IMAP flags ${target}` }; + } + if (/^discard$/i.test(raw)) return { action: "discard", target: "", supported: true, summary: "Discard the message" }; + if (/^keep$/i.test(raw)) return { action: "keep", target: "", supported: true, summary: "Keep in the inbox" }; + if (/^stop$/i.test(raw)) return { action: "stop", target: "", supported: true, stop: true, summary: "Stop processing more rules" }; + return { action: "unsupported", target: "", supported: false, summary: raw || "Unsupported action", raw }; + } + + function parseSieveRules(script = $("#sieve-editor")?.value || "") { + const source = String(script || ""); + const markerPattern = /^\s*#\s*(?:rule:\[([^\]\r\n]+)\]|RUSTBOX-RULE-BEGIN\s+([A-Za-z0-9_-]+))\s*$/gim; + const markers = []; + let marker; + while ((marker = markerPattern.exec(source))) markers.push({ index: marker.index, end: markerPattern.lastIndex, name: marker[1]?.trim() || "", rustboxId: marker[2] || "" }); + const rules = []; + const nameCounts = new Map(); + for (let markerIndex = 0; markerIndex < markers.length; markerIndex += 1) { + const info = markers[markerIndex]; + const boundary = markers[markerIndex + 1]?.index ?? source.length; + const segment = source.slice(info.end, boundary); + const ifMatch = /\bif\s+([\s\S]*?)\{/i.exec(segment); + if (!ifMatch) continue; + const openBrace = info.end + ifMatch.index + ifMatch[0].lastIndexOf("{"); + const closeBrace = findSieveClosingBrace(source, openBrace); + if (closeBrace < 0 || closeBrace >= boundary) continue; + const conditionOffset = ifMatch[0].indexOf(ifMatch[1]); + const conditionStart = info.end + ifMatch.index + conditionOffset; + const conditionEnd = conditionStart + ifMatch[1].length; + const rawCondition = ifMatch[1].trim(); + const disabledMatch = rawCondition.match(/^false\s*#\s*([\s\S]+)$/i); + const enabled = !disabledMatch; + const actualCondition = (disabledMatch ? disabledMatch[1] : rawCondition).trim(); + const condition = parseSieveConditionExpression(actualCondition); + const body = source.slice(openBrace + 1, closeBrace); + const parsedActions = splitSieveStatements(body).map(parseSieveActionStatement); + const stop = parsedActions.some((action) => action.stop); + const actions = parsedActions.filter((action) => !action.stop); + let end = closeBrace + 1; + if (info.rustboxId) { + const tail = source.slice(end, boundary); + const endMarker = new RegExp(`^\\s*#\\s*RUSTBOX-RULE-END\\s*$`, "im").exec(tail); + if (endMarker) end += endMarker.index + endMarker[0].length; + } + const fallbackName = info.rustboxId ? `RustBox ${info.rustboxId.replace(/^rule-/, "").slice(0, 12)}` : `Rule ${rules.length + 1}`; + const name = info.name || fallbackName; + const count = (nameCounts.get(name) || 0) + 1; + nameCounts.set(name, count); + const id = `${info.rustboxId ? "rustbox" : "named"}:${name}:${count}`; + const editable = condition.supported && !condition.mixed && actions.every((action) => action.supported); + rules.push({ + id, name, enabled, start: info.index, end, full: source.slice(info.index, end), rawCondition, actualCondition, + conditionStart, conditionEnd, condition, actions, stop, editable, rustboxId: info.rustboxId, + }); + } + return rules; + } + + function setSieveConditionControls(suffix, condition) { + const kind = condition?.kind || ""; + $(`#sieve-builder-condition${suffix}`).value = kind; + $(`#sieve-builder-match${suffix}`).value = condition?.match || "contains"; + $(`#sieve-builder-value${suffix}`).value = condition?.value || ""; + $(`#sieve-builder-negate${suffix}`).checked = Boolean(condition?.negate); + const header = $(`#sieve-builder-header${suffix}`); + if (header) header.value = condition?.header || ""; + } + + function setSieveDynamicCondition(row, condition, logic) { + row.querySelector(".sieve-dynamic-logic").value = logic === "anyof" ? "anyof" : "allof"; + row.querySelector(".sieve-dynamic-kind").value = condition.kind || "from"; + row.querySelector(".sieve-dynamic-match").value = condition.match || "contains"; + row.querySelector(".sieve-dynamic-header").value = condition.header || ""; + row.querySelector(".sieve-dynamic-value").value = condition.value || ""; + row.querySelector(".sieve-dynamic-negate").checked = Boolean(condition.negate); + } + + function setSieveActionControls(suffix, action) { + $(`#sieve-builder-action${suffix}`).value = action?.action || (suffix ? "" : "move"); + const target = $(`#sieve-builder-target${suffix}`); + if (target) target.value = action?.target || ""; + } + + function setSieveDynamicAction(row, action) { + row.querySelector(".sieve-dynamic-action").value = action.action || "keep"; + row.querySelector(".sieve-dynamic-target").value = action.target || ""; + configureSieveDynamicAction(row); + } + + function showSieveRuleDialog(mode = "new", ruleName = "") { + const dialog = $("#sieve-rule-dialog"); + if (!dialog) return; + $("#sieve-rule-dialog-title").textContent = mode === "edit" ? `Edit filter · ${ruleName}` : "Create filter"; + $("#sieve-rule-dialog-subtitle").textContent = mode === "edit" + ? "Change the rule visually; the live preview shows exactly what will be written to Sieve." + : "Build the rule visually and verify the preview before applying it."; + if (!dialog.open) dialog.showModal(); + } + + function openNewSieveRuleDialog() { + clearSieveRuleBuilder(); + showSieveRuleDialog("new"); + setTimeout(() => $("#sieve-builder-name")?.focus(), 20); + } + + function loadSieveRuleIntoBuilder(rule) { + if (!rule?.editable) return toast("This rule uses Sieve syntax the visual editor cannot safely rewrite. Edit it in Advanced Sieve source.", "warning"); + state.sieveEditingRuleId = rule.id; + $("#sieve-builder-name").value = rule.name; + $("#sieve-builder-enabled").checked = rule.enabled; + $("#sieve-builder-stop").checked = rule.stop; + $("#sieve-builder-extra-conditions").replaceChildren(); + $("#sieve-builder-extra-actions").replaceChildren(); + const conditions = rule.condition.conditions; + setSieveConditionControls("", conditions[0]); + setSieveConditionControls("-2", conditions[1] || null); + setSieveConditionControls("-3", conditions[2] || null); + $("#sieve-builder-logic").value = rule.condition.logic === "anyof" ? "anyof" : "allof"; + $("#sieve-builder-logic-3").value = rule.condition.logic === "anyof" ? "anyof" : "allof"; + for (const condition of conditions.slice(3)) { + const row = createSieveDynamicCondition(); + setSieveDynamicCondition(row, condition, rule.condition.logic); + $("#sieve-builder-extra-conditions").append(row); + } + setSieveActionControls("", rule.actions[0] || { action: "keep", target: "" }); + setSieveActionControls("-2", rule.actions[1] || null); + for (const action of rule.actions.slice(2)) { + const row = createSieveDynamicAction(); + setSieveDynamicAction(row, action); + $("#sieve-builder-extra-actions").append(row); + } + $("#sieve-builder-replace").disabled = false; + updateSieveBuilder(); + renderManagedSieveRules(); + showSieveRuleDialog("edit", rule.name); + setTimeout(() => $("#sieve-builder-name")?.focus(), 20); + } + + function clearSieveRuleBuilder() { + state.sieveEditingRuleId = null; + $("#sieve-builder-name").value = ""; + $("#sieve-builder-enabled").checked = true; + $("#sieve-builder-stop").checked = true; + $("#sieve-builder-condition").value = "from"; + $("#sieve-builder-match").value = "contains"; + $("#sieve-builder-value").value = ""; + $("#sieve-builder-header").value = ""; + $("#sieve-builder-negate").checked = false; + for (const suffix of ["-2", "-3"]) { + $(`#sieve-builder-condition${suffix}`).value = ""; + $(`#sieve-builder-match${suffix}`).value = "contains"; + $(`#sieve-builder-value${suffix}`).value = ""; + $(`#sieve-builder-header${suffix}`).value = ""; + $(`#sieve-builder-negate${suffix}`).checked = false; + } + $("#sieve-builder-logic").value = "allof"; + $("#sieve-builder-logic-3").value = "allof"; + $("#sieve-builder-extra-conditions").replaceChildren(); + $("#sieve-builder-action").value = "move"; + $("#sieve-builder-target").value = ""; + $("#sieve-builder-action-2").value = ""; + $("#sieve-builder-target-2").value = ""; + $("#sieve-builder-extra-actions").replaceChildren(); + $("#sieve-builder-replace").disabled = true; + updateSieveBuilder(); + renderManagedSieveRules(); + if ($("#sieve-rule-dialog")?.open) { + $("#sieve-rule-dialog-title").textContent = "Create filter"; + $("#sieve-rule-dialog-subtitle").textContent = "Build the rule visually and verify the preview before applying it."; + } + } + + function moveParsedSieveRule(ruleId, delta) { + const editor = $("#sieve-editor"); + const rules = parseSieveRules(editor.value); + const index = rules.findIndex((rule) => rule.id === ruleId); + const target = index + delta; + if (index < 0 || target < 0 || target >= rules.length) return; + const first = delta > 0 ? rules[index] : rules[target]; + const second = delta > 0 ? rules[target] : rules[index]; + const separator = editor.value.slice(first.end, second.start); + const replacement = second.full + separator + first.full; + editor.value = editor.value.slice(0, first.start) + replacement + editor.value.slice(second.end); + state.sieveEditingRuleId = null; + renderManagedSieveRules(); + $("#sieve-result").textContent = "Rule order changed — save to apply"; + } + + function toggleSieveRuleEnabled(rule) { + const editor = $("#sieve-editor"); + const source = editor.value; + const replacement = rule.enabled ? `false # ${rule.actualCondition}` : rule.actualCondition; + editor.value = source.slice(0, rule.conditionStart) + replacement + source.slice(rule.conditionEnd); + state.sieveEditingRuleId = null; + renderManagedSieveRules(); + $("#sieve-result").textContent = `${rule.name} ${rule.enabled ? "disabled" : "enabled"} — save to apply`; + } + + async function deleteSieveRule(rule) { + if (!await appConfirm(`Delete Sieve rule “${rule.name}”?`, { title: "Delete filter rule", confirmLabel: "Delete", danger: true })) return; + const editor = $("#sieve-editor"); + editor.value = `${editor.value.slice(0, rule.start).trimEnd()}\n\n${editor.value.slice(rule.end).trimStart()}`.trim() + "\n"; + state.sieveEditingRuleId = null; + clearSieveRuleBuilder(); + renderManagedSieveRules(); + $("#sieve-result").textContent = `Rule “${rule.name}” removed — save to apply`; + } + + function renderManagedSieveRules() { + const list = $("#sieve-managed-rules"); + if (!list) return; + const rules = parseSieveRules(); + const count = $("#sieve-rule-count"); + if (count) count.textContent = String(rules.length); + if (state.sieveEditingRuleId && !rules.some((rule) => rule.id === state.sieveEditingRuleId)) { + state.sieveEditingRuleId = null; + const update = $("#sieve-builder-replace"); + if (update) update.disabled = true; + } + list.replaceChildren(); + if (!rules.length) { + list.append(el("div", "muted", "No named rules found. Rules using # rule:[name] are parsed automatically.")); + return; + } + rules.forEach((rule, index) => { + const item = el("article", `sieve-managed-rule${rule.enabled ? "" : " disabled"}${rule.editable ? "" : " unsupported"}${state.sieveEditingRuleId === rule.id ? " selected" : ""}`); + const main = el("div", "sieve-managed-rule-main"); + const title = el("div", "sieve-managed-rule-title"); + title.append(el("strong", "", rule.name), el("span", `sieve-rule-state ${rule.enabled ? "enabled" : ""}`, rule.enabled ? "ON" : "OFF")); + const summary = el("div", "sieve-managed-rule-summary"); + const when = el("div"); when.innerHTML = `When: ${escapeHtml(rule.condition.summary || rule.actualCondition)}`; + const actionText = rule.actions.length ? rule.actions.map((action) => action.summary).join("; ") : "No action parsed"; + const then = el("div"); then.innerHTML = `Then: ${escapeHtml(actionText)}${rule.stop ? "; stop processing more rules" : ""}`; + summary.append(when, then); + main.append(title, summary); + if (!rule.editable) main.append(el("div", "sieve-rule-warning", "Uses syntax not fully supported by the visual editor; source is preserved unchanged.")); + const actions = el("div", "sieve-managed-rule-actions"); + const edit = el("button", "button quiet small", "Edit"); edit.type = "button"; edit.disabled = !rule.editable; edit.addEventListener("click", () => loadSieveRuleIntoBuilder(rule)); + const toggle = el("button", "button quiet small", rule.enabled ? "Disable" : "Enable"); toggle.type = "button"; toggle.addEventListener("click", () => toggleSieveRuleEnabled(rule)); + const up = el("button", "button quiet small", "↑"); up.type = "button"; up.title = "Move rule up"; up.disabled = index === 0; up.addEventListener("click", () => moveParsedSieveRule(rule.id, -1)); + const down = el("button", "button quiet small", "↓"); down.type = "button"; down.title = "Move rule down"; down.disabled = index === rules.length - 1; down.addEventListener("click", () => moveParsedSieveRule(rule.id, 1)); + const remove = el("button", "button quiet small danger", "Delete"); remove.type = "button"; remove.addEventListener("click", () => deleteSieveRule(rule)); + actions.append(edit, toggle, up, down, remove); + item.append(main, actions); + list.append(item); + }); + } + + function createSieveDynamicCondition() { + const row = el("div", "sieve-dynamic-row sieve-dynamic-condition"); + row.innerHTML = ``; + const refresh = () => { + const kind = row.querySelector(".sieve-dynamic-kind").value; + row.querySelector(".sieve-dynamic-header-wrap").classList.toggle("hidden", kind !== "header"); + updateSieveBuilder(); + }; + row.querySelector(".sieve-dynamic-kind").addEventListener("change", refresh); + row.querySelectorAll("select,input").forEach((node) => node.addEventListener("input", updateSieveBuilder)); + row.querySelector(".sieve-dynamic-remove").addEventListener("click", () => { row.remove(); updateSieveBuilder(); }); + return row; + } + + function buildSieveDynamicCondition(row) { + const condition = row.querySelector(".sieve-dynamic-kind").value; + const value = row.querySelector(".sieve-dynamic-value").value.trim(); + if (!condition || !value) throw new Error("Every additional condition needs a value"); + const match = row.querySelector(".sieve-dynamic-match").value; + const matchToken = match === "is" ? ":is" : match === "matches" ? ":matches" : ":contains"; + let test; + let header = ""; + if (condition === "from") test = `address ${matchToken} "from" "${sieveQuote(value)}"`; + else if (condition === "from-domain") test = `address :domain ${matchToken} "from" "${sieveQuote(value)}"`; + else if (condition === "to") test = `address ${matchToken} ["to", "cc"] "${sieveQuote(value)}"`; + else if (condition === "header-from") test = `header ${matchToken} "from" "${sieveQuote(value)}"`; + else if (condition === "header-to") test = `header ${matchToken} "to" "${sieveQuote(value)}"`; + else if (condition === "subject") test = `header ${matchToken} "subject" "${sieveQuote(value)}"`; + else if (condition === "list-id") test = `header ${matchToken} "list-id" "${sieveQuote(value)}"`; + else { + header = row.querySelector(".sieve-dynamic-header").value.trim(); + if (!/^[A-Za-z0-9-]+$/.test(header)) throw new Error("Additional custom header name is invalid"); + test = `header ${matchToken} "${sieveQuote(header)}" "${sieveQuote(value)}"`; + } + const negate = row.querySelector(".sieve-dynamic-negate").checked; + if (negate) test = `not ${test}`; + return { test, condition, match, value, header, negate, logic: row.querySelector(".sieve-dynamic-logic").value === "anyof" ? "anyof" : "allof", summary: `${negate ? "NOT " : ""}${sieveConditionLabel(condition, header)} ${sieveMatchLabel(match)} “${value}”` }; + } + + function createSieveDynamicAction() { + const row = el("div", "sieve-dynamic-row sieve-dynamic-action-row"); + row.innerHTML = ``; + const refresh = () => { configureSieveDynamicAction(row); updateSieveBuilder(); }; + row.querySelector(".sieve-dynamic-action").addEventListener("change", refresh); + row.querySelector(".sieve-dynamic-target").addEventListener("input", updateSieveBuilder); + row.querySelector(".sieve-dynamic-remove").addEventListener("click", () => { row.remove(); updateSieveBuilder(); }); + configureSieveDynamicAction(row); + return row; + } + + function configureSieveDynamicAction(row) { + const action = row.querySelector(".sieve-dynamic-action").value; + const wrap = row.querySelector(".sieve-dynamic-target-wrap"); + const label = row.querySelector(".sieve-dynamic-target-label"); + const target = row.querySelector(".sieve-dynamic-target"); + const needsTarget = ["move", "copy", "redirect", "redirect-copy", "add-flag", "set-flags"].includes(action); + wrap.classList.toggle("hidden", !needsTarget); + label.textContent = ["redirect", "redirect-copy"].includes(action) ? "Address" : ["add-flag", "set-flags"].includes(action) ? "IMAP flag(s)" : "Folder"; + target.placeholder = ["redirect", "redirect-copy"].includes(action) ? "user@example.com" : action === "set-flags" ? "\\Seen, \\Flagged" : action === "add-flag" ? "$Label1" : "Archive"; + target.removeAttribute("list"); + if (["move", "copy"].includes(action)) target.setAttribute("list", "sieve-folder-options"); + } + + function buildSieveDynamicAction(row) { + return buildSieveActionFromValues(row.querySelector(".sieve-dynamic-action").value, row.querySelector(".sieve-dynamic-target").value.trim(), "Additional action"); + } + + function cleanSieveRuleName(value) { + const name = String(value || "").trim(); + if (!name) throw new Error("Rule name is required"); + if (/[\]\r\n]/.test(name)) throw new Error("Rule name cannot contain ] or a line break"); + return name; + } + + function buildSieveVisualRule() { + const name = cleanSieveRuleName($("#sieve-builder-name").value); + const enabled = $("#sieve-builder-enabled").checked; + const primary = buildSieveCondition("", true); + const secondary = buildSieveCondition("-2", false); + const tertiary = buildSieveCondition("-3", false); + let test = primary.test; + let summary = primary.summary; + if (secondary) { + const logic = $("#sieve-builder-logic").value === "anyof" ? "anyof" : "allof"; + test = `${logic} (${test}, ${secondary.test})`; + summary = `${summary} ${logic === "anyof" ? "OR" : "AND"} ${secondary.summary}`; + } + if (tertiary) { + const logic = $("#sieve-builder-logic-3").value === "anyof" ? "anyof" : "allof"; + test = `${logic} (${test}, ${tertiary.test})`; + summary = `${summary} ${logic === "anyof" ? "OR" : "AND"} ${tertiary.summary}`; + } + for (const row of $$("#sieve-builder-extra-conditions .sieve-dynamic-condition")) { + const extra = buildSieveDynamicCondition(row); + test = `${extra.logic} (${test}, ${extra.test})`; + summary = `${summary} ${extra.logic === "anyof" ? "OR" : "AND"} ${extra.summary}`; + } + + const actions = [buildSieveAction("", true), buildSieveAction("-2", false), ...$$("#sieve-builder-extra-actions .sieve-dynamic-action-row").map(buildSieveDynamicAction)].filter(Boolean); + const commands = actions.map((item) => item.command); + const stop = $("#sieve-builder-stop").checked; + if (stop) commands.push("stop;"); + const requirements = new Set(); + for (const item of actions) for (const requirement of item.requirements) requirements.add(requirement); + const conditionCode = enabled ? test : `false # ${test}`; + return { + name, enabled, stop, requirements: [...requirements], conditionSummary: summary, + actionSummary: actions.map((item) => item.summary).join("; ") + (stop ? "; stop processing more rules" : ""), + rule: `# rule:[${name}]\nif ${conditionCode}\n{\n\t${commands.join("\n\t")}\n}`, + }; + } + + function configureSieveActionFields(suffix = "") { + const action = $(`#sieve-builder-action${suffix}`).value; + const wrap = $(`#sieve-builder-target-wrap${suffix}`); + const label = $(`#sieve-builder-target-label${suffix}`); + const target = $(`#sieve-builder-target${suffix}`); + const needsTarget = ["move", "copy", "redirect", "redirect-copy", "add-flag", "set-flags"].includes(action); + wrap?.classList.toggle("hidden", !needsTarget); + if (!target || !label) return; + label.textContent = ["redirect", "redirect-copy"].includes(action) ? "Address" : ["add-flag", "set-flags"].includes(action) ? "IMAP flag(s)" : "Folder"; + target.placeholder = ["redirect", "redirect-copy"].includes(action) ? "user@example.com" : action === "set-flags" ? "\\Seen, \\Flagged" : action === "add-flag" ? "$Label1" : "Archive"; + target.removeAttribute("list"); + if (["move", "copy"].includes(action)) target.setAttribute("list", "sieve-folder-options"); + } + + function applySieveCapabilityMatrix(extensions) { + const supports = (name) => extensions.has(name.toLowerCase()); + const actionAllowed = (action) => { + if (["move", "copy"].includes(action) && !supports("fileinto")) return false; + if (["copy", "redirect-copy"].includes(action) && !supports("copy")) return false; + if (["mark-read", "add-flag", "set-flags"].includes(action) && !supports("imap4flags")) return false; + return true; + }; + for (const select of [$("#sieve-builder-action"), $("#sieve-builder-action-2"), ...$$("#sieve-builder-extra-actions .sieve-dynamic-action")]) { + if (!select) continue; + for (const option of select.options) option.disabled = Boolean(option.value) && !actionAllowed(option.value); + if (select.selectedOptions[0]?.disabled) select.value = select.id === "sieve-builder-action-2" ? "" : "keep"; + } + $("#sieve-vacation-append").disabled = Boolean(state.sieveInfo) && !supports("vacation"); + } + + function renderSieveBuilderPreview() { + const preview = $("#sieve-builder-preview-text"); + const code = $("#sieve-builder-preview-code"); + const statePill = $("#sieve-builder-preview-state"); + if (!preview || !code || !statePill) return; + try { + const built = buildSieveVisualRule(); + preview.innerHTML = `
    When: ${escapeHtml(built.conditionSummary)}
    Then: ${escapeHtml(built.actionSummary || "No action")}
    `; + code.textContent = built.rule; + statePill.textContent = built.enabled ? "Enabled" : "Disabled"; + statePill.className = `sieve-status-pill ${built.enabled ? "ok" : ""}`; + } catch (error) { + preview.textContent = error.message; + code.textContent = ""; + statePill.textContent = "Incomplete"; + statePill.className = "sieve-status-pill error"; + } + } + + function updateSieveBuilder() { + const condition = $("#sieve-builder-condition").value; + $("#sieve-builder-header-wrap").classList.toggle("hidden", condition !== "header"); + + for (const suffix of ["-2", "-3"]) { + const nextCondition = $(`#sieve-builder-condition${suffix}`).value; + const enabled = Boolean(nextCondition); + $(`#sieve-builder-header-wrap${suffix}`).classList.toggle("hidden", nextCondition !== "header"); + $(`#sieve-builder-match${suffix}`).disabled = !enabled; + $(`#sieve-builder-value${suffix}`).disabled = !enabled; + $(`#sieve-builder-negate${suffix}`).disabled = !enabled; + const customHeader = $(`#sieve-builder-header${suffix}`); + if (customHeader) customHeader.disabled = !enabled || nextCondition !== "header"; + } + $("#sieve-builder-logic").disabled = !Boolean($("#sieve-builder-condition-2").value); + $("#sieve-builder-logic-3").disabled = !Boolean($("#sieve-builder-condition-3").value); + + configureSieveActionFields(""); + configureSieveActionFields("-2"); + $$("#sieve-builder-extra-actions .sieve-dynamic-action-row").forEach(configureSieveDynamicAction); + $$("#sieve-builder-extra-conditions .sieve-dynamic-condition").forEach((row) => { + row.querySelector(".sieve-dynamic-header-wrap").classList.toggle("hidden", row.querySelector(".sieve-dynamic-kind").value !== "header"); + }); + + const list = $("#sieve-folder-options"); + list.replaceChildren(...state.folders.map((folder) => { + const option = document.createElement("option"); + option.value = folder.name; + return option; + })); + + const extensions = new Set((state.sieveInfo?.extensions || []).map((value) => value.toLowerCase())); + applySieveCapabilityMatrix(extensions); + const actions = ["", "-2"].map((suffix) => $(`#sieve-builder-action${suffix}`)?.value).filter(Boolean) + .concat($$("#sieve-builder-extra-actions .sieve-dynamic-action").map((node) => node.value).filter(Boolean)); + const requirements = new Set(); + for (const actionName of actions) for (const requirement of sieveBuilderRequirements("", actionName)) requirements.add(requirement); + const missing = [...requirements].filter((requirement) => state.sieveInfo && !extensions.has(requirement.toLowerCase())); + $("#sieve-builder-capability").textContent = missing.length + ? `Server does not advertise: ${missing.join(", ")}` + : requirements.size ? `Requires: ${[...requirements].join(", ")}` : "No optional extension required"; + const vacationSupported = extensions.has("vacation"); + const vacationScheduled = Boolean($("#sieve-vacation-start")?.value || $("#sieve-vacation-end")?.value); + const scheduleSupported = extensions.has("date") && extensions.has("relational"); + $("#sieve-vacation-capability").textContent = state.sieveInfo + ? !vacationSupported + ? "Server does not advertise vacation" + : vacationScheduled && !scheduleSupported + ? "Scheduled dates require date + relational" + : vacationScheduled + ? "Server supports vacation scheduling" + : "Server supports vacation" + : ""; + $("#sieve-builder-replace").disabled = !state.sieveEditingRuleId; + renderSieveBuilderPreview(); + } + + + function buildSieveVacationRule() { + const subject = $("#sieve-vacation-subject").value.trim() || "Out of office"; + const message = $("#sieve-vacation-message").value.trim(); + const days = Math.max(1, Math.min(30, Number.parseInt($("#sieve-vacation-days").value, 10) || 7)); + const start = $("#sieve-vacation-start").value.trim(); + const end = $("#sieve-vacation-end").value.trim(); + if (!message) throw new Error("Autoresponder message is required"); + if (start && !/^\d{4}-\d{2}-\d{2}$/.test(start)) throw new Error("Vacation start date is invalid"); + if (end && !/^\d{4}-\d{2}-\d{2}$/.test(end)) throw new Error("Vacation end date is invalid"); + if (start && end && end < start) throw new Error("Vacation end date must not be before the start date"); + + const extensions = new Set((state.sieveInfo?.extensions || []).map((value) => value.toLowerCase())); + const requirements = new Set(["vacation"]); + const tests = []; + if (start || end) { + if (state.sieveInfo && (!extensions.has("date") || !extensions.has("relational"))) { + throw new Error("This server does not advertise the Sieve date + relational extensions required for scheduled vacation"); + } + requirements.add("date"); + requirements.add("relational"); + if (start) tests.push(`currentdate :value "ge" "date" "${sieveQuote(start)}"`); + if (end) tests.push(`currentdate :value "le" "date" "${sieveQuote(end)}"`); + } + if ($("#sieve-vacation-skip-lists").checked) tests.push('not exists ["list-id"]'); + if ($("#sieve-vacation-skip-auto").checked) tests.push('not header :matches "auto-submitted" ["auto-replied", "auto-generated"]'); + const command = `vacation :days ${days} :subject "${sieveQuote(subject)}" "${sieveQuote(message)}";`; + const rule = tests.length + ? `# RustBox autoresponder\nif allof (${tests.join(", ")}) {\n ${command}\n}` + : `# RustBox autoresponder\n${command}`; + return { requirements: [...requirements], rule }; + } + + async function appendSieveVacation() { + try { + const built = buildSieveVacationRule(); + const editor = $("#sieve-editor"); + editor.value = mergeSieveRequirements([editor.value.trim(), built.rule].filter(Boolean).join("\n\n"), built.requirements); + $("#sieve-result").textContent = "Autoresponder appended"; + await validateSieve(); + } catch (error) { toast(error.message, "error"); } + } + + async function applySieveVisualRule(updateSelected) { + try { + const built = buildSieveVisualRule(); + const editor = $("#sieve-editor"); + let body = editor.value; + if (updateSelected) { + if (!state.sieveEditingRuleId) throw new Error("Select a parsed rule to update first"); + const selected = parseSieveRules(body).find((rule) => rule.id === state.sieveEditingRuleId); + if (!selected) throw new Error("The selected rule no longer exists in the source"); + body = `${body.slice(0, selected.start)}${built.rule}${body.slice(selected.end)}`; + } else { + body = [body.trim(), built.rule].filter(Boolean).join("\n\n"); + } + editor.value = mergeSieveRequirements(body, built.requirements); + const parsed = parseSieveRules(editor.value); + const matching = parsed.find((rule) => rule.name === built.name); + state.sieveEditingRuleId = matching?.id || null; + renderManagedSieveRules(); + updateSieveBuilder(); + $("#sieve-result").textContent = updateSelected ? `Rule "${built.name}" updated - save to apply` : `Rule "${built.name}" added - save to apply`; + await validateSieve(); + if ($("#sieve-rule-dialog")?.open) $("#sieve-rule-dialog").close(); + } catch (error) { + toast(error.message, "error"); + } + } + + function renderSieveScripts() { + const list = $("#sieve-scripts"); + list.replaceChildren(); + if (!state.sieveScripts.length) list.append(el("div", "empty-list", "No scripts")); + for (const script of state.sieveScripts) { + const button = el("button", `sieve-script ${script.active ? "active" : ""}`); + button.type = "button"; + button.innerHTML = `${escapeHtml(script.name)}${script.active ? "ACTIVE" : ""}`; + button.addEventListener("click", () => openSieveScript(script.name)); + list.append(button); + } + } + + async function openSieveScript(name) { + try { + const data = await request(`/sieve/scripts/${encodeURIComponent(name)}`, { method: "GET" }); + $("#sieve-name").value = data.name; + $("#sieve-editor").value = data.content; + state.sieveEditingRuleId = null; + clearSieveRuleBuilder(); + renderManagedSieveRules(); + $("#sieve-result").textContent = "Loaded"; + } catch (error) { toast(error.message, "error"); } + } + + function newSieveScript() { + $("#sieve-name").value = "rustbox"; + $("#sieve-editor").value = `require ["fileinto"];\n`; + state.sieveEditingRuleId = null; + clearSieveRuleBuilder(); + renderManagedSieveRules(); + $("#sieve-result").textContent = "New script"; + $("#sieve-name").focus(); + } + + async function validateSieve() { + try { + const data = await request("/sieve/check", { method: "POST", body: JSON.stringify({ content: $("#sieve-editor").value }) }); + $("#sieve-result").textContent = data.valid ? (data.warning || "Valid script") : (data.error || "Invalid script"); + return data.valid; + } catch (error) { + $("#sieve-result").textContent = error.message; + toast(error.message, "error"); + return false; + } + } + + async function backupSieveScripts() { + try { + if (!state.sieveScripts.length) await loadSieve(); + if (!state.sieveScripts.length) return toast("No Sieve scripts to back up", "info"); + const scripts = []; + for (const script of state.sieveScripts) { + const data = await request(`/sieve/scripts/${encodeURIComponent(script.name)}`, { method: "GET" }); + scripts.push({ name: script.name, active: Boolean(script.active), content: data.content || "" }); + } + const payload = { + format: "rustbox-sieve-backup", + version: 1, + exported_at: new Date().toISOString(), + scripts, + }; + const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = `rustbox-sieve-${new Date().toISOString().slice(0, 10)}.json`; + document.body.append(link); + link.click(); + link.remove(); + setTimeout(() => URL.revokeObjectURL(url), 1000); + toast(`Backed up ${scripts.length} Sieve script${scripts.length === 1 ? "" : "s"}`, "success"); + } catch (error) { toast(error.message, "error"); } + } + + async function restoreSieveBackup(file) { + if (!file) return; + try { + if (file.size > 2 * 1024 * 1024) throw new Error("Sieve backup is too large"); + const payload = JSON.parse(await file.text()); + if (payload?.format !== "rustbox-sieve-backup" || payload?.version !== 1 || !Array.isArray(payload.scripts)) { + throw new Error("This is not a supported RustBox Sieve backup"); + } + const scripts = payload.scripts.slice(0, 100).map((script) => ({ + name: String(script?.name || "").trim(), + active: Boolean(script?.active), + content: String(script?.content || ""), + })); + if (!scripts.length || scripts.some((script) => !script.name || script.name.length > 128 || /[\r\n]/.test(script.name))) { + throw new Error("Backup contains an invalid script name"); + } + if (payload.scripts.length > 100) throw new Error("Backup contains too many scripts"); + const activeCount = scripts.filter((script) => script.active).length; + if (activeCount > 1) throw new Error("Backup marks more than one Sieve script as active"); + const existing = new Set(state.sieveScripts.map((script) => script.name)); + const overwriteCount = scripts.filter((script) => existing.has(script.name)).length; + const confirmed = await appConfirm( + `Restore ${scripts.length} Sieve script${scripts.length === 1 ? "" : "s"}${overwriteCount ? ` and overwrite ${overwriteCount} existing script${overwriteCount === 1 ? "" : "s"}` : ""}? Existing scripts not present in the backup will be kept.`, + { title: "Restore Sieve backup", confirmLabel: "Restore", danger: overwriteCount > 0 } + ); + if (!confirmed) return; + + // Validate the complete backup against the current server before writing any script. + for (const script of scripts) { + const result = await request("/sieve/check", { method: "POST", body: JSON.stringify({ content: script.content }) }); + if (!result.valid) throw new Error(`Script “${script.name}” is not valid on this server: ${result.error || "validation failed"}`); + } + for (const script of scripts) { + await request(`/sieve/scripts/${encodeURIComponent(script.name)}`, { method: "PUT", body: JSON.stringify({ content: script.content }) }); + } + const active = scripts.find((script) => script.active); + if (active) await request(`/sieve/scripts/${encodeURIComponent(active.name)}/activate`, { method: "POST", body: "{}" }); + await loadSieve(); + toast(`Restored ${scripts.length} Sieve script${scripts.length === 1 ? "" : "s"}`, "success"); + } catch (error) { toast(error.message, "error"); } + finally { $("#sieve-restore-file").value = ""; } + } + + async function saveSieve() { + const name = $("#sieve-name").value.trim(); + if (!name) return toast("Script name is required", "error"); + const existing = state.sieveScripts.find((script) => script.name === name); + if (existing?.active) { + const confirmed = await appConfirm(`“${name}” is the active Sieve script. Validate and overwrite it?`, { title: "Overwrite active filter script", confirmLabel: "Overwrite", danger: true }); + if (!confirmed) return; + } + if (!await validateSieve()) return; + try { + const data = await request(`/sieve/scripts/${encodeURIComponent(name)}`, { method: "PUT", body: JSON.stringify({ content: $("#sieve-editor").value }) }); + $("#sieve-result").textContent = data.warning || "Saved"; + await loadSieve(); + toast(`Sieve script “${name}” saved`, "success"); + } catch (error) { toast(error.message, "error"); } + } + + async function activateSieve() { + const name = $("#sieve-name").value.trim(); + if (!name) return toast("Select a script first", "error"); + try { + await request(`/sieve/scripts/${encodeURIComponent(name)}/activate`, { method: "POST", body: "{}" }); + await loadSieve(); + toast(`Sieve script “${name}” activated`, "success"); + } catch (error) { toast(error.message, "error"); } + } + + async function deactivateSieve() { + try { + await request("/sieve/deactivate", { method: "POST", body: "{}" }); + await loadSieve(); + toast("Sieve script deactivated", "success"); + } catch (error) { toast(error.message, "error"); } + } + + async function deleteSieve() { + const name = $("#sieve-name").value.trim(); + if (!name || !await appConfirm(`Delete Sieve script “${name}”?`, { title: "Delete Sieve script", confirmLabel: "Delete", danger: true })) return; + try { + await request(`/sieve/scripts/${encodeURIComponent(name)}`, { method: "DELETE" }); + $("#sieve-name").value = ""; + $("#sieve-editor").value = ""; + await loadSieve(); + toast(`Sieve script “${name}” deleted`, "success"); + } catch (error) { toast(error.message, "error"); } + } + + function bindEditor() { + $$(".editor-toolbar [data-command]").forEach((button) => button.addEventListener("click", () => { + $("#compose-editor").focus(); + document.execCommand(button.dataset.command, false, button.dataset.commandValue || null); + })); + $("#editor-link").addEventListener("click", async () => { + const url = await appPrompt("Insert link", { value: "https://", type: "url", confirmLabel: "Insert link", required: true }); + if (!url) return; + $("#compose-editor").focus(); + document.execCommand("createLink", false, url); + }); + $("#editor-font").addEventListener("change", (event) => { + if (!event.target.value) return; + $("#compose-editor").focus(); + document.execCommand("fontName", false, event.target.value); + scheduleDraftSave(); + }); + $("#editor-size").addEventListener("change", (event) => { + if (!event.target.value) return; + $("#compose-editor").focus(); + document.execCommand("fontSize", false, event.target.value); + scheduleDraftSave(); + }); + $("#editor-color").addEventListener("input", (event) => { + $("#compose-editor").focus(); + document.execCommand("foreColor", false, event.target.value); + scheduleDraftSave(); + }); + } + + function bindPaneResizer(selector, preferenceKey, minPx, maxPx) { + const handle = $(selector); + if (!handle) return; + handle.addEventListener("pointerdown", (event) => { + if (innerWidth <= 820 || event.button !== 0) return; + event.preventDefault(); + const scaleFactor = Number(getComputedStyle(root).getPropertyValue("--ui-scale-factor")) || 1; + const startX = event.clientX; + const startRendered = Number(state.prefs[preferenceKey] || minPx) * scaleFactor; + handle.setPointerCapture?.(event.pointerId); + document.body.classList.add("resizing-pane"); + const move = (moveEvent) => { + const rendered = Math.max(minPx * scaleFactor, Math.min(maxPx * scaleFactor, startRendered + (moveEvent.clientX - startX))); + const base = Math.round(rendered / scaleFactor); + state.prefs[preferenceKey] = base; + const cssName = preferenceKey === "folderPaneWidth" ? "--folder-pane-width" : "--message-pane-width"; + root.style.setProperty(cssName, `${Math.round(rendered)}px`); + handle.setAttribute("aria-valuenow", String(base)); + }; + const finish = () => { + handle.removeEventListener("pointermove", move); + handle.removeEventListener("pointerup", finish); + handle.removeEventListener("pointercancel", finish); + document.body.classList.remove("resizing-pane"); + savePreference(preferenceKey, state.prefs[preferenceKey]); + }; + handle.addEventListener("pointermove", move); + handle.addEventListener("pointerup", finish); + handle.addEventListener("pointercancel", finish); + }); + } + + function bindEvents() { + bindPaneResizer("#folder-resizer", "folderPaneWidth", 170, 420); + bindPaneResizer("#message-resizer", "messagePaneWidth", 300, 760); + bindRecipientAutocomplete("#compose-to"); + bindRecipientAutocomplete("#compose-cc"); + bindRecipientAutocomplete("#compose-bcc"); + $("#login-form").addEventListener("submit", login); + $("#logout-btn").addEventListener("click", logout); + $("#compose-btn").addEventListener("click", () => openCompose("new")); + $("#compose-fullscreen").addEventListener("click", toggleComposeFullscreen); + $("#compose-form").addEventListener("submit", sendMessage); + $("#compose-attach").addEventListener("click", () => $("#compose-files").click()); + $("#compose-files").addEventListener("change", async (event) => { await uploadComposeFiles(event.target.files); event.target.value = ""; }); + $("#editor-image").addEventListener("click", () => { state.composeInsertRange = captureEditorRange(); $("#compose-images").click(); }); + $("#editor-image-edit").addEventListener("click", () => openInlineImageEditor()); + $("#compose-images").addEventListener("change", async (event) => { await uploadInlineImage(event.target.files, state.composeInsertRange); state.composeInsertRange = null; event.target.value = ""; }); + $("#image-edit-form").addEventListener("submit", applyInlineImageEditor); + $("#image-edit-reset").addEventListener("click", resetImageCropControls); + ["#image-crop-top", "#image-crop-right", "#image-crop-bottom", "#image-crop-left"].forEach((selector) => $(selector).addEventListener("input", drawInlineImageCropPreview)); + $("#image-edit-width").addEventListener("input", updateImageEditOutputs); + $("#image-edit-dialog").addEventListener("close", () => { state.imageEditorBitmap?.close?.(); state.imageEditorBitmap = null; state.imageEditorBlob = null; }); + $("#compose-recover").addEventListener("click", recoverDraft); + $("#compose-mode").addEventListener("change", async (event) => { + const editor = $("#compose-editor"); + const previous = editor.dataset.mode || "html"; + const next = event.target.value; + const richContent = previous === "html" && next !== "html" && Boolean(editor.querySelector("img,a,strong,b,em,i,u,s,ul,ol,li,blockquote,[style]")); + if (richContent && !await appConfirm("Switching away from HTML can hide or discard formatting when the message is sent. Continue?", { title: "Change compose format", confirmLabel: "Switch format" })) { + event.target.value = previous; + return; + } + setComposeMode(next, true); + insertIdentitySignature(editor); + scheduleDraftSave(); + }); + $("#compose-from").addEventListener("change", () => { insertIdentitySignature($("#compose-editor")); updateComposePgpUi(true); }); + ["#compose-to", "#compose-cc", "#compose-bcc"].forEach((selector) => $(selector).addEventListener("input", () => { + state.pgpRecipientStatus = []; + updateComposePgpUi(); + scheduleDraftSave(); + })); + $("#compose-subject").addEventListener("input", scheduleDraftSave); + ["#compose-priority", "#compose-mdn", "#compose-pgp-mode", "#compose-pgp-inline", "#compose-pgp-attach-key", "#compose-pgp-attach-recipient-keys"].forEach((selector) => $(selector).addEventListener("change", async () => { + if (selector === "#compose-pgp-attach-recipient-keys" && $(selector).checked) await checkComposePgpRecipients(false, composePgpVisibleRecipients()); + updateComposePgpUi(); + scheduleDraftSave(); + })); + $("#compose-editor").addEventListener("input", () => { if (state.composeSelectedImage && !state.composeSelectedImage.isConnected) setSelectedInlineImage(null); scheduleDraftSave(); scheduleUnusedInlineAttachmentCleanup(); }); + $("#compose-editor").addEventListener("click", (event) => { + const image = event.target.closest?.("img[data-rustbox-cid]"); + setSelectedInlineImage(image || null); + }); + $("#compose-editor").addEventListener("dblclick", (event) => { + const image = event.target.closest?.("img[data-rustbox-cid]"); + if (image) { event.preventDefault(); openInlineImageEditor(image); } + }); + $("#compose-editor").addEventListener("paste", async (event) => { + const images = [...(event.clipboardData?.items || [])] + .filter((item) => item.kind === "file" && /^image\/(png|jpeg|gif|webp)$/i.test(item.type)) + .map((item) => item.getAsFile()) + .filter(Boolean); + if (!images.length) return; + event.preventDefault(); + if ($("#compose-mode").value !== "html") return toast("Switch to HTML mode to paste inline images", "warning"); + const range = captureEditorRange(); + await uploadInlineImage(images, range); + }); + $("#compose-editor").addEventListener("dragover", (event) => { if ([...event.dataTransfer.types].includes("Files")) event.preventDefault(); }); + $("#compose-editor").addEventListener("drop", async (event) => { + const files = [...event.dataTransfer.files]; + if (!files.length) return; + event.preventDefault(); + const allImages = files.every((file) => /^image\/(png|jpeg|gif|webp)$/i.test(file.type)); + if ($("#compose-mode").value === "html" && allImages) await uploadInlineImage(files, editorRangeFromPoint(event.clientX, event.clientY)); + else await uploadComposeFiles(files); + }); + $("#reply-btn").addEventListener("click", () => openCompose("reply")); + $("#reply-all-btn").addEventListener("click", () => openCompose("reply-all")); + $("#forward-btn").addEventListener("click", () => openCompose("forward")); + $("#delete-btn").addEventListener("click", deleteCurrent); + $("#unread-btn").addEventListener("click", toggleUnread); + $("#flag-btn").addEventListener("click", toggleFlagged); + $("#archive-btn").addEventListener("click", archiveCurrent); + $("#spam-btn").addEventListener("click", junkCurrent); + $("#move-select").addEventListener("change", async (event) => { const target = event.target.value; event.target.value = ""; if (target) await moveCurrentTo(target); }); + $("#copy-select").addEventListener("change", async (event) => { const target = event.target.value; event.target.value = ""; if (target) await copyCurrentTo(target); }); + $("#bulk-select-all").addEventListener("change", (event) => { const source = state.searchAllResults || state.messages; for (const message of source) { const key = messageKey(message); event.target.checked ? state.selectedMessages.add(key) : state.selectedMessages.delete(key); } renderMessages(); }); + $("#bulk-read").addEventListener("click", () => performBulk("read")); + $("#bulk-unread").addEventListener("click", () => performBulk("unread")); + $("#bulk-flag").addEventListener("click", () => performBulk("flag")); + $("#bulk-unflag").addEventListener("click", () => performBulk("unflag")); + $("#bulk-archive").addEventListener("click", () => performBulk("archive")); + $("#bulk-select-search").addEventListener("click", selectAllSearchResults); + $("#bulk-junk").addEventListener("click", () => performBulk("junk")); + $("#bulk-delete").addEventListener("click", () => performBulk("delete")); + $("#bulk-move-select").addEventListener("change", async (event) => { const target = event.target.value; event.target.value = ""; if (target) await performBulk("move", target); }); + $("#bulk-copy-select").addEventListener("change", async (event) => { const target = event.target.value; event.target.value = ""; if (target) await performBulk("copy", target); }); + $("#bulk-tag-select").addEventListener("change", async (event) => { const tagId = event.target.value; event.target.value = ""; if (tagId) await bulkAddTag(tagId); }); + $("#select-mode-btn").addEventListener("click", () => setSelectionMode(!state.selectionMode)); + $("#selection-done").addEventListener("click", () => setSelectionMode(false)); + $("#refresh-btn").addEventListener("click", () => loadFolders(false)); + $("#message-sort").addEventListener("change", async (event) => { + const value = event.target.value; + state.mailPrefs.default_sort = value; + $("#mail-pref-sort").value = value; + try { + state.mailPrefs = await request("/settings/mailbox", { method: "PUT", body: JSON.stringify({ default_sort: value }) }); + state.page = 0; + await loadMessages(true); + toast("Message sort updated", "success"); + } catch (error) { toast(error.message, "error"); } + }); + $("#load-more").addEventListener("click", async () => { state.page += 1; await loadMessages(false); }); + $("#message-list").addEventListener("scroll", scheduleMessageListStateSave, { passive: true }); + window.addEventListener("offline", () => setConnectivityState(false)); + window.addEventListener("online", async () => { + setConnectivityState(true); + disconnectRealtime(); + connectRealtime(); + if (state.accounts.length) { + await refreshFolderListOnly(); + if (state.searchContext) await runSearch(new URLSearchParams(state.searchContext.params), state.searchContext.label, { restoreScrollTop: state.searchContext.scrollTop }); + else await loadMessages(true); + } + }); + $("#search-form").addEventListener("submit", searchMail); + $("#advanced-search-btn").addEventListener("click", openAdvancedSearch); + $("#advanced-search-form").addEventListener("submit", submitAdvancedSearch); + $("#search-reset-btn").addEventListener("click", resetAdvancedSearch); + $("#search-save-btn").addEventListener("click", saveAdvancedSearch); + $("#contacts-btn").addEventListener("click", openContacts); + $("#contact-group-filter").addEventListener("change", async () => { renderContactGroupFilter(); await loadContacts($("#contact-search").value.trim()); }); + $("#contact-group-new").addEventListener("click", createContactGroup); + $("#contact-group-rename").addEventListener("click", renameContactGroup); + $("#contact-group-delete").addEventListener("click", deleteContactGroup); + $("#tag-add-btn").addEventListener("click", () => openTagDialog()); + $("#tag-form").addEventListener("submit", saveTag); + $("#tag-delete").addEventListener("click", deleteTag); + $("#calendar-btn").addEventListener("click", openCalendar); + $("#settings-btn").addEventListener("click", () => openSettings("appearance")); + $("#shortcuts-open").addEventListener("click", () => $("#shortcuts-dialog").showModal()); + $("#account-add").addEventListener("click", () => { $("#add-account-error").classList.add("hidden"); $("#add-account-dialog").showModal(); setTimeout(() => $("#add-account-email").focus(), 20); }); + $("#account-revoke-others").addEventListener("click", revokeOtherSessions); + $("#add-account-form").addEventListener("submit", addBrowserAccount); + $("#mobile-folders").addEventListener("click", () => $("#folder-pane").classList.toggle("open")); + $("#reader-back").addEventListener("click", closeReader); + $("#reader-prev").addEventListener("click", () => navigateMessage(-1)); + $("#reader-next").addEventListener("click", () => navigateMessage(1)); + $("#thread-toggle").addEventListener("click", () => { + state.threadExpanded = !state.threadExpanded; + localStorage.setItem("rustbox.threadExpanded", String(state.threadExpanded)); + if (state.currentDetail) renderThread(state.currentDetail); + }); + $("#folder-manage-btn").addEventListener("click", openFolderDialog); + $("#create-folder-btn").addEventListener("click", createFolder); + $("#rename-folder-btn").addEventListener("click", renameFolder); + $("#delete-folder-btn").addEventListener("click", deleteFolder); + $("#subscribe-folder-btn").addEventListener("click", toggleCurrentFolderSubscription); + $("#headers-btn").addEventListener("click", showHeaders); + $("#source-btn").addEventListener("click", showMessageSource); + $("#quick-filter-btn").addEventListener("click", () => createFilterFromCurrentMessage("sender")); + $("#reader-from").addEventListener("click", (event) => { const rect = event.currentTarget.getBoundingClientRect(); openCurrentSenderMenu(rect.left, rect.bottom + 4); }); + $("#reader-from").addEventListener("keydown", (event) => { if (["Enter", " "].includes(event.key)) { event.preventDefault(); const rect = event.currentTarget.getBoundingClientRect(); openCurrentSenderMenu(rect.left, rect.bottom + 4); } }); + $("#print-btn").addEventListener("click", printCurrentMessage); + $("#pgp-reader-process").addEventListener("click", processCurrentPgpMessage); + $("#pgp-reader-discover").addEventListener("click", discoverCurrentPgpSigner); + $("#copy-headers-btn").addEventListener("click", copyHeaders); + $$(".close-dialog").forEach((button) => button.addEventListener("click", async () => { + const dialog = button.closest("dialog"); + if (dialog?.id === "compose-dialog") await closeComposer(); + else dialog?.close(); + })); + $("#compose-dialog").addEventListener("cancel", async (event) => { event.preventDefault(); await closeComposer(); }); + window.addEventListener("beforeunload", () => { if (state.composeDirty && draftHasContent()) storeLocalDraftSnapshot(); }); + $("#compose-pgp-check").addEventListener("click", () => checkComposePgpRecipients(true)); + $("#compose-pgp-discover").addEventListener("click", discoverComposePgpRecipients); + ["#compose-to", "#compose-cc", "#compose-bcc"].forEach((selector) => $(selector).addEventListener("change", () => { state.pgpRecipientStatus = []; updateComposePgpUi(); })); + $("#pgp-refresh").addEventListener("click", loadOpenPgp); + $("#pgp-pref-save").addEventListener("click", savePgpPreferences); + $("#pgp-generate").addEventListener("click", generatePgpKey); + $("#pgp-import").addEventListener("click", () => importPgpText()); + $("#pgp-import-file").addEventListener("click", () => $("#pgp-import-file-input").click()); + $("#pgp-import-file-input").addEventListener("change", async (event) => { const file = event.target.files?.[0]; if (file) await importPgpFile(file); event.target.value = ""; }); + $("#pgp-discover").addEventListener("click", () => discoverPgpEmail()); + $$(".settings-tab").forEach((button) => button.addEventListener("click", () => { selectSettingsTab(button.dataset.tab); if (button.dataset.tab === "sieve") loadSieve(); if (button.dataset.tab === "identities") { loadIdentities(); loadOpenPgp(); } if (button.dataset.tab === "openpgp") loadOpenPgp(); if (button.dataset.tab === "notifications") loadNotificationPreferences(); if (button.dataset.tab === "mailbox") loadMailPreferences(); if (button.dataset.tab === "diagnostics") loadDiagnostics(); if (button.dataset.tab === "account") { renderAccountSettings(); loadActiveSessions(); } if (button.dataset.tab === "folders") { loadSpecialFolders(); loadQuota(); } })); + $("#pref-theme").addEventListener("change", (event) => savePreference("theme", event.target.value)); + $("#pref-density").addEventListener("change", (event) => savePreference("density", event.target.value)); + $("#pref-preview").addEventListener("change", (event) => savePreference("preview", event.target.value)); + $("#pref-scale").addEventListener("change", (event) => savePreference("scale", event.target.value)); + $("#pref-language").addEventListener("change", (event) => savePreference("language", event.target.value)); + $("#pref-color-preset").addEventListener("change", (event) => savePreference("colorPreset", event.target.value)); + $("#pref-accent").addEventListener("input", (event) => { $("#pref-accent-value").textContent = event.target.value; savePreference("accentColor", event.target.value); }); + [["#pref-background", "#pref-background-value", "backgroundColor"], ["#pref-panel", "#pref-panel-value", "panelColor"], ["#pref-text-color", "#pref-text-color-value", "textColor"]].forEach(([input, output, key]) => $(input).addEventListener("input", (event) => { $(output).textContent = event.target.value; savePreference(key, event.target.value); })); + $("#pref-font-family").addEventListener("change", (event) => savePreference("fontFamily", event.target.value)); + [["#pref-font-size", "fontSize"], ["#pref-row-height", "rowHeight"], ["#pref-folder-width", "folderPaneWidth"], ["#pref-message-width", "messagePaneWidth"], ["#pref-radius", "borderRadius"]].forEach(([selector, key]) => $(selector).addEventListener("input", (event) => savePreference(key, Number(event.target.value)))); + $("#pref-contrast").addEventListener("change", (event) => savePreference("contrast", event.target.value)); + $("#pref-date-format").addEventListener("change", (event) => { savePreference("dateFormat", event.target.value); renderMessages(); if (state.currentDetail) renderMessageDetail(state.currentDetail); }); + $("#pref-time-format").addEventListener("change", (event) => { savePreference("timeFormat", event.target.value); renderMessages(); if (state.currentDetail) renderMessageDetail(state.currentDetail); }); + $("#pref-avatars").addEventListener("change", (event) => savePreference("showAvatars", event.target.checked)); + $("#pref-attachment-icons").addEventListener("change", (event) => savePreference("showAttachmentIcons", event.target.checked)); + $("#pref-flags").addEventListener("change", (event) => savePreference("showFlags", event.target.checked)); + $("#pref-reduce-motion").addEventListener("change", (event) => savePreference("reduceMotion", event.target.checked)); + [["#pref-icon-size", "iconSize"], ["#pref-sidebar-height", "sidebarItemHeight"], ["#pref-composer-width", "composerWidth"], ["#pref-unread-weight", "unreadFontWeight"]].forEach(([selector, key]) => $(selector).addEventListener("input", (event) => savePreference(key, Number(event.target.value)))); + $("#pref-reader-width").addEventListener("change", (event) => savePreference("readerMaxWidth", Number(event.target.value))); + $("#pref-toolbar-density").addEventListener("change", (event) => savePreference("toolbarDensity", event.target.value)); + $("#pref-selection-color").addEventListener("input", (event) => { $("#pref-selection-color-value").textContent = event.target.value; savePreference("selectionColor", event.target.value); }); + $("#pref-unread-badge").addEventListener("change", (event) => savePreference("unreadBadgeStyle", event.target.value)); + $("#pref-scrollbar-density").addEventListener("change", (event) => savePreference("scrollbarDensity", event.target.value)); + $("#pref-compact-headers").addEventListener("change", (event) => savePreference("compactHeaders", event.target.checked)); + $("#pref-show-borders").addEventListener("change", (event) => savePreference("showBorders", event.target.checked)); + $("#pref-monochrome").addEventListener("change", (event) => savePreference("monochrome", event.target.checked)); + [["folder", "folderBackground", "#101310"], ["message", "messageBackground", "#181c19"], ["reader", "readerBackground", "#181c19"]].forEach(([name, key, fallback]) => { + const enabled = $(`#pref-${name}-bg-enabled`); const color = $(`#pref-${name}-bg`); + enabled.addEventListener("change", () => savePreference(key, enabled.checked ? color.value : "")); + color.addEventListener("input", () => { if (enabled.checked) savePreference(key, color.value); }); + }); + $("#compose-pref-format").addEventListener("change", (event) => savePreference("composeFormat", event.target.value)); + $("#compose-pref-autosave").addEventListener("change", (event) => savePreference("composeAutosaveMs", Number(event.target.value))); + $("#compose-pref-priority").addEventListener("change", (event) => savePreference("defaultPriority", event.target.value)); + $("#compose-pref-mdn").addEventListener("change", (event) => savePreference("requestMdnDefault", event.target.checked)); + $("#compose-pref-attachment-reminder").addEventListener("change", (event) => savePreference("attachmentReminder", event.target.checked)); + $("#compose-pref-font").addEventListener("change", (event) => savePreference("composeFontFamily", event.target.value)); + $("#compose-pref-reply-position").addEventListener("change", (event) => savePreference("replyPosition", event.target.value)); + $("#compose-pref-quote-style").addEventListener("change", (event) => savePreference("quoteStyle", event.target.value)); + $("#compose-pref-forward-mode").addEventListener("change", (event) => savePreference("forwardMode", event.target.value)); + $("#compose-pref-spellcheck").addEventListener("change", (event) => savePreference("spellcheckEnabled", event.target.checked)); + $("#compose-markdown-preview").addEventListener("click", previewMarkdown); + $$(`[data-appearance-preset]`).forEach((button) => button.addEventListener("click", () => applyAppearancePreset(button.dataset.appearancePreset))); + $("#pref-reset").addEventListener("click", resetAppearance); + $("#special-folders-save").addEventListener("click", saveSpecialFolders); + $("#special-folders-auto").addEventListener("click", autoDetectSpecialFolders); + $("#identity-form").addEventListener("submit", saveIdentity); + $("#identity-new").addEventListener("click", () => editIdentity(null)); + $("#identity-delete").addEventListener("click", deleteIdentity); + $$("[data-signature-command]").forEach((button) => button.addEventListener("click", () => { + const editor = $("#identity-signature"); + editor.focus(); + document.execCommand(button.dataset.signatureCommand, false, null); + })); + $("#identity-signature-link").addEventListener("click", async () => { + const editor = $("#identity-signature"); + const range = captureEditorRange(editor); + const url = await appPrompt("Insert signature link", { placeholder: "https://example.com", type: "url", confirmLabel: "Insert" }); + if (!url) return; + editor.focus(); + restoreEditorRange(range, editor); + document.execCommand("createLink", false, url); + }); + $("#identity-signature-image-btn").addEventListener("click", () => { state.identitySignatureRange = captureEditorRange($("#identity-signature")); $("#identity-signature-image").click(); }); + $("#identity-signature-image").addEventListener("change", async (event) => { const file = event.target.files?.[0]; await insertIdentitySignatureImage(file); event.target.value = ""; }); + $("#identity-signature").addEventListener("paste", async (event) => { + const file = [...(event.clipboardData?.items || [])].find((item) => item.kind === "file" && /^image\/(png|jpeg|gif|webp)$/i.test(item.type))?.getAsFile(); + if (!file) return; + event.preventDefault(); + await insertIdentitySignatureImage(file, captureEditorRange($("#identity-signature"))); + }); + $("#contact-form").addEventListener("submit", saveContact); + $("#contact-new").addEventListener("click", () => editContact(null)); + $("#contact-import").addEventListener("click", () => $("#contact-import-file").click()); + $("#contact-import-file").addEventListener("change", (event) => importContactVcard(event.target.files)); + $("#contact-export").addEventListener("click", downloadContactExport); + $("#contact-duplicates").addEventListener("click", showContactDuplicates); + $("#contact-photo-upload").addEventListener("click", () => $("#contact-photo-file").click()); + $("#contact-photo-file").addEventListener("change", (event) => uploadContactPhoto(event.target.files)); + $("#contact-photo-delete").addEventListener("click", deleteContactPhoto); + $("#contact-delete").addEventListener("click", deleteContact); + $("#contact-compose").addEventListener("click", composeToContact); + $("#contact-search").addEventListener("input", (event) => { clearTimeout(event.target._timer); event.target._timer = setTimeout(() => loadContacts(event.target.value.trim()), 180); }); + $("#calendar-form").addEventListener("submit", saveCalendarEvent); + $("#calendar-import").addEventListener("click", () => $("#calendar-import-file").click()); + $("#calendar-import-file").addEventListener("change", (event) => importCalendarIcs(event.target.files)); + $("#calendar-export").addEventListener("click", exportCalendarIcs); + $("#calendar-recurrence").addEventListener("change", updateCalendarRecurrenceUi); + $("#calendar-new").addEventListener("click", () => editCalendarEvent(null)); + $("#calendar-cancel").addEventListener("click", () => $("#calendar-form").classList.add("hidden")); + $("#calendar-delete").addEventListener("click", deleteCalendarEvent); + $("#calendar-view").addEventListener("change", async (event) => { state.calendarView = event.target.value; localStorage.setItem("rustbox.calendarView", state.calendarView); await loadCalendar(); }); + $("#calendar-prev").addEventListener("click", async () => { shiftCalendarCursor(-1); await loadCalendar(); }); + $("#calendar-next").addEventListener("click", async () => { shiftCalendarCursor(1); await loadCalendar(); }); + $("#calendar-today").addEventListener("click", async () => { state.calendarCursor = new Date(); await loadCalendar(); }); + $("#notify-save").addEventListener("click", saveNotificationPreferences); + $("#notify-permission").addEventListener("click", requestNotificationPermission); + $("#mail-pref-save").addEventListener("click", saveMailPreferences); + $("#mail-pref-reconcile-labels")?.addEventListener("click", reconcileImapLabels); + $("#diagnostics-refresh").addEventListener("click", loadDiagnostics); + $("#diagnostic-imap-reconnect").addEventListener("click", reconnectImap); + $("#diagnostic-search-rebuild").addEventListener("click", rebuildSearchIndex); + $("#remote-image-load").addEventListener("click", () => { if (state.currentDetail) renderMessageBody(state.currentDetail, true); }); + const sieveBuilderFields = [ + "#sieve-builder-name", "#sieve-builder-enabled", "#sieve-builder-stop", + "#sieve-builder-condition", "#sieve-builder-match", "#sieve-builder-header", "#sieve-builder-value", "#sieve-builder-negate", + "#sieve-builder-condition-2", "#sieve-builder-match-2", "#sieve-builder-header-2", "#sieve-builder-value-2", "#sieve-builder-negate-2", "#sieve-builder-logic", + "#sieve-builder-condition-3", "#sieve-builder-match-3", "#sieve-builder-header-3", "#sieve-builder-value-3", "#sieve-builder-negate-3", "#sieve-builder-logic-3", + "#sieve-builder-action", "#sieve-builder-target", "#sieve-builder-action-2", "#sieve-builder-target-2", + ]; + sieveBuilderFields.forEach((selector) => { + const node = $(selector); + if (!node) return; + node.addEventListener("input", updateSieveBuilder); + node.addEventListener("change", updateSieveBuilder); + }); + $("#sieve-rule-new").addEventListener("click", openNewSieveRuleDialog); + $("#sieve-builder-add-condition").addEventListener("click", () => { $("#sieve-builder-extra-conditions").append(createSieveDynamicCondition()); updateSieveBuilder(); }); + $("#sieve-builder-add-action").addEventListener("click", () => { $("#sieve-builder-extra-actions").append(createSieveDynamicAction()); updateSieveBuilder(); }); + $("#sieve-vacation-start").addEventListener("change", updateSieveBuilder); + $("#sieve-vacation-end").addEventListener("change", updateSieveBuilder); + $("#sieve-builder-append").addEventListener("click", () => applySieveVisualRule(false)); + $("#sieve-builder-replace").addEventListener("click", () => applySieveVisualRule(true)); + $("#sieve-builder-clear").addEventListener("click", clearSieveRuleBuilder); + $("#sieve-editor").addEventListener("input", () => { + state.sieveEditingRuleId = null; + renderManagedSieveRules(); + updateSieveBuilder(); + }); + $("#sieve-vacation-append").addEventListener("click", appendSieveVacation); + $("#sieve-backup").addEventListener("click", backupSieveScripts); + $("#sieve-restore").addEventListener("click", () => $("#sieve-restore-file").click()); + $("#sieve-restore-file").addEventListener("change", (event) => restoreSieveBackup(event.target.files?.[0])); + $("#sieve-refresh").addEventListener("click", loadSieve); + $("#sieve-new").addEventListener("click", newSieveScript); + $("#sieve-check").addEventListener("click", validateSieve); + $("#sieve-save").addEventListener("click", saveSieve); + $("#sieve-activate").addEventListener("click", activateSieve); + $("#sieve-deactivate").addEventListener("click", deactivateSieve); + $("#sieve-delete").addEventListener("click", deleteSieve); + bindEditor(); + document.addEventListener("click", (event) => { if (!event.target.closest("#context-menu")) hideContextMenu(); }); + window.addEventListener("blur", hideContextMenu); + document.addEventListener("scroll", hideContextMenu, true); + document.addEventListener("keydown", (event) => { + const tag = document.activeElement?.tagName || ""; + if (event.key === "/" && !["INPUT", "TEXTAREA"].includes(tag) && !document.activeElement?.isContentEditable) { + event.preventDefault(); $("#search-input").focus(); + } + if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "n") { event.preventDefault(); openCompose("new"); } + if (event.key === "?" && !["INPUT", "TEXTAREA", "SELECT"].includes(tag) && !document.activeElement?.isContentEditable) { + event.preventDefault(); + if (!document.querySelector("dialog[open]")) $("#shortcuts-dialog").showModal(); + } + const dialogOpen = Boolean(document.querySelector("dialog[open]")); + const keyboardIdle = !dialogOpen && !["INPUT", "TEXTAREA", "SELECT"].includes(tag) && !document.activeElement?.isContentEditable; + if (keyboardIdle) { + const globalKey = event.key.toLowerCase(); + if (globalKey === "c") { event.preventDefault(); openCompose("new"); return; } + if (globalKey === "v") { event.preventDefault(); setSelectionMode(!state.selectionMode); return; } + } + if (keyboardIdle && state.currentDetail) { + const key = event.key.toLowerCase(); + if (key === "x") { + const current = state.messages.find((message) => message.uid === state.currentDetail.uid && (message.folder || state.folder) === state.currentDetail.folder); + if (current) { event.preventDefault(); setMessageSelected(current, !state.selectedMessages.has(messageKey(current))); } + } + else if (key === "j") { event.preventDefault(); navigateMessage(1); } + else if (key === "k") { event.preventDefault(); navigateMessage(-1); } + else if (key === "r") { event.preventDefault(); openCompose("reply"); } + else if (key === "a") { event.preventDefault(); openCompose("reply-all"); } + else if (key === "f") { event.preventDefault(); openCompose("forward"); } + else if (key === "e") { event.preventDefault(); archiveCurrent(); } + else if (key === "m") { event.preventDefault(); toggleUnread(); } + else if (key === "s") { event.preventDefault(); toggleFlagged(); } + else if (event.key === "!") { event.preventDefault(); junkCurrent(); } + else if (event.key === "Delete") { event.preventDefault(); deleteCurrent(); } + } + if (event.key === "Escape" && innerWidth <= 820) closeReader(); + }); + matchMedia("(prefers-color-scheme: light)").addEventListener("change", () => { if (state.prefs.theme === "system") applyPreferences(); }); + } + + async function boot() { + applyPreferences(); + bindEvents(); + await loadAuthPolicy(); + const authenticated = await loadAccounts(); + if (authenticated) { await loadPreferences(); await loadMailPreferences(); await loadOpenPgp(); await loadIdentities(); await loadFolders(true); await loadSpecialFolders(); await loadTags(); await loadNotificationPreferences(); connectRealtime(); } else { await loadTranslations(state.prefs.language); } + } + + boot(); +})(); diff --git a/src/web/css/00-tokens.css b/src/web/css/00-tokens.css new file mode 100644 index 0000000..5c31f50 --- /dev/null +++ b/src/web/css/00-tokens.css @@ -0,0 +1,114 @@ +:root { + color-scheme: dark; + --bg: #121513; + --panel: #181c19; + --panel-2: #1e231f; + --panel-3: #242a25; + --sidebar: #101310; + --line: #313832; + --line-strong: #485149; + --text: #edf0ec; + --muted: #9da79f; + --subtle: #7c877f; + --accent: #8fb9a4; + --accent-strong: #a4c9b6; + --accent-ink: #102018; + --danger: #e18f8a; + --warning: #d0ad70; + --success: #a7c892; + --field: #121713; + --selected: #28352e; + --hover: #202621; + --shadow: rgba(0, 0, 0, .32); + --font-sans: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --font-mono: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace; + --radius-control: 3px; + --radius-panel: 4px; + --ui-size: 13px; + --row-height: 58px; + --control-height: 30px; + --appbar-height: 44px; + --dialog-header-height: 43px; + --ui-scale-factor: 1; + --density-factor: 1; + --icon-size: 18px; + --button-pad-x: 10px; + --button-pad-y: 5px; + --field-pad-x: 8px; + --field-pad-y: 5px; +} +html[data-theme="light"] { + color-scheme: light; + --bg: #f2f4f1; + --panel: #ffffff; + --panel-2: #f7f8f6; + --panel-3: #edf1ec; + --sidebar: #e9eee9; + --line: #cbd2cb; + --line-strong: #aeb8af; + --text: #202622; + --muted: #647068; + --subtle: #7a847d; + --accent: #507b67; + --accent-strong: #416b58; + --accent-ink: #ffffff; + --danger: #a94c48; + --warning: #8a6930; + --success: #587a49; + --field: #ffffff; + --selected: #dce8e0; + --hover: #e8ede8; + --shadow: rgba(31, 39, 33, .16); +} + +/* User personalization */ +:root { + --folder-pane-width: 228px; + --message-pane-width: 420px; +} +html[data-color-preset="blue"] { --accent: #6f96c9; --accent-strong: #86a9d5; --selected: color-mix(in srgb, var(--accent) 20%, var(--panel)); } +html[data-color-preset="slate"] { --accent: #8e9aa8; --accent-strong: #aab4bf; --selected: color-mix(in srgb, var(--accent) 20%, var(--panel)); } +html[data-color-preset="amber"] { --accent: #c69a55; --accent-strong: #d7ad6b; --selected: color-mix(in srgb, var(--accent) 20%, var(--panel)); } +html[data-color-preset="violet"] { --accent: #9b88c9; --accent-strong: #ad9bd5; --selected: color-mix(in srgb, var(--accent) 20%, var(--panel)); } +html[data-color-preset="rose"] { --accent: #c98591; --accent-strong: #d79aa5; --selected: color-mix(in srgb, var(--accent) 20%, var(--panel)); } +html[data-color-preset="custom"] { --bg: var(--user-bg, #121513); --panel: var(--user-panel, #181c19); --panel-2: color-mix(in srgb, var(--panel) 88%, var(--text)); --panel-3: color-mix(in srgb, var(--panel) 80%, var(--text)); --sidebar: color-mix(in srgb, var(--bg) 88%, black); --text: var(--user-text, #edf0ec); --muted: color-mix(in srgb, var(--text) 66%, var(--panel)); --subtle: color-mix(in srgb, var(--text) 52%, var(--panel)); --field: color-mix(in srgb, var(--bg) 82%, var(--panel)); --line: color-mix(in srgb, var(--text) 18%, var(--panel)); --line-strong: color-mix(in srgb, var(--text) 30%, var(--panel)); --hover: color-mix(in srgb, var(--text) 8%, var(--panel)); --accent: var(--user-accent, #6f9c86); --accent-strong: color-mix(in srgb, var(--accent) 82%, white); --selected: color-mix(in srgb, var(--accent) 22%, var(--panel)); } +html[data-font-family="inter"] { --font-sans: Inter, ui-sans-serif, system-ui, sans-serif; } +html[data-font-family="humanist"] { --font-sans: "Segoe UI", Candara, Calibri, ui-sans-serif, system-ui, sans-serif; } +html[data-font-family="mono"] { --font-sans: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace; } +html[data-contrast="soft"] { --line: color-mix(in srgb, var(--line) 70%, transparent); --line-strong: color-mix(in srgb, var(--line-strong) 78%, transparent); } +html[data-contrast="high"] { --line: color-mix(in srgb, var(--text) 28%, var(--panel)); --line-strong: color-mix(in srgb, var(--text) 48%, var(--panel)); --muted: color-mix(in srgb, var(--text) 74%, var(--panel)); } +html[data-hide-avatars="true"] .avatar { display: none !important; } +html[data-hide-attachments="true"] .message-attachment, html[data-hide-attachments="true"] .attachment-indicator { display: none !important; } +html[data-hide-flags="true"] .message-flag, html[data-hide-flags="true"] .flag-indicator { display: none !important; } +html[data-reduce-motion="true"] * { scroll-behavior: auto !important; transition: none !important; animation: none !important; } +html[data-color-preset="teal"] { --accent:#4f9d93; --accent-strong:#68b0a5; --selected:color-mix(in srgb,var(--accent) 20%,var(--panel)); } +html[data-color-preset="cyan"] { --accent:#4c9fba; --accent-strong:#68b4cb; --selected:color-mix(in srgb,var(--accent) 20%,var(--panel)); } +html[data-color-preset="indigo"] { --accent:#7184c5; --accent-strong:#8b9bd2; --selected:color-mix(in srgb,var(--accent) 20%,var(--panel)); } +html[data-color-preset="graphite"] { --accent:#9aa19c; --accent-strong:#b0b6b2; --selected:color-mix(in srgb,var(--accent) 18%,var(--panel)); } +html[data-color-preset="orange"] { --accent:#c9824e; --accent-strong:#d69a6d; --selected:color-mix(in srgb,var(--accent) 20%,var(--panel)); } +html[data-color-preset="red"] { --accent:#bd6662; --accent-strong:#cf7e7a; --selected:color-mix(in srgb,var(--accent) 20%,var(--panel)); } + +/* Advanced per-user interface controls. */ +:root { + --toolbar-density-factor: 1; + --sidebar-item-height: 29px; + --reader-max-width: none; + --composer-width: 900px; + --unread-font-weight: 730; + --folder-bg: var(--sidebar); + --message-bg: var(--panel); + --reader-bg: var(--panel); +} +html[data-monochrome="true"] { --accent:#929a95; --accent-strong:#aeb4b0; --danger:#9f7775; --warning:#9b8e75; --success:#879482; } +html[data-scrollbar-density="normal"] * { scrollbar-width:auto; } +html[data-scrollbar-density="thin"] * { scrollbar-width:thin; } +html[data-scrollbar-density="hidden"] * { scrollbar-width:none; } +html[data-scrollbar-density="hidden"] *::-webkit-scrollbar { width:0; height:0; } +html[data-show-borders="false"] .folder-pane, +html[data-show-borders="false"] .message-pane, +html[data-show-borders="false"] .pane-toolbar, +html[data-show-borders="false"] .reader-toolbar, +html[data-show-borders="false"] .message-header, +html[data-show-borders="false"] .thread-section, +html[data-show-borders="false"] .attachment-list, +html[data-show-borders="false"] .message-row { border-color:transparent !important; } diff --git a/src/web/css/10-base.css b/src/web/css/10-base.css new file mode 100644 index 0000000..cecd2ee --- /dev/null +++ b/src/web/css/10-base.css @@ -0,0 +1,206 @@ +* { + box-sizing: border-box; + scrollbar-color: var(--line-strong) var(--panel); + scrollbar-width: thin; +} + +html, +body { + height: 100%; +} + +body { + margin: 0; + overflow: hidden; + background: var(--bg); + color: var(--text); + font: var(--ui-size)/1.45 var(--font-sans); +} + +button, +input, +select, +textarea { + font: inherit; + color: inherit; +} + +button { + cursor: pointer; +} + +button:disabled { + cursor: default; + opacity: .55; +} + +input, +select, +textarea { + width: 100%; + min-width: 0; + border: 1px solid var(--line-strong); + border-radius: var(--radius-control); + background: var(--field); + min-height: var(--control-height); + padding: var(--field-pad-y) var(--field-pad-x); + outline: none; +} + +input:focus, +select:focus, +textarea:focus, +[contenteditable="true"]:focus { + border-color: var(--accent); + box-shadow: 0 0 0 1px var(--accent); + outline: 0; +} + +button:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +svg { + width: var(--icon-size); + height: var(--icon-size); + fill: none; + stroke: currentColor; + stroke-width: 1.8; + stroke-linecap: round; + stroke-linejoin: round; +} + +.hidden, +[hidden] { + display: none !important; +} + +.muted { + color: var(--muted); +} + +.wide { + width: 100%; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.button { + min-height: var(--control-height); + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + border: 1px solid var(--line-strong); + border-radius: var(--radius-control); + padding: var(--button-pad-y) var(--button-pad-x); + background: var(--panel-2); + color: var(--text); + font-weight: 600; +} + +.button:hover { + border-color: var(--accent); +} + +.button.primary { + border-color: var(--accent); + background: var(--accent); + color: var(--accent-ink); +} + +.button.primary:hover { + background: var(--accent-strong); +} + +.button.quiet { + background: transparent; +} + +.button.danger, +.icon-button.danger { + color: var(--danger); +} + +.icon-button { + width: var(--control-height); + height: var(--control-height); + display: inline-grid; + place-items: center; + padding: 0; + border: 1px solid transparent; + border-radius: var(--radius-control); + background: transparent; + color: var(--muted); +} + +.icon-button:hover { + color: var(--text); + background: var(--hover); + border-color: var(--line); +} + +.icon-button.small { + width: 26px; + height: 26px; +} + +.icon-button.small svg { + width: 15px; + height: 15px; +} + +.form-error { + margin-top: 8px; + padding: 8px 9px; + border: 1px solid color-mix(in srgb, var(--danger) 55%, var(--line)); + color: var(--danger); + background: color-mix(in srgb, var(--danger) 8%, var(--panel)); + border-radius: var(--radius-control); +} + +label>span, +.field-label { + display: block; + margin-bottom: 5px; + color: var(--muted); + font-size: 11px; + font-weight: 650; +} + +.checkbox-row { + display: flex; + align-items: center; + gap: 8px; +} + +.checkbox-row input { + width: 15px; + height: 15px; + min-height: 0; + margin: 0; +} + +.checkbox-row span { + margin: 0; +} + +kbd { + font: 10px var(--font-mono); + color: var(--subtle); + border: 1px solid var(--line); + border-bottom-color: var(--line-strong); + border-radius: 3px; + padding: 1px 5px; +} \ No newline at end of file diff --git a/src/web/css/20-shell.css b/src/web/css/20-shell.css new file mode 100644 index 0000000..7d3e12e --- /dev/null +++ b/src/web/css/20-shell.css @@ -0,0 +1,326 @@ +.boot-screen { + height: 100%; + display: grid; + place-content: center; + justify-items: center; + gap: 12px; + background: var(--bg); +} + +.boot-screen strong { + font-size: 14px; + letter-spacing: .2px; +} + +.boot-icon { + width: 32px; + height: 32px; + color: var(--accent); +} + +.login-view { + height: 100%; + display: grid; + place-items: center; + padding: 24px; + background: var(--bg); +} + +.login-panel { + width: min(100%, 360px); + border: 1px solid var(--line); + background: var(--panel); + border-radius: var(--radius-panel); + box-shadow: 0 18px 46px var(--shadow); +} + +.login-brand { + display: flex; + align-items: center; + gap: 11px; + padding: 18px 20px; + border-bottom: 1px solid var(--line); +} + +.brand-icon { + width: 24px; + height: 24px; + color: var(--accent); +} + +.login-brand div { + display: grid; + gap: 1px; +} + +.login-brand strong { + font-size: 16px; +} + +.login-brand span { + font-size: 11px; + color: var(--muted); +} + +.login-form { + display: grid; + gap: 13px; + padding: 20px; +} + +.login-panel footer { + padding: 10px 20px 14px; + color: var(--subtle); + font-size: 10px; + text-align: center; +} + +.app-view { + height: 100%; + display: grid; + grid-template-rows: var(--appbar-height, 44px) minmax(0, 1fr); + background: var(--bg); +} + +.appbar { + display: grid; + grid-template-columns: 220px minmax(260px, 620px) 1fr; + align-items: center; + gap: 16px; + padding: 0 10px; + border-bottom: 1px solid var(--line); + background: var(--panel); +} + +.app-brand { + display: flex; + align-items: center; + gap: 8px; + padding-left: 3px; +} + +.app-brand strong { + font-size: 14px; + letter-spacing: -.2px; +} + +.search-field { + height: 30px; + display: grid; + grid-template-columns: 22px 1fr auto auto; + align-items: center; + gap: 4px; + padding: 0 7px; + border: 1px solid var(--line-strong); + border-radius: var(--radius-control); + background: var(--field); +} + +.search-field:focus-within { + border-color: var(--accent); + box-shadow: 0 0 0 1px var(--accent); +} + +.search-field svg { + width: 15px; + height: 15px; + color: var(--muted); +} + +.search-field input { + min-height: 0; + height: 28px; + border: 0; + padding: 0 3px; + background: transparent; + box-shadow: none; +} + +.search-field input:focus { + box-shadow: none; +} + +.appbar-actions { + justify-self: end; + display: flex; + align-items: center; + gap: 4px; +} + +.account-summary { + display: grid; + min-width: 120px; + margin-left: 6px; + padding-left: 10px; + border-left: 1px solid var(--line); + line-height: 1.25; +} + +.account-summary span:first-child { + max-width: 190px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 11px; + font-weight: 650; +} + +.account-summary span:last-child { + color: var(--success); + font-size: 9px; +} + +.mail-shell { + min-height: 0; + display: grid; + grid-template-columns: var(--folder-pane-width) minmax(300px, var(--message-pane-width)) minmax(420px, 1fr); + overflow: hidden; +} + +.folder-pane, +.message-pane, +.reader-pane { + min-width: 0; + min-height: 0; +} + +.message-pane { + overflow: hidden; +} + +.reader-pane { + container-name: reader-pane; + container-type: inline-size; +} + +.folder-pane { + display: flex; + flex-direction: column; + padding: 10px; + border-right: 1px solid var(--line); + background: var(--sidebar); +} + +.compose-button { + justify-content: flex-start; + margin-bottom: 12px; +} + +.section-heading { + min-height: 27px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + color: var(--muted); + font-size: 10px; + font-weight: 700; + letter-spacing: .08em; + text-transform: uppercase; +} + +.folder-pane-footer { + margin-top: auto; + padding-top: 10px; + border-top: 1px solid var(--line); + display: flex; + justify-content: space-between; + color: var(--subtle); + font: 9px var(--font-mono); +} + +.mobile-only { + display: none; +} + +.search-filter-button { + width: 24px; + height: 24px; + display: grid; + place-items: center; + padding: 0; + border: 0; + border-radius: var(--radius-control); + background: transparent; + color: var(--muted); +} + +.search-filter-button:hover, +.search-filter-button:focus-visible { + background: var(--hover); + color: var(--text); + outline: none; +} + +.search-filter-button svg { + width: 14px; + height: 14px; +} + +.folder-pane, +.message-pane { + position: relative; +} + +.pane-resizer { + position: absolute; + z-index: 8; + top: 0; + right: -3px; + bottom: 0; + width: 7px; + cursor: col-resize; + touch-action: none; + background: transparent; +} + +.pane-resizer::after { + content: ""; + position: absolute; + top: 0; + bottom: 0; + left: 3px; + width: 1px; + background: transparent; +} + +.pane-resizer:hover::after, +.resizing-pane .pane-resizer::after { + background: var(--accent); +} + +.resizing-pane { + cursor: col-resize !important; + user-select: none !important; +} + +.resizing-pane iframe { + pointer-events: none; +} + +@media (max-width: 820px) { + .pane-resizer { + display: none; + } +} + +.offline-banner { + position: fixed; + z-index: 80; + top: calc(var(--appbar-height, 44px) + 6px); + left: 50%; + max-width: min(620px, calc(100vw - 24px)); + transform: translateX(-50%); + padding: 7px 10px; + border: 1px solid var(--warning); + border-radius: var(--radius-control); + background: var(--panel-3); + color: var(--text); + box-shadow: 0 8px 24px var(--shadow); + font-size: 10px; + text-align: center; +} + +.network-offline .account-summary span:last-child { + color: var(--warning); +} \ No newline at end of file diff --git a/src/web/css/30-mail.css b/src/web/css/30-mail.css new file mode 100644 index 0000000..3ee8854 --- /dev/null +++ b/src/web/css/30-mail.css @@ -0,0 +1,1035 @@ +.folder-list { + display: grid; + gap: 1px; + overflow: auto; +} + +.folder-button { + min-height: 29px; + width: 100%; + display: grid; + grid-template-columns: 22px minmax(0, 1fr) auto; + align-items: center; + gap: 5px; + padding: 3px 6px 3px calc(6px + (var(--folder-depth, 0) * 12px)); + border: 1px solid transparent; + border-radius: var(--radius-control); + background: transparent; + color: var(--muted); + text-align: left; +} + +.folder-button svg { + width: 15px; + height: 15px; +} + +.folder-button .folder-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.folder-button:hover { + color: var(--text); + background: var(--hover); +} + +.folder-button.active { + color: var(--text); + background: var(--selected); + border-color: var(--line); +} + +.folder-count { + min-width: 20px; + text-align: right; + color: var(--subtle); + font: 10px var(--font-mono); +} + +.folder-count.unread { + color: var(--accent); + font-weight: 700; +} + +.message-pane { + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto; + border-right: 1px solid var(--line); + background: var(--panel); +} + +.pane-toolbar { + min-height: 48px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 7px 9px; + border-bottom: 1px solid var(--line); +} + +.pane-title { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; +} + +.pane-title>div { + display: grid; + min-width: 0; +} + +.pane-title strong { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 13px; +} + +.pane-title span { + color: var(--subtle); + font-size: 10px; +} + +.toolbar-actions, +.reader-toolbar { + display: flex; + align-items: center; + gap: 3px; +} + +.message-list { + min-height: 0; + overflow-y: auto; + overflow-x: hidden; +} + +.message-row { + width: 100%; + min-height: var(--row-height); + display: grid; + grid-template-columns: 19px minmax(0, 1fr) auto; + grid-template-rows: auto auto; + column-gap: 8px; + row-gap: 2px; + align-content: center; + padding: 7px 9px; + border: 0; + border-bottom: 1px solid var(--line); + background: var(--panel); + color: var(--text); + text-align: left; +} + +.message-row:hover { + background: var(--hover); +} + +.message-row.active { + background: var(--selected); +} + +.message-row.unread .message-from, +.message-row.unread .message-subject { + font-weight: 730; +} + +.message-state { + grid-row: 1 / 3; + align-self: center; + width: 8px; + height: 8px; + border: 1px solid var(--line-strong); + border-radius: 50%; +} + +.message-row.unread .message-state { + background: var(--accent); + border-color: var(--accent); +} + +.message-main { + min-width: 0; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: baseline; + gap: 8px; +} + +.message-from, +.message-subject, +.message-snippet { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.message-from { + font-size: 11px; +} + +.message-date { + color: var(--subtle); + font: 9px var(--font-mono); + white-space: nowrap; +} + +.message-subject-line { + min-width: 0; + display: flex; + align-items: center; + gap: 6px; +} + +.message-subject { + font-size: 11px; +} + +.message-snippet { + color: var(--muted); + font-size: 10px; +} + +.message-icons { + display: inline-flex; + gap: 3px; + color: var(--subtle); +} + +.message-icons svg { + width: 12px; + height: 12px; +} + +.load-more { + margin: 7px; +} + +.reader-pane { + position: relative; + overflow: hidden; + background: var(--panel-2); +} + +.reader-empty { + height: 100%; + display: grid; + place-content: center; + justify-items: center; + gap: 7px; + color: var(--muted); +} + +.reader-empty svg { + width: 34px; + height: 34px; + color: var(--line-strong); +} + +.reader-empty strong { + color: var(--text); + font-size: 13px; +} + +.reader-empty span { + font-size: 11px; +} + +.reader-content { + height: 100%; + overflow: auto; + background: var(--panel); +} + +.reader-toolbar { + position: sticky; + top: 0; + z-index: 4; + min-height: 42px; + padding: 5px 10px; + border-bottom: 1px solid var(--line); + background: var(--panel); +} + +.reader-toolbar .button { + min-height: 29px; +} + +.reader-toolbar .button svg { + width: 15px; + height: 15px; +} + +.toolbar-spacer { + flex: 1; +} + +.move-control select { + width: 118px; + height: 29px; + min-height: 29px; + padding-top: 3px; + padding-bottom: 3px; +} + +.message-header { + padding: 20px 22px 14px; + border-bottom: 1px solid var(--line); +} + +.message-header h1 { + margin: 0 0 15px; + font-size: 20px; + line-height: 1.25; + font-weight: 620; + letter-spacing: -.25px; + overflow-wrap: anywhere; +} + +.message-meta { + display: grid; + grid-template-columns: 32px minmax(0, 1fr) auto; + align-items: center; + gap: 9px; +} + +.avatar { + width: 32px; + height: 32px; + display: grid; + place-items: center; + border: 1px solid var(--line-strong); + border-radius: 50%; + background: var(--panel-2); + color: var(--muted); + font-weight: 700; +} + +.sender { + display: grid; + min-width: 0; +} + +.sender strong, +.sender span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sender strong { + font-size: 12px; +} + +.sender span, +.message-meta time { + color: var(--muted); + font-size: 10px; +} + +.thread-section { + padding: 9px 22px; + border-bottom: 1px solid var(--line); +} + +.thread-list { + display: grid; + gap: 1px; +} + +.thread-item { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px; + padding: 6px 7px; + border: 0; + border-radius: var(--radius-control); + background: transparent; + color: var(--muted); + text-align: left; +} + +.thread-item:hover { + background: var(--hover); + color: var(--text); +} + +.thread-item.current { + background: var(--selected); + color: var(--text); +} + +.thread-item span:first-child { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.thread-item time { + font: 9px var(--font-mono); + color: var(--subtle); +} + +.attachment-list { + display: flex; + flex-wrap: wrap; + gap: 5px; + padding: 10px 22px; + border-bottom: 1px solid var(--line); +} + +.attachment-button { + display: inline-flex; + align-items: center; + gap: 6px; + border: 1px solid var(--line); + border-radius: var(--radius-control); + background: var(--panel-2); + color: var(--text); + padding: 5px 8px; + font-size: 10px; +} + +.attachment-button svg { + width: 13px; + height: 13px; +} + +.attachment-download-all { + display: inline-flex; + align-items: center; + gap: 6px; + border: 1px solid var(--line-strong); + border-radius: var(--radius-control); + background: transparent; + color: var(--muted); + padding: 5px 8px; + font-size: 10px; +} + +.attachment-download-all:hover { + color: var(--text); + background: var(--hover); + border-color: var(--accent); +} + +.attachment-download-all svg { + width: 13px; + height: 13px; +} + +.message-body { + min-height: 260px; + background: var(--panel); +} + +.message-body iframe { + display: block; + width: 100%; + min-height: 68vh; + border: 0; + background: #fff; +} + +.message-body pre { + margin: 0; + padding: 22px; + white-space: pre-wrap; + overflow-wrap: anywhere; + font: 13px/1.58 var(--font-sans); + color: var(--text); +} + +.empty-list { + padding: 30px 18px; + color: var(--muted); + text-align: center; + font-size: 11px; +} + +.bulk-toolbar { + position: fixed; + z-index: 72; + top: calc(var(--appbar-height, 44px) + 58px); + left: 50%; + transform: translateX(-50%); + width: max-content; + max-width: min(900px, calc(100vw - 24px)); + min-height: 40px; + display: flex; + align-items: center; + justify-content: center; + flex-wrap: wrap; + gap: 5px; + padding: 6px; + border: 1px solid var(--line-strong); + border-radius: calc(var(--radius-panel) + 4px); + background: color-mix(in srgb, var(--panel-3) 94%, transparent); + box-shadow: 0 12px 34px var(--shadow); + backdrop-filter: blur(10px); +} + +.bulk-toolbar.hidden { + display: none; +} + +.bulk-toolbar .toolbar-spacer { + display: none; +} + +.bulk-toolbar .button { + min-height: 28px; + padding: 4px 8px; +} + +.bulk-toolbar .move-control select { + width: 126px; +} + +.bulk-select { + display: flex; + align-items: center; + gap: 7px; + color: var(--muted); + white-space: nowrap; +} + +.message-row { + grid-template-columns: 19px minmax(0, 1fr) auto; + cursor: default; +} + +.message-select { + display: none; + grid-row: 1 / 3; + align-self: center; + margin: 0; + width: 14px; + height: 14px; + accent-color: var(--accent); +} + +.message-state { + grid-column: 1; +} + +.message-main { + grid-column: 2; +} + +.message-subject-line { + grid-column: 2; +} + +.message-icons { + grid-column: 3; +} + +.message-list.selection-mode .message-row { + grid-template-columns: 18px 19px minmax(0, 1fr) auto; + cursor: pointer; +} + +.message-list.selection-mode .message-select { + display: block; + grid-column: 1; +} + +.message-list.selection-mode .message-state { + grid-column: 2; +} + +.message-list.selection-mode .message-main { + grid-column: 3; +} + +.message-list.selection-mode .message-subject-line { + grid-column: 3; +} + +.message-list.selection-mode .message-icons { + grid-column: 4; +} + +.message-row.selected { + background: color-mix(in srgb, var(--accent) 14%, var(--panel)); +} + +#select-mode-btn.active { + color: var(--accent); + background: var(--selected); + border-color: var(--line); +} + +.label-heading { + margin-top: 10px; +} + +.tag-list { + max-height: 150px; + overflow: auto; +} + +.tag-item { + width: 100%; + min-height: 29px; + display: grid; + grid-template-columns: 10px minmax(0, 1fr) auto; + align-items: center; + gap: 7px; + padding: 4px 7px; + border: 0; + border-radius: var(--radius-control); + background: transparent; + color: var(--muted); + text-align: left; +} + +.tag-item:hover, +.tag-item.active { + background: var(--hover); + color: var(--text); +} + +.tag-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--tag-color); +} + +.tag-count { + font-size: 10px; + color: var(--muted); +} + +.message-tags { + display: inline-flex; + flex-wrap: wrap; + gap: 3px; + margin-left: 5px; + vertical-align: middle; +} + +.message-tag { + display: inline-flex; + align-items: center; + min-height: 15px; + max-width: 120px; + padding: 0 5px; + border: 1px solid color-mix(in srgb, var(--tag-color) 48%, var(--line)); + border-radius: 999px; + background: color-mix(in srgb, var(--tag-color) 10%, transparent); + color: var(--text); + font-size: 9px; + line-height: 14px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.folder-button.drop-target { + outline: 1px solid var(--accent); + outline-offset: -1px; + background: var(--selected); +} + +.message-row.dragging { + opacity: .5; +} + +.remote-image-bar { + display: flex; + align-items: center; + gap: 10px; + margin: 0 18px 8px; + padding: 8px 10px; + border: 1px solid var(--line); + border-radius: var(--radius-control); + background: var(--panel-2); + color: var(--muted); + font-size: 12px; +} + +.remote-image-bar .button { + margin-left: auto; +} + +.source-card { + width: min(1040px, calc(100vw - 28px)); + height: min(82vh, 860px); + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto; +} + +.message-source-content { + margin: 0; + padding: 14px; + overflow: auto; + white-space: pre; + tab-size: 4; + font: 12px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + background: var(--panel-2); + color: var(--text); + border-block: 1px solid var(--line); +} + +.attachment-item { + display: inline-flex; + align-items: center; + gap: 4px; +} + +.attachment-item .attachment-button { + min-width: 0; +} + +/* Reader toolbars must remain usable at every UI scale. */ +.reader-toolbar { + max-width: 100%; + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: thin; + flex-wrap: nowrap; +} + +.reader-toolbar>*, +.bulk-toolbar>* { + flex: 0 0 auto; +} + +#spam-btn.active { + color: var(--warning); + background: color-mix(in srgb, var(--warning) 10%, transparent); + border-color: color-mix(in srgb, var(--warning) 35%, var(--line)); +} + +.message-icons { + align-items: center; + min-width: 40px; + justify-content: flex-end; +} + +.message-static-icon { + display: inline-grid; + place-items: center; + color: var(--subtle); +} + +.message-quick-actions { + display: inline-flex; + align-items: center; + gap: 1px; + opacity: 0; + pointer-events: none; +} + +.message-row:hover .message-quick-actions, +.message-row:focus-within .message-quick-actions, +.message-row.selected .message-quick-actions { + opacity: 1; + pointer-events: auto; +} + +.message-quick-action { + width: 22px; + height: 22px; + display: grid; + place-items: center; + padding: 0; + border: 0; + border-radius: var(--radius-control); + background: transparent; + color: var(--muted); +} + +.message-quick-action:hover { + background: var(--hover); + color: var(--text); +} + +.message-quick-action.active { + color: var(--warning); +} + +.message-quick-action svg { + width: 12px; + height: 12px; +} + +@media (hover:none) { + .message-quick-actions { + opacity: 1; + pointer-events: auto; + } +} + +/* Hierarchical folder tree. The disclosure control is separate from the folder action. */ +.folder-row { + display: grid; + grid-template-columns: 18px minmax(0, 1fr); + align-items: center; + padding-left: calc(var(--folder-depth, 0) * 12px); + min-width: 0; +} + +.folder-expander { + width: 18px; + height: 28px; + display: grid; + place-items: center; + padding: 0; + border: 0; + border-radius: var(--radius-control); + background: transparent; + color: var(--subtle); +} + +.folder-expander:hover, +.folder-expander:focus-visible { + color: var(--text); + background: var(--hover); +} + +.folder-expander svg { + width: 13px; + height: 13px; + transition: transform .12s ease; +} + +.folder-expander.expanded svg { + transform: rotate(90deg); +} + +.folder-expander.placeholder { + visibility: hidden; + pointer-events: none; +} + +.folder-row .folder-button { + padding-left: 4px; +} + +html[data-reduce-motion="true"] .folder-expander svg { + transition: none; +} + +#reader-from[role="button"] { + cursor: pointer; + border-radius: var(--radius-control); + outline-offset: 3px; +} + +#reader-from[role="button"]:hover { + text-decoration: underline; + text-underline-offset: 2px; +} + +#reader-from[role="button"]:focus-visible { + outline: 1px solid var(--accent); +} + +.list-sort-control select { + width: auto; + max-width: 132px; + min-height: 28px; + height: 28px; + padding: 2px 24px 2px 7px; + font-size: 10px; +} + +#thread-toggle svg { + transition: transform .12s ease; +} + +#thread-toggle.expanded svg { + transform: rotate(90deg); +} + +html[data-reduce-motion="true"] #thread-toggle svg { + transition: none; +} + + +.attachment-preview-dialog { + width: min(1040px, calc(100vw - 28px)); + max-width: none; +} + +.attachment-preview-card { + height: min(84vh, 880px); + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto; +} + +.attachment-preview-body { + min-height: 0; + overflow: auto; + display: grid; + place-items: center; + background: var(--field); +} + +.attachment-preview-body img { + display: block; + max-width: 100%; + max-height: 100%; + object-fit: contain; +} + +.attachment-preview-body iframe { + width: 100%; + height: 100%; + min-height: 420px; + border: 0; + background: #fff; +} + +.attachment-preview-body pre { + align-self: stretch; + justify-self: stretch; + margin: 0; + padding: 16px; + overflow: auto; + white-space: pre-wrap; + overflow-wrap: anywhere; + font: 12px/1.5 var(--font-mono); + color: var(--text); +} + +/* A flagged message stays visually quiet while remaining easy to scan. */ +.message-row.flagged { + box-shadow: inset 0 -2px 0 color-mix(in srgb, var(--danger) 58%, transparent); +} + +.search-highlight { + background: var(--selected); + color: inherit; + border-radius: 2px; + padding: 0 1px; +} + +/* Advanced appearance preferences. */ +.folder-pane { + background: var(--folder-bg); +} + +.folder-button, +.tag-item { + min-height: var(--sidebar-item-height); +} + +.message-pane, +.message-row { + background: var(--message-bg); +} + +.reader-content, +.message-body { + background: var(--reader-bg); +} + +.message-row.unread .message-from, +.message-row.unread .message-subject { + font-weight: var(--unread-font-weight); +} + +.message-header, +.thread-section, +.attachment-list, +.remote-image-bar, +.message-body { + width: 100%; + max-width: var(--reader-max-width); + margin-inline: auto; +} + +html[data-unread-badge="outline"] .message-row.unread .message-state { + background: transparent; + border: 2px solid var(--accent); +} + +html[data-unread-badge="solid"] .message-row.unread .message-state { + width: 12px; + height: 6px; + border-radius: 999px; + background: var(--accent); + border-color: var(--accent); +} + +html[data-compact-headers="true"] .message-header { + padding-block: 10px 8px; +} + +html[data-compact-headers="true"] .message-header h1 { + margin-bottom: 8px; + font-size: 17px; +} + +html[data-compact-headers="true"] .message-meta { + grid-template-columns: 26px minmax(0, 1fr) auto; +} + +html[data-compact-headers="true"] .message-meta .avatar { + width: 26px; + height: 26px; +} + +html[data-toolbar-density="compact"] .pane-toolbar, +html[data-toolbar-density="compact"] .reader-toolbar { + min-height: calc(36px * var(--ui-scale-factor)); + padding-block: 3px; +} + +html[data-toolbar-density="spacious"] .pane-toolbar, +html[data-toolbar-density="spacious"] .reader-toolbar { + min-height: calc(52px * var(--ui-scale-factor)); + padding-block: 8px; +} + + +/* Pane-aware anti-collision rules. These react to the pane width, not only viewport width. */ +@container reader-pane (max-width: 640px) { + .reader-toolbar { + gap: 1px; + padding-inline: 6px; + } + + .reader-toolbar .button span { + display: none; + } + + .reader-toolbar .button { + padding-inline: 6px; + } +} + +@container reader-pane (max-width: 460px) { + .reader-toolbar { + scrollbar-width: none; + } + + .reader-toolbar::-webkit-scrollbar { + display: none; + } +} + +@media (max-width: 560px) { + .bulk-toolbar { + top: auto; + bottom: 10px; + width: calc(100vw - 20px); + max-width: none; + } + + .bulk-toolbar .move-control { + display: block; + } + + .bulk-toolbar .move-control select { + width: 116px; + } +} \ No newline at end of file diff --git a/src/web/css/40-compose.css b/src/web/css/40-compose.css new file mode 100644 index 0000000..345abbc --- /dev/null +++ b/src/web/css/40-compose.css @@ -0,0 +1,786 @@ +.dialog { + padding: 0; + border: 1px solid var(--line-strong); + border-radius: var(--radius-panel); + background: transparent; + color: var(--text); + box-shadow: 0 20px 60px var(--shadow); +} + +.dialog::backdrop { + background: rgba(0, 0, 0, .58); +} + +.dialog-card { + min-width: 420px; + background: var(--panel); +} + +.dialog-header { + min-height: 43px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 6px 10px 6px 13px; + border-bottom: 1px solid var(--line); +} + +.dialog-header strong { + font-size: 13px; +} + +.compose-header-actions { + display: flex; + align-items: center; + gap: 6px; +} + +.compose-header-actions .button.small { + min-height: 28px; + padding: 4px 8px; + font-size: 10px; +} + +.compose-dialog { + width: min(900px, calc(100vw - 40px)); + max-width: none; +} + +.compose-card { + height: min(760px, calc(100dvh - 48px)); + display: grid; + grid-template-rows: auto auto auto minmax(240px, 1fr) auto auto auto auto; + overflow: hidden; + container-name: compose-card; + container-type: inline-size; +} + +.compose-addresses { + display: grid; + gap: 6px; + max-height: min(46dvh, 380px); + padding: 10px 12px; + border-bottom: 1px solid var(--line); + background: var(--panel); + overflow: auto; +} + +.compose-addresses>label { + display: grid; + grid-template-columns: 72px minmax(0, 1fr); + align-items: center; + gap: 10px; + min-height: 34px; + border: 0; +} + +.compose-addresses>label>span { + margin: 0; + padding: 0; + font-size: 10px; + text-align: right; +} + +.compose-addresses>label input, +.compose-addresses>label select { + min-height: 34px; + border: 1px solid var(--line); + border-radius: var(--radius-control); + background: var(--field); + box-shadow: none; +} + +.compose-addresses>label input:focus, +.compose-addresses>label select:focus { + border-color: var(--accent); + box-shadow: 0 0 0 1px var(--accent); +} + +.compose-options-row { + display: grid; + grid-template-columns: 72px minmax(0, 1fr); + align-items: start; + gap: 10px; + min-height: 34px; +} + +.compose-options-row>span { + padding-top: 8px; + font-size: 10px; + color: var(--muted); + text-align: right; +} + +.compose-options { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 7px; + padding: 0; +} + +.compose-options label { + display: inline-flex; + grid-template-columns: none; + align-items: center; + gap: 6px; + min-height: 34px; + margin: 0; + padding: 3px 7px; + border: 1px solid var(--line); + border-radius: var(--radius-control); + background: var(--panel-2); + font-size: 10px; + color: var(--muted); +} + +.compose-options label>span { + margin: 0; + padding: 0; + font-size: 10px; +} + +.compose-options select { + min-width: 92px; + min-height: 26px; + border: 0; + background: transparent; + padding-block: 2px; +} + +.compose-options .compose-mdn-option input { + min-height: auto; + width: 14px; + height: 14px; +} + +.editor-toolbar { + min-height: 36px; + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 2px; + row-gap: 3px; + padding: 4px 8px; + border-bottom: 1px solid var(--line); + background: var(--panel-2); +} + +.editor-toolbar button { + width: 28px; + height: 27px; + display: grid; + place-items: center; + border: 1px solid transparent; + border-radius: var(--radius-control); + background: transparent; + color: var(--muted); +} + +.editor-toolbar button:hover { + border-color: var(--line); + background: var(--hover); + color: var(--text); +} + +.editor-toolbar svg { + width: 15px; + height: 15px; +} + +.toolbar-separator { + width: 1px; + height: 18px; + margin: 0 4px; + background: var(--line); +} + +.editor-mode { + margin-left: auto; + color: var(--subtle); + font: 9px var(--font-mono); +} + +.compose-editor { + min-height: 0; + overflow: auto; + padding: 18px 20px; + background: var(--field); + border: 0; + outline: none; + font: 14px/1.6 var(--font-sans); +} + +.compose-editor:empty::before { + content: "Write a message"; + color: var(--subtle); +} + +.compose-editor a { + color: var(--accent); +} + +.dialog-actions { + display: flex; + align-items: center; + gap: 7px; + padding: 8px 10px; + border-top: 1px solid var(--line); +} + +.dialog-actions> :first-child { + margin-right: auto; +} + +.compose-upload-progress { + display: grid; + grid-template-columns: minmax(80px, 1fr) auto; + align-items: center; + gap: 8px; + padding: 7px 10px; + border-top: 1px solid var(--line); + background: var(--panel-2); + color: var(--muted); + font-size: 10px; +} + +.compose-upload-progress.hidden { + display: none; +} + +.compose-upload-track { + height: 4px; + overflow: hidden; + border-radius: 999px; + background: var(--line); +} + +.compose-upload-track>span { + display: block; + width: 0; + height: 100%; + background: var(--accent); + transition: width .12s linear; +} + +.compose-attachments { + display: flex; + flex-wrap: wrap; + gap: 6px; + padding: 8px 10px; + border-top: 1px solid var(--line); + background: var(--panel-2); +} + +.compose-attachment { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 5px 7px; + border: 1px solid var(--line); + border-radius: var(--radius-control); + background: var(--field); +} + +.compose-attachment button { + width: 20px; + height: 20px; + display: grid; + place-items: center; + padding: 0; + border: 0; + background: transparent; + color: var(--muted); +} + +.compose-mode-control { + margin-left: auto; +} + +.compose-mode-control select { + min-width: 108px; +} + +.compose-editor.plain-mode { + font-family: var(--font-mono); + white-space: pre-wrap; +} + +.editor-toolbar button:disabled { + opacity: .4; + cursor: not-allowed; +} + +.editor-select select { + height: 28px; + max-width: 104px; + border: 1px solid var(--line); + background: var(--panel); + color: var(--text); + font: inherit; +} + +.editor-color { + display: inline-flex; + align-items: center; +} + +.editor-color input { + width: 30px; + height: 28px; + padding: 2px; + border: 1px solid var(--line); + background: var(--panel); +} + +.recipient-field { + position: relative; +} + +.recipient-suggestions { + position: absolute; + z-index: 120; + top: calc(100% + 2px); + left: 0; + right: 0; + max-height: 220px; + overflow: auto; + padding: 3px; + border: 1px solid var(--line-strong); + border-radius: var(--radius-control); + background: var(--panel-3); + box-shadow: 0 10px 26px var(--shadow); +} + +.recipient-suggestion { + width: 100%; + min-height: 34px; + display: grid; + gap: 1px; + padding: 5px 7px; + border: 0; + border-radius: var(--radius-control); + background: transparent; + color: var(--text); + text-align: left; +} + +.recipient-suggestion:hover, +.recipient-suggestion.active { + background: var(--selected); +} + +.recipient-suggestion strong { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 11px; +} + +.recipient-suggestion span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--muted); + font-size: 10px; +} + + +/* Inline image editing */ +.compose-editor img[data-rustbox-cid] { + max-width: 100%; + height: auto; + cursor: pointer; + border-radius: min(3px, var(--radius-control)); +} + +.compose-editor img[data-rustbox-cid].compose-inline-image-selected { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.image-edit-dialog { + width: min(840px, calc(100vw - 28px)); + max-width: none; +} + +.image-edit-card { + width: 100%; + height: min(720px, calc(100dvh - 28px)); + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto; + overflow: hidden; +} + +.image-edit-layout { + min-height: 0; + display: grid; + grid-template-columns: minmax(0, 1.35fr) minmax(240px, .65fr); + overflow: hidden; +} + +.image-edit-preview { + min-width: 0; + min-height: 0; + display: grid; + place-items: center; + overflow: auto; + padding: 16px; + background: var(--field); +} + +.image-edit-preview canvas { + display: block; + max-width: 100%; + max-height: 100%; + width: auto; + height: auto; + border: 1px solid var(--line); + background: var(--panel); +} + +.image-edit-controls { + min-height: 0; + overflow: auto; + padding: 16px; + border-left: 1px solid var(--line); +} + +.image-edit-controls>label, +.image-crop-controls label { + display: grid; + gap: 6px; + margin-bottom: 14px; +} + +.image-edit-controls label>span, +.image-crop-controls label>span { + color: var(--muted); + font-size: 10px; +} + +.image-edit-range-line { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 8px; +} + +.image-edit-range-line output { + min-width: 46px; + text-align: right; + color: var(--muted); + font: 10px var(--font-mono); +} + +.image-crop-controls { + margin: 8px 0 0; + padding: 12px; + border: 1px solid var(--line); + border-radius: var(--radius-control); +} + +.image-crop-controls legend { + padding: 0 5px; + color: var(--muted); + font-size: 10px; + font-weight: 700; +} + +.image-crop-disabled { + opacity: .5; + pointer-events: none; +} + +.image-edit-help { + margin: 8px 0 0; + color: var(--subtle); + font-size: 10px; + line-height: 1.45; +} + +@media (max-width: 720px) { + .image-edit-layout { + grid-template-columns: 1fr; + grid-template-rows: minmax(220px, 1fr) auto; + } + + .image-edit-controls { + border-left: 0; + border-top: 1px solid var(--line); + max-height: 42dvh; + } +} + +.compose-dialog { + width: min(var(--composer-width), calc(100vw - 40px)); +} + +.markdown-preview-dialog { + width: min(860px, calc(100vw - 32px)); +} + +.markdown-preview-card { + max-height: min(82dvh, 820px); + display: grid; + grid-template-rows: auto minmax(0, 1fr); +} + +.markdown-preview-content { + overflow: auto; + padding: 22px; + background: var(--panel); + color: var(--text); + line-height: 1.55; +} + +.markdown-preview-content> :first-child { + margin-top: 0; +} + +.markdown-preview-content> :last-child { + margin-bottom: 0; +} + +.markdown-preview-content pre { + overflow: auto; + padding: 10px; + background: var(--panel-2); + border: 1px solid var(--line); + border-radius: var(--radius-control); +} + +.markdown-preview-content code { + font-family: var(--font-mono); +} + +.markdown-preview-content blockquote { + margin-left: 0; + padding-left: 12px; + border-left: 3px solid var(--line-strong); + color: var(--muted); +} + +#compose-editor { + font-family: var(--compose-font-family, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif); +} + +.compose-reply-entry { + min-height: 1.35em; +} + +/* OpenPGP composer / reader */ +.pgp-reader-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 9px 12px; + border-block: 1px solid var(--border); + background: color-mix(in srgb, var(--accent) 8%, var(--panel)); +} + +.pgp-reader-bar>div:first-child { + display: grid; + gap: 2px; + min-width: 0 +} + +.pgp-reader-status { + display: block +} + +.compose-pgp-panel { + margin-left: 82px; + overflow: hidden; + border: 1px solid var(--line); + border-radius: var(--radius-control); + background: color-mix(in srgb, var(--accent) 5%, var(--panel)); +} + +.compose-pgp-panel>summary { + min-height: 32px; + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 9px; + padding: 5px 9px; + cursor: pointer; + list-style: none; + user-select: none; +} + +.compose-pgp-panel>summary::-webkit-details-marker { + display: none +} + +.compose-pgp-summary-title { + font-size: 10px; + font-weight: 700; + color: var(--text) +} + +.compose-pgp-summary-status { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 10px +} + +.compose-pgp-summary-action { + color: var(--accent); + font-size: 10px +} + +.compose-pgp-details { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 8px 14px; + align-items: center; + padding: 8px 9px; + border-top: 1px solid var(--line); +} + +.compose-pgp-details>label:not(.checkbox-row) { + display: grid; + gap: 4px; + min-width: 0 +} + +.compose-pgp-details .inline-actions { + justify-content: flex-end; + margin-top: 0 +} + +.compose-pgp-wide { + grid-column: 1 / -1 +} + +.compose-pgp-passphrase { + grid-column: 1 / -1 +} + +.compose-pgp-passphrase input { + width: 100% +} + +.compose-fullscreen-toggle { + font-size: 17px; + line-height: 1; + font-family: var(--font-sans); +} + + +/* Signature/footer editor */ +.signature-editor-shell { + overflow: hidden; + border: 1px solid var(--line-strong); + border-radius: var(--radius-control); + background: var(--field); +} + +.signature-editor-toolbar { + min-height: 34px; + display: flex; + align-items: center; + gap: 2px; + padding: 3px 5px; + border-bottom: 1px solid var(--line); + background: var(--panel-2); +} + +.signature-editor-toolbar button { + width: 28px; + height: 27px; + display: grid; + place-items: center; + padding: 0; + border: 1px solid transparent; + border-radius: var(--radius-control); + background: transparent; + color: var(--muted); +} + +.signature-editor-toolbar button:hover { + border-color: var(--line); + background: var(--hover); + color: var(--text); +} + +.signature-editor-toolbar svg { + width: 15px; + height: 15px; +} + +.signature-editor-shell .signature-editor { + min-height: 150px; + max-height: 320px; + overflow: auto; + padding: 10px; + border: 0; + border-radius: 0; + background: var(--field); + outline: none; +} + +.signature-editor-shell .signature-editor:focus { + box-shadow: inset 0 0 0 1px var(--accent); +} + +.signature-editor img { + max-width: min(100%, 420px); + height: auto; +} + +.signature-help { + display: block; + margin-top: 5px; + line-height: 1.45; +} + +.compose-signature img { + max-width: 100%; + height: auto; +} + +@container compose-card (max-width: 620px) { + .compose-addresses { + padding: 8px; + } + + .compose-addresses>label, + .compose-options-row { + grid-template-columns: 54px minmax(0, 1fr); + gap: 7px; + } + + .compose-pgp-panel { + margin-left: 61px; + } +} + +@media (max-width: 560px) { + + .compose-addresses>label, + .compose-options-row { + grid-template-columns: 1fr; + gap: 3px; + } + + .compose-addresses>label>span, + .compose-options-row>span { + text-align: left; + padding-top: 0; + } + + .compose-pgp-panel { + margin-left: 0; + } + + .editor-toolbar { + max-height: 112px; + overflow: auto; + } +} \ No newline at end of file diff --git a/src/web/css/50-settings.css b/src/web/css/50-settings.css new file mode 100644 index 0000000..550b043 --- /dev/null +++ b/src/web/css/50-settings.css @@ -0,0 +1,2360 @@ +.settings-dialog { + width: min(900px, calc(100vw - 40px)); + max-width: none; +} + +.settings-card { + min-height: 560px; +} + +.settings-layout { + display: grid; + grid-template-columns: 160px minmax(0, 1fr); + min-height: 510px; +} + +.settings-nav { + min-height: 0; + display: flex; + flex-direction: column; + gap: 1px; + padding: 9px; + border-right: 1px solid var(--line); + background: var(--sidebar); + overflow: auto; + overscroll-behavior: contain; +} + +.settings-tab { + min-height: 30px; + border: 1px solid transparent; + border-radius: var(--radius-control); + background: transparent; + color: var(--muted); + text-align: left; + padding: 4px 8px; +} + +.settings-tab:hover { + background: var(--hover); + color: var(--text); +} + +.settings-tab.active { + background: var(--selected); + border-color: var(--line); + color: var(--text); +} + +.settings-content { + min-width: 0; + min-height: 0; + padding: 18px 20px; + overflow: auto; + overscroll-behavior: contain; +} + +.settings-panel h2 { + margin: 0 0 18px; + font-size: 16px; + font-weight: 620; +} + +.settings-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 260px)); + gap: 14px 18px; +} + +.settings-section { + padding: 14px; + border-bottom: 1px solid var(--line); +} + +.inline-field { + display: grid; + grid-template-columns: 1fr auto; + gap: 7px; +} + +.inline-actions { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 9px; +} + +.folder-card { + width: min(470px, calc(100vw - 30px)); +} + +.sieve-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 12px; + margin-bottom: 14px; +} + +.sieve-header h2 { + margin-bottom: 2px; +} + +.sieve-header p { + margin: 0; +} + +.sieve-layout { + display: grid; + grid-template-columns: 190px minmax(0, 1fr); + gap: 12px; + min-height: 390px; +} + +.sieve-scripts { + min-height: 260px; + max-height: 390px; + overflow: auto; + border: 1px solid var(--line); + background: var(--panel-2); +} + +.sieve-script { + width: 100%; + min-height: 30px; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 7px; + border: 0; + border-bottom: 1px solid var(--line); + background: transparent; + color: var(--muted); + text-align: left; + padding: 5px 7px; +} + +.sieve-script:hover, +.sieve-script.active { + background: var(--selected); + color: var(--text); +} + +.sieve-script span:first-child { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sieve-script span:last-child { + color: var(--success); + font: 8px var(--font-mono); +} + +.sieve-editor-wrap { + display: grid; + align-content: start; + gap: 10px; + min-width: 0; +} + +.code-editor { + min-height: 310px; + resize: vertical; + font: 11px/1.55 var(--font-mono); + tab-size: 4; +} + +.about-list { + display: grid; + grid-template-columns: 120px minmax(0, 1fr); + margin: 0; + border-top: 1px solid var(--line); +} + +.about-list dt, +.about-list dd { + margin: 0; + padding: 8px 0; + border-bottom: 1px solid var(--line); +} + +.about-list dt { + color: var(--muted); +} + +.about-list code { + font: 10px var(--font-mono); + overflow-wrap: anywhere; +} + +.toast-stack { + position: fixed; + right: 14px; + bottom: 14px; + z-index: 1000; + display: grid; + gap: 6px; + width: min(340px, calc(100vw - 28px)); +} + +.toast { + border: 1px solid var(--line-strong); + border-left: 3px solid var(--accent); + border-radius: var(--radius-control); + background: var(--panel-3); + color: var(--text); + box-shadow: 0 10px 28px var(--shadow); + padding: 9px 10px; + font-size: 11px; +} + +.toast.error { + border-left-color: var(--danger); +} + +.settings-panel-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + margin-bottom: 16px; +} + +.settings-panel-heading h2 { + margin-bottom: 3px; +} + +.appearance-grid { + grid-template-columns: repeat(2, minmax(220px, 1fr)); + max-width: 760px; +} + +.appearance-grid input[type="range"] { + padding: 0; + border: 0; + background: transparent; + min-height: 24px; +} + +.color-input { + display: grid; + grid-template-columns: 44px 1fr; + align-items: center; + gap: 8px; +} + +.color-input input[type="color"] { + width: 44px; + height: 30px; + padding: 2px; +} + +.color-input output { + font: 11px var(--font-mono); + color: var(--muted); +} + +.appearance-toggles { + display: grid; + grid-template-columns: repeat(2, minmax(220px, 1fr)); + gap: 10px 18px; + margin-top: 18px; + padding-top: 14px; + border-top: 1px solid var(--line); + max-width: 760px; +} + +.appearance-presets { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; + max-width: 760px; + margin-bottom: 18px; +} + +.appearance-preset { + min-width: 0; + display: grid; + gap: 4px; + padding: 10px; + border: 1px solid var(--line); + border-radius: var(--radius-panel); + background: var(--panel-2); + color: var(--text); + text-align: left; +} + +.appearance-preset:hover, +.appearance-preset:focus-visible { + border-color: var(--accent); + background: var(--hover); +} + +.appearance-preset strong { + font-size: 11px; +} + +.appearance-preset span { + color: var(--muted); + font-size: 9px; + line-height: 1.35; +} + +.app-prompt-card { + width: min(460px, calc(100vw - 24px)); + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto; +} + +.app-prompt-body { + display: grid; + gap: 10px; + padding: 16px; + overflow: auto; +} + +.app-prompt-message { + margin: 0; + color: var(--muted); + line-height: 1.5; +} + +.app-prompt-control { + width: 100%; +} + +.app-prompt-actions> :first-child { + margin-right: auto; +} + +.print-frame { + position: fixed; + right: 0; + bottom: 0; + width: 1px; + height: 1px; + border: 0; + opacity: 0; + pointer-events: none; +} + +.headers-card { + width: min(820px, calc(100vw - 32px)); + max-height: min(720px, calc(100dvh - 32px)); +} + +.headers-content { + margin: 0; + padding: 14px; + overflow: auto; + white-space: pre-wrap; + overflow-wrap: anywhere; + background: var(--field); + border-top: 1px solid var(--line); + border-bottom: 1px solid var(--line); + font: 11px/1.55 var(--font-mono); +} + +.context-menu { + position: fixed; + z-index: 1000; + min-width: 210px; + max-width: 300px; + padding: 4px; + border: 1px solid var(--line-strong); + border-radius: var(--radius-control); + background: var(--panel-2); + box-shadow: 0 12px 28px var(--shadow); +} + +.context-menu button { + width: 100%; + min-height: 30px; + display: flex; + align-items: center; + gap: 8px; + padding: 5px 8px; + border: 0; + border-radius: var(--radius-control); + background: transparent; + color: var(--text); + text-align: left; +} + +.context-menu button:hover { + background: var(--hover); +} + +.context-menu button.danger { + color: var(--danger); +} + +.context-menu .separator { + height: 1px; + margin: 4px; + background: var(--line); +} + +.identity-overview { + display: grid; + gap: 10px; + min-width: 0 +} + +.identity-overview-hint { + display: grid; + gap: 3px; + padding: 11px 12px; + border: 1px solid var(--line); + border-radius: var(--radius-control); + background: var(--panel-2) +} + +.identity-list { + min-width: 0; + max-height: 430px; + overflow: auto; + border: 1px solid var(--line); + border-radius: var(--radius-control); + background: var(--field) +} + +.identity-item { + width: 100%; + min-width: 0; + display: block; + padding: 10px 11px; + border: 0; + border-bottom: 1px solid var(--line); + background: transparent; + color: var(--text); + text-align: left +} + +.identity-item:last-child { + border-bottom: 0 +} + +.identity-item:hover, +.identity-item.active { + background: var(--hover) +} + +.identity-item strong, +.identity-item small { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap +} + +.identity-item small { + margin-top: 2px; + color: var(--muted) +} + +.identity-signature-field { + min-width: 0 +} + +.signature-editor { + min-height: 150px; + padding: 9px; + border: 1px solid var(--line-strong); + border-radius: var(--radius-control); + background: var(--field); + overflow: auto +} + +.dialog.identity-dialog { + width: min(980px, calc(100vw - 28px)); + max-width: none; + overflow: hidden +} + +.identity-card { + width: 100%; + height: min(820px, calc(100dvh - 28px)); + max-height: calc(100dvh - 28px); + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto; + overflow: hidden +} + +.identity-form-body { + min-width: 0; + min-height: 0; + overflow-y: auto; + overflow-x: hidden; + display: grid; + align-content: start; + gap: 12px; + padding: 14px +} + +.identity-modal-section { + min-width: 0; + display: grid; + gap: 11px; + padding: 13px; + border: 1px solid var(--line); + border-radius: var(--radius-panel); + background: var(--panel-2) +} + +.identity-modal-heading { + display: grid; + gap: 2px +} + +.identity-modal-heading>span { + line-height: 1.4 +} + +.identity-fields-grid, +.identity-options-grid, +.identity-toggle-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px 14px +} + +.identity-field-wide { + grid-column: 1 / -1 +} + +.identity-signature-grid { + display: grid; + grid-template-columns: minmax(240px, .8fr) minmax(360px, 1.2fr); + gap: 14px; + min-width: 0 +} + +.identity-signature-grid>label { + min-width: 0 +} + +.identity-signature-grid textarea { + min-height: 210px; + resize: vertical +} + +.identity-signature-grid .signature-editor-shell .signature-editor { + min-height: 210px; + max-height: 360px +} + +.identity-options-grid { + margin-top: 2px +} + +.identity-toggle-grid .checkbox-row { + min-width: 0 +} + +.identity-dialog-actions { + padding: 9px 12px +} + +.identity-dialog-actions #identity-delete { + margin-right: auto +} + +.notification-settings { + display: grid; + gap: 14px; + max-width: 620px; +} + +.notification-folder-list { + display: grid; + grid-template-columns: repeat(2, minmax(180px, 1fr)); + gap: 7px 14px; + max-height: 220px; + overflow: auto; + padding: 9px; + border: 1px solid var(--line); + background: var(--field); +} + +.contacts-card, +.calendar-card { + width: min(900px, calc(100vw - 32px)); + min-height: 560px; + max-height: calc(100dvh - 32px); +} + +.contacts-layout { + display: grid; + grid-template-columns: 280px minmax(0, 1fr); + min-height: 500px; +} + +.contacts-sidebar { + border-right: 1px solid var(--line); + padding: 10px; + background: var(--panel-2); +} + +.contact-list { + margin-top: 10px; + max-height: 430px; + overflow: auto; + border: 1px solid var(--line); + background: var(--field); +} + +.contact-item { + width: 100%; + display: block; + padding: 8px 9px; + border: 0; + border-bottom: 1px solid var(--line); + background: transparent; + color: var(--text); + text-align: left; +} + +.contact-item:hover, +.contact-item.active { + background: var(--hover); +} + +.contact-item small { + display: block; + color: var(--muted); + margin-top: 2px; +} + +.contact-form { + display: grid; + align-content: start; + gap: 12px; + padding: 16px; +} + +.calendar-toolbar { + display: grid; + grid-template-columns: auto 1fr auto auto; + align-items: center; + gap: 8px; + padding: 10px 12px; + border-bottom: 1px solid var(--line); +} + +.calendar-toolbar strong { + text-align: center; +} + +.calendar-events { + max-height: 360px; + overflow: auto; + padding: 10px 12px; +} + +.calendar-event { + width: 100%; + display: grid; + grid-template-columns: 150px 1fr auto; + gap: 10px; + align-items: center; + padding: 8px 9px; + border: 0; + border-bottom: 1px solid var(--line); + background: transparent; + color: var(--text); + text-align: left; +} + +.calendar-event:hover { + background: var(--hover); +} + +.calendar-event time, +.calendar-event small { + color: var(--muted); +} + +.calendar-form { + display: grid; + gap: 12px; + padding: 14px 16px; + border-top: 1px solid var(--line); + background: var(--panel-2); +} + +@media (max-width:720px) { + .contacts-layout { + grid-template-columns: 1fr; + } + + .contacts-sidebar { + border-right: 0; + border-bottom: 1px solid var(--line); + } + + .calendar-event { + grid-template-columns: 1fr; + } +} + +.advanced-search-card { + width: min(760px, calc(100vw - 32px)); + max-height: calc(100dvh - 32px); +} + +.advanced-search-form { + display: grid; + gap: 16px; + padding: 16px; + overflow: auto; +} + +.advanced-search-grid { + grid-template-columns: repeat(2, minmax(220px, 1fr)); + max-width: none; +} + +.advanced-search-grid .wide-field { + grid-column: 1 / -1; +} + +.saved-search-section { + border-top: 1px solid var(--line); + padding-top: 12px; +} + +.saved-search-list { + display: grid; + gap: 4px; + max-height: 160px; + overflow: auto; +} + +.saved-search-item { + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; + align-items: center; + gap: 6px; + min-height: 32px; + padding: 4px 6px; + border: 1px solid var(--line); + background: var(--field); +} + +.saved-search-item button { + min-height: 26px; +} + +.saved-search-item strong { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.advanced-search-actions { + padding: 0; + border-top: 0; +} + +@media (max-width:720px) { + .advanced-search-grid { + grid-template-columns: 1fr; + } + + .advanced-search-grid .wide-field { + grid-column: auto; + } +} + +.tag-card { + width: min(390px, calc(100vw - 32px)); +} + +.tag-form { + display: grid; + gap: 12px; + padding: 16px; +} + +.quota-summary { + display: grid; + gap: 6px; + max-width: 520px; + margin-top: 16px; + padding: 10px 12px; + border: 1px solid var(--line); + background: var(--field); +} + +.quota-summary>span { + color: var(--muted); + font-size: 11px; +} + +.quota-track { + height: 6px; + overflow: hidden; + border-radius: 999px; + background: var(--panel-3); +} + +.quota-track span { + display: block; + width: 0; + height: 100%; + background: var(--accent); + transition: width .16s ease; +} + +.contact-group-toolbar { + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto auto; + gap: 5px; + margin-bottom: 8px; +} + +.contact-group-toolbar .button { + padding-inline: 7px; +} + +.contact-group-membership { + display: grid; + grid-template-columns: repeat(2, minmax(140px, 1fr)); + gap: 5px 10px; + margin-top: 5px; + padding: 8px; + border: 1px solid var(--line); + background: var(--field); + max-height: 130px; + overflow: auto; +} + +.field-label { + display: block; + margin-bottom: 4px; + color: var(--muted); + font-size: 11px; +} + +@media (max-width:720px) { + .contact-group-toolbar { + grid-template-columns: 1fr auto; + } + + .contact-group-toolbar #contact-group-rename, + .contact-group-toolbar #contact-group-delete { + grid-row: 2; + } +} + +.sieve-rules-overview { + display: grid; + gap: 8px; + padding: 11px; + border: 1px solid var(--line); + border-radius: var(--radius-panel); + background: var(--panel-2); +} + +.sieve-section-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.sieve-section-heading>div { + display: grid; + gap: 2px; +} + +.sieve-section-heading small { + display: block; + line-height: 1.35; +} + +.sieve-count-badge, +.sieve-status-pill { + display: inline-flex; + align-items: center; + min-height: 22px; + padding: 2px 8px; + border: 1px solid var(--line); + border-radius: 999px; + background: var(--field); + color: var(--muted); + font-size: 10px; + white-space: nowrap; +} + +.sieve-status-pill.ok { + border-color: color-mix(in srgb, var(--success) 45%, var(--line)); + color: var(--success); +} + +.sieve-status-pill.error { + border-color: color-mix(in srgb, var(--danger) 45%, var(--line)); + color: var(--danger); +} + +.sieve-builder { + overflow: hidden; + border: 1px solid var(--line); + border-radius: var(--radius-panel); + background: var(--panel-2); +} + +.sieve-builder>summary { + cursor: pointer; + padding: 9px 11px; + font-weight: 650; + user-select: none; +} + +.sieve-builder[open]>summary { + border-bottom: 1px solid var(--line); +} + +.sieve-builder-body { + display: grid; + gap: 0; +} + +.sieve-builder-section { + display: grid; + gap: 8px; + padding: 11px; + border-bottom: 1px solid var(--line); +} + +.sieve-builder-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; + padding: 0; +} + +.sieve-builder-grid-rule { + grid-template-columns: minmax(220px, 1.5fr) minmax(150px, 1fr) minmax(170px, 1fr); +} + +.sieve-builder label { + min-width: 0; +} + +.sieve-builder input, +.sieve-builder select { + width: 100%; +} + +.sieve-builder .checkbox-row { + align-self: end; + min-height: var(--control-height); +} + +.sieve-builder-wide { + grid-column: 1 / -1; +} + +.sieve-builder-dynamic-list { + display: grid; + gap: 7px; +} + +.sieve-dynamic-row { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 7px; + align-items: end; + padding: 8px; + border: 1px solid var(--line); + border-radius: var(--radius-control); + background: var(--field); +} + +.sieve-dynamic-row .sieve-dynamic-remove { + justify-self: start; +} + +.sieve-rule-preview { + display: grid; + gap: 9px; + margin: 11px; + padding: 11px; + border: 1px solid var(--line); + border-radius: var(--radius-control); + background: var(--field); +} + +.sieve-rule-preview-text { + display: grid; + gap: 5px; + line-height: 1.45; +} + +.sieve-rule-preview-text strong { + color: var(--text); +} + +.sieve-rule-preview-code { + max-height: 180px; + margin: 0; + padding: 9px; + overflow: auto; + border: 1px solid var(--line); + border-radius: var(--radius-control); + background: var(--panel); + color: var(--muted); + font: 10px/1.45 var(--font-mono); + white-space: pre-wrap; +} + +.sieve-builder-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 0 11px 11px; +} + +.sieve-builder-footer .inline-actions { + padding: 0; +} + +.sieve-raw-source { + border: 1px solid var(--line); + border-radius: var(--radius-control); + background: var(--panel-2); +} + +.sieve-raw-source>summary { + cursor: pointer; + padding: 8px 10px; + font-weight: 600; +} + +.sieve-raw-source[open]>summary { + border-bottom: 1px solid var(--line); +} + +.sieve-raw-source p { + margin: 8px 10px 0; +} + +.sieve-raw-source .code-editor { + width: calc(100% - 20px); + min-height: 260px; + margin: 8px 10px 10px; + resize: vertical; +} + +.sieve-result { + min-height: 18px; +} + +@media (max-width:900px) { + + .sieve-builder-grid, + .sieve-builder-grid-rule, + .sieve-dynamic-row { + grid-template-columns: 1fr 1fr; + } +} + +@media (max-width:620px) { + + .sieve-builder-grid, + .sieve-builder-grid-rule, + .sieve-dynamic-row { + grid-template-columns: 1fr; + } + + .sieve-builder-footer, + .sieve-section-heading { + align-items: stretch; + flex-direction: column; + } +} + +.sieve-rule-launch { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + padding: 10px 11px; + border: 1px dashed var(--line-strong); + border-radius: var(--radius-control); + background: var(--panel-2) +} + +.sieve-rule-launch .muted { + flex: 1 1 260px; + line-height: 1.4 +} + +.dialog.sieve-rule-dialog { + width: min(1040px, calc(100vw - 28px)); + max-width: none; + overflow: hidden +} + +.sieve-rule-card { + width: 100%; + height: min(900px, calc(100dvh - 28px)); + max-height: calc(100dvh - 28px); + display: grid; + grid-template-rows: auto minmax(0, 1fr); + overflow: hidden +} + +.sieve-rule-card>.dialog-header>div:first-child { + display: grid; + gap: 2px; + min-width: 0 +} + +.sieve-rule-dialog-subtitle { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 10px +} + +.sieve-rule-dialog-body { + min-width: 0; + min-height: 0; + overflow-y: auto; + overflow-x: hidden; + padding: 12px; + background: var(--panel) +} + +.sieve-builder-modal { + overflow: visible +} + +.sieve-builder-modal .sieve-builder-body { + overflow: visible +} + +.sieve-builder-modal .sieve-builder-section { + padding: 14px +} + +.sieve-builder-modal .sieve-rule-preview { + margin: 14px +} + +.sieve-builder-modal .sieve-builder-footer { + position: sticky; + bottom: -12px; + z-index: 3; + padding: 10px 14px; + border-top: 1px solid var(--line); + background: var(--panel-2); + box-shadow: 0 -8px 18px color-mix(in srgb, var(--shadow) 45%, transparent) +} + +.contact-transfer { + display: flex; + gap: 6px; + flex-wrap: wrap; + margin: 6px 0; +} + +/* Diagnostics and mailbox behavior */ +.diagnostics-grid { + display: grid; + gap: 12px; +} + +.diagnostic-card { + border: 1px solid var(--line); + border-radius: var(--radius-panel); + background: var(--panel-2); + padding: 12px; +} + +.diagnostic-card .section-heading { + margin: 0 0 10px; +} + +.capability-grid { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.capability-badge { + display: inline-flex; + align-items: center; + gap: 6px; + min-height: 26px; + padding: 3px 7px; + border: 1px solid var(--line); + border-radius: var(--radius-control); + background: var(--panel-3); + font-size: 12px; +} + +.capability-badge strong { + font-size: 11px; + font-weight: 650; +} + +.capability-badge.supported strong { + color: var(--success); +} + +.capability-badge.unsupported { + color: var(--muted); +} + +.capability-list { + margin-top: 10px; + color: var(--muted); + font-size: 12px; + line-height: 1.55; + overflow-wrap: anywhere; +} + +.account-settings-list { + display: grid; + gap: 8px; +} + +.account-settings-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; + gap: 8px; + align-items: center; + padding: 10px; + border: 1px solid var(--line); + border-radius: var(--radius-control); + background: var(--panel-2); +} + +.account-settings-row .account-email { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.account-settings-row.active .account-email { + font-weight: 650; +} + +.add-account-card { + width: min(440px, calc(100vw - 28px)); +} + +.contact-photo-editor { + display: grid; + grid-template-columns: 72px minmax(0, 1fr); + gap: 10px; + align-items: center; + padding-bottom: 8px; + border-bottom: 1px solid var(--line); +} + +.contact-photo-preview, +.contact-photo-placeholder { + width: 72px; + height: 72px; + border: 1px solid var(--line); + border-radius: var(--radius-panel); + background: var(--panel-2); +} + +.contact-photo-preview { + object-fit: cover; +} + +.contact-photo-placeholder { + display: grid; + place-items: center; + color: var(--muted); + font-size: 12px; +} + +.contact-photo-editor .inline-actions { + align-self: center; +} + +.folder-properties-card { + width: min(520px, calc(100vw - 28px)); +} + +.folder-properties-list { + display: grid; + padding: 10px 14px 14px; +} + +.folder-property-row { + display: grid; + grid-template-columns: 120px minmax(0, 1fr); + gap: 12px; + padding: 8px 0; + border-bottom: 1px solid var(--line); +} + +.folder-property-row:last-child { + border-bottom: 0; +} + +.folder-property-row strong { + overflow-wrap: anywhere; + font-weight: 550; +} + +.account-session-heading { + margin-top: 22px; +} + +.account-session-details { + min-width: 0; + display: grid; + gap: 3px; +} + +.account-session-details .muted { + font-size: 0.82rem; +} + +.duplicates-card { + width: min(720px, calc(100vw - 32px)); +} + +.duplicate-contact-list { + display: grid; + gap: 12px; + max-height: 60vh; + overflow: auto; +} + +.duplicate-contact-group { + display: grid; + gap: 8px; + padding: 12px; + border: 1px solid var(--border); + border-radius: var(--radius); +} + +.duplicate-contact-options { + display: grid; + gap: 6px; +} + +.duplicate-contact-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding-top: 6px; + border-top: 1px solid var(--border); +} + +.duplicate-contact-summary { + display: grid; + min-width: 0; +} + +/* Calendar views */ +.calendar-toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; +} + +.calendar-toolbar #calendar-range { + flex: 1 1 220px; + text-align: center; +} + +.calendar-toolbar #calendar-view { + min-width: 100px; +} + +.calendar-events { + max-height: 440px; +} + +.calendar-grid { + display: grid; + grid-template-columns: repeat(7, minmax(0, 1fr)); + border-top: 1px solid var(--line); + border-left: 1px solid var(--line); +} + +.calendar-weekday { + padding: 6px 8px; + border-right: 1px solid var(--line); + border-bottom: 1px solid var(--line); + color: var(--muted); + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: .04em; +} + +.calendar-day-cell { + min-height: 110px; + padding: 5px; + border-right: 1px solid var(--line); + border-bottom: 1px solid var(--line); + background: var(--panel); + overflow: hidden; +} + +.calendar-week-grid .calendar-day-cell { + min-height: 260px; +} + +.calendar-day-cell.outside-month { + opacity: .52; +} + +.calendar-day-cell.today { + box-shadow: inset 0 0 0 1px var(--accent); +} + +.calendar-day-number { + width: 28px; + height: 24px; + border: 0; + background: transparent; + color: var(--muted); + text-align: center; + border-radius: var(--radius); + cursor: pointer; +} + +.calendar-day-number:hover { + background: var(--hover); + color: var(--text); +} + +.calendar-event-chip { + width: 100%; + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 5px; + align-items: center; + margin-top: 4px; + padding: 4px 5px; + border: 0; + border-left: 3px solid var(--event-color); + background: var(--panel-2); + color: var(--text); + text-align: left; + cursor: pointer; +} + +.calendar-event-chip:hover { + background: var(--hover); +} + +.calendar-event-chip span { + color: var(--muted); + font-size: 10px; + white-space: nowrap; +} + +.calendar-event-chip strong { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 11px; +} + +.calendar-more { + display: block; + margin-top: 4px; + color: var(--muted); + font-size: 10px; +} + +.calendar-agenda-day { + display: grid; + gap: 4px; + margin-bottom: 12px; +} + +.calendar-agenda-day h4 { + margin: 0; + padding: 7px 4px; + border-bottom: 1px solid var(--line); + font-size: 12px; +} + +.calendar-event { + border-left: 3px solid var(--event-color, var(--accent)); +} + +@media (max-width:720px) { + .calendar-grid { + grid-template-columns: 1fr; + border-left: 0; + } + + .calendar-weekday { + display: none; + } + + .calendar-day-cell { + min-height: auto; + border-left: 1px solid var(--line); + } + + .calendar-day-cell.outside-month { + display: none; + } + + .calendar-toolbar #calendar-range { + order: -1; + flex-basis: 100%; + } +} + +/* 0.0.1 UI reliability pass: viewport-safe dialogs, toasts and calendar layout */ +.dialog { + max-width: calc(100vw - 16px); + max-height: calc(100dvh - 16px); + overflow: auto; + overscroll-behavior: contain; + scrollbar-gutter: auto; +} + +.dialog-card { + max-width: 100%; + max-height: calc(100dvh - 18px); + min-height: 0; + overflow: auto; + overscroll-behavior: contain; + scrollbar-gutter: auto; +} + +/* Grid dialogs own their internal scroll regions; simple/legacy dialogs fall + back to scrolling the complete card so actions can never be trapped below + the viewport. */ +.compose-card, +.settings-card, +.identity-card, +.sieve-rule-card, +.contacts-card, +.calendar-card, +.headers-card, +.source-card, +.shortcuts-card, +.attachment-preview-card, +.app-prompt-card, +.quick-filter-card { + overflow: hidden; +} + +.settings-card { + min-height: 0; + height: min(720px, calc(100dvh - 18px)); + display: grid; + grid-template-rows: auto minmax(0, 1fr); +} + +.settings-layout { + min-height: 0; + height: 100%; + overflow: hidden; +} + +.dialog-header { + min-height: var(--dialog-header-height, 43px); + flex: 0 0 auto; + background: var(--panel); +} + +.dialog-actions { + flex: 0 0 auto; + margin: 0; + background: var(--panel); + position: relative; + z-index: 2; +} + +.contacts-card, +.calendar-card { + width: min(980px, calc(100vw - 32px)); + height: min(780px, calc(100dvh - 32px)); + min-height: 0; + max-height: calc(100dvh - 32px); + display: grid; + overflow: hidden; +} + +.contacts-card { + grid-template-rows: auto minmax(0, 1fr); +} + +.contacts-layout { + min-height: 0; + height: 100%; + overflow: hidden; + grid-template-columns: minmax(240px, 300px) minmax(0, 1fr); +} + +.contacts-sidebar { + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.contact-list { + min-height: 120px; + max-height: none; + flex: 1 1 auto; + overflow: auto; +} + +.contact-form { + min-height: 0; + overflow: auto; + overscroll-behavior: contain; + padding-bottom: 0; +} + +.contact-form>.inline-actions:last-child { + position: sticky; + bottom: 0; + z-index: 3; + margin: 0 -16px; + padding: 10px 16px; + border-top: 1px solid var(--line); + background: var(--panel); +} + +.calendar-card { + grid-template-rows: auto auto minmax(0, 1fr) auto; +} + +.calendar-toolbar { + display: grid; + grid-template-columns: auto minmax(180px, 1fr) auto auto auto auto auto; + align-items: center; + gap: 6px; + padding: 8px 10px; + overflow-x: auto; + scrollbar-width: thin; +} + +.calendar-toolbar #calendar-range { + min-width: 0; + text-align: center; +} + +.calendar-toolbar #calendar-view { + min-width: 96px; +} + +.calendar-events { + min-height: 0; + max-height: none; + overflow: auto; + padding: 0; + overscroll-behavior: contain; +} + +.calendar-grid { + width: 100%; + min-width: 680px; + display: grid !important; + grid-template-columns: repeat(7, minmax(86px, 1fr)) !important; + grid-auto-flow: row; + align-items: stretch; +} + +.calendar-weekday { + min-width: 0; +} + +.calendar-day-cell { + min-width: 0; +} + +.calendar-form { + min-height: 0; + max-height: min(48dvh, 430px); + overflow: auto; + overscroll-behavior: contain; +} + +.headers-card, +.source-card { + width: min(1040px, calc(100vw - 28px)); + height: min(82dvh, 860px); + max-height: calc(100dvh - 28px); + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto; + overflow: hidden; +} + +.headers-content, +.message-source-content { + min-height: 0; + max-height: none; + overflow: auto; + overscroll-behavior: contain; +} + +.toast-stack { + z-index: 2147483000; + pointer-events: none; +} + +.toast { + min-height: 42px; + display: grid; + grid-template-columns: 20px minmax(0, 1fr) auto auto; + align-items: center; + gap: 8px; + padding: 8px 8px 8px 10px; + pointer-events: auto; + opacity: 0; + transform: translateY(6px); + transition: opacity .15s ease, transform .15s ease; +} + +.toast.visible { + opacity: 1; + transform: translateY(0); +} + +.toast-icon { + display: grid; + place-items: center; + color: var(--accent); +} + +.toast-icon svg { + width: 17px; + height: 17px; +} + +.toast-message { + min-width: 0; + overflow-wrap: anywhere; +} + +.toast-countdown { + min-width: 24px; + color: var(--subtle); + font: 10px var(--font-mono); + text-align: right; +} + +.toast-controls { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 2px; + white-space: nowrap; +} + +.toast-action { + border: 0; + border-radius: var(--radius-control); + background: transparent; + color: var(--accent); + padding: 4px 6px; + font-weight: 700; + font-size: 10px; + cursor: pointer; +} + +.toast-action:hover, +.toast-action:focus-visible { + background: var(--hover); + color: var(--text); + outline: none; +} + +.toast-close { + width: 24px; + height: 24px; + display: grid; + place-items: center; + border: 0; + border-radius: var(--radius-control); + background: transparent; + color: var(--muted); +} + +.toast-close:hover { + background: var(--hover); + color: var(--text); +} + +.toast-close svg { + width: 14px; + height: 14px; +} + +.toast-success { + border-left-color: var(--success); +} + +.toast-success .toast-icon { + color: var(--success); +} + +.toast-error { + border-left-color: var(--danger); +} + +.toast-error .toast-icon { + color: var(--danger); +} + +.toast-warning { + border-left-color: var(--warning); +} + +.toast-warning .toast-icon { + color: var(--warning); +} + +.toast-info { + border-left-color: var(--accent); +} + +@media (max-width: 720px) { + + .contacts-card, + .calendar-card { + width: calc(100vw - 12px); + height: calc(100dvh - 12px); + max-height: calc(100dvh - 12px); + } + + .contacts-layout { + grid-template-columns: 1fr; + grid-template-rows: minmax(180px, 34%) minmax(0, 1fr); + overflow: hidden; + } + + .contacts-sidebar { + border-right: 0; + border-bottom: 1px solid var(--line); + } + + .contact-form { + padding: 12px; + } + + .contact-form>.inline-actions:last-child { + margin: 0 -12px; + padding: 9px 12px; + } + + .calendar-toolbar { + grid-template-columns: auto minmax(150px, 1fr) auto auto; + } + + .calendar-toolbar #calendar-import, + .calendar-toolbar #calendar-export, + .calendar-toolbar #calendar-new { + grid-row: 2; + } + + .calendar-grid { + min-width: 0; + grid-template-columns: 1fr !important; + border-left: 0; + } + + .calendar-weekday { + display: none; + } + + .calendar-day-cell { + border-left: 1px solid var(--line); + } + + .calendar-day-cell.outside-month { + display: none; + } +} + +.dialog-header .inline-actions { + margin-top: 0; +} + +.headers-card { + grid-template-rows: auto auto minmax(0, 1fr) auto; +} + +.headers-summary { + max-height: min(30dvh, 240px); + overflow: auto; + padding: 8px 14px; + border-bottom: 1px solid var(--line); + background: var(--panel); +} + +.header-summary-row { + display: grid; + grid-template-columns: 130px minmax(0, 1fr); + gap: 12px; + padding: 5px 0; + border-bottom: 1px solid color-mix(in srgb, var(--line) 70%, transparent); +} + +.header-summary-row:last-child { + border-bottom: 0; +} + +.header-summary-row code { + min-width: 0; + overflow-wrap: anywhere; + white-space: pre-wrap; + font: 10px/1.45 var(--font-mono); +} + +@media (max-width: 560px) { + .header-summary-row { + grid-template-columns: 1fr; + gap: 2px; + } +} + +/* Dialog/layout hardening */ +.contacts-card, +.calendar-card, +.headers-card, +.source-card { + box-sizing: border-box; + contain: layout paint; +} + +.contacts-card { + grid-template-rows: auto minmax(0, 1fr); +} + +.contact-form { + scrollbar-gutter: auto; + padding-bottom: 12px; +} + +.contact-form>.inline-actions:last-child { + box-shadow: 0 -1px 0 var(--line); +} + +.calendar-card { + grid-template-rows: auto auto minmax(0, 1fr) auto; +} + +.calendar-events { + width: 100%; + min-width: 0; + overflow: auto; + scrollbar-gutter: auto; +} + +.calendar-grid { + min-width: 700px; + grid-template-columns: repeat(7, minmax(96px, 1fr)) !important; +} + +.calendar-week-grid { + min-width: 700px; +} + +.calendar-form:not(.hidden) { + border-top: 1px solid var(--line); + scrollbar-gutter: auto; +} + +.headers-content, +.message-source-content { + scrollbar-gutter: auto; +} + +.source-card .dialog-header .button { + margin-left: auto; +} + +.reader-toolbar { + min-width: 0; + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: thin; +} + +.duplicate-contact-group { + border-color: var(--line); + border-radius: var(--radius-panel); +} + +.duplicate-contact-row { + border-color: var(--line); +} + +@media (max-width: 720px) { + + .calendar-grid, + .calendar-week-grid { + min-width: 0; + grid-template-columns: 1fr !important; + } +} + +.shortcuts-card { + width: min(680px, calc(100vw - 32px)); + max-height: calc(100dvh - 32px); + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto; +} + +.shortcuts-grid { + overflow: auto; + padding: 18px; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 24px; +} + +.shortcuts-grid h3 { + margin: 0 0 10px; + font-size: 12px; +} + +.shortcuts-grid dl { + margin: 0; + display: grid; + grid-template-columns: minmax(92px, max-content) minmax(0, 1fr); + gap: 7px 14px; + align-items: center; +} + +.shortcuts-grid dt, +.shortcuts-grid dd { + margin: 0; +} + +kbd { + display: inline-block; + min-width: 24px; + padding: 2px 6px; + border: 1px solid var(--line); + border-bottom-width: 2px; + border-radius: var(--radius-control); + background: var(--field); + color: var(--text); + font: 10px var(--font-mono); + text-align: center; +} + +@media (max-width:620px) { + .shortcuts-grid { + grid-template-columns: 1fr; + } +} + +.quota-summary.quota-warning { + border-color: color-mix(in srgb, var(--warning) 55%, var(--line)); +} + +.quota-summary.quota-warning .quota-track span { + background: var(--warning); +} + +.quota-summary.quota-danger { + border-color: color-mix(in srgb, var(--danger) 62%, var(--line)); +} + +.quota-summary.quota-danger .quota-track span { + background: var(--danger); +} + +@media (max-width: 900px) { + .appearance-presets { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 520px) { + .appearance-presets { + grid-template-columns: 1fr; + } +} + +.sieve-managed-rules { + display: grid; + gap: 7px; +} + +.sieve-managed-rule { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px 12px; + padding: 9px 10px; + border: 1px solid var(--line); + border-radius: var(--radius-control); + background: var(--panel); +} + +.sieve-managed-rule.selected { + border-color: var(--accent); + box-shadow: inset 3px 0 0 var(--accent); +} + +.sieve-managed-rule.disabled { + opacity: .68; +} + +.sieve-managed-rule.unsupported { + border-style: dashed; +} + +.sieve-managed-rule-main { + display: grid; + gap: 5px; + min-width: 0; +} + +.sieve-managed-rule-title { + display: flex; + align-items: center; + gap: 7px; + min-width: 0; +} + +.sieve-managed-rule-title strong { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sieve-rule-state { + flex: 0 0 auto; + padding: 1px 6px; + border: 1px solid var(--line); + border-radius: 999px; + color: var(--muted); + font-size: 9px; + text-transform: uppercase; + letter-spacing: .04em; +} + +.sieve-rule-state.enabled { + color: var(--success); + border-color: color-mix(in srgb, var(--success) 40%, var(--line)); +} + +.sieve-managed-rule-summary { + display: grid; + gap: 2px; + color: var(--muted); + font-size: 11px; + line-height: 1.35; +} + +.sieve-managed-rule-summary b { + color: var(--text); + font-weight: 600; +} + +.sieve-managed-rule-actions { + display: flex; + align-items: flex-start; + gap: 3px; + flex-wrap: wrap; + justify-content: flex-end; +} + +.sieve-managed-rule-actions button { + min-width: 27px; + min-height: 25px; + padding: 2px 6px; +} + +.sieve-rule-warning { + color: var(--warning); + font-size: 10px; +} + +@media (max-width:720px) { + .sieve-managed-rule { + grid-template-columns: 1fr; + } + + .sieve-managed-rule-actions { + justify-content: flex-start; + } +} + +.quick-filter-dialog { + width: min(700px, calc(100vw - 32px)); +} + +.quick-filter-card { + width: 100%; + max-height: min(720px, calc(100dvh - 32px)); +} + +.quick-filter-body { + padding: 16px; + overflow: auto; +} + +.quick-filter-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.quick-filter-grid label { + min-width: 0; +} + +.quick-filter-grid input, +.quick-filter-grid select { + width: 100%; +} + +.quick-filter-toggles { + margin-top: 14px; +} + +.quick-filter-hint { + min-height: 20px; + margin-top: 10px; +} + +@media (max-width: 620px) { + .quick-filter-grid { + grid-template-columns: 1fr; + } +} + +.quick-filter-preview { + margin-top: 14px; + padding: 10px 12px; + border: 1px solid var(--line); + border-radius: var(--radius-control); + background: var(--panel-2); + font-size: 12px; + line-height: 1.45; + overflow-wrap: anywhere; +} + +.appearance-advanced { + margin-top: 12px; + border-top: 1px solid var(--line); + padding-top: 10px; +} + +.appearance-advanced>summary { + cursor: pointer; + color: var(--text); + font-weight: 600; + margin-bottom: 10px; +} + +.pane-background-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; + margin-top: 10px; +} + +.pane-background-control { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + min-width: 0; +} + +.pane-background-control>span { + display: flex; + align-items: center; + gap: 6px; +} + +@media (max-width:820px) { + .pane-background-grid { + grid-template-columns: 1fr; + } +} + +/* OpenPGP settings */ +.pgp-settings-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px +} + +.pgp-settings-grid .settings-section { + margin: 0 +} + +.pgp-key-list { + display: grid; + gap: 8px +} + +.pgp-key-card { + display: grid; + gap: 8px; + padding: 12px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--panel) +} + +.pgp-key-head { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: start +} + +.pgp-key-head>div { + display: grid; + gap: 3px; + min-width: 0 +} + +.pgp-key-head small { + color: var(--muted) +} + +.pgp-fingerprint { + display: block; + overflow-wrap: anywhere; + font-size: .84em; + color: var(--muted) +} + +@media(max-width:760px) { + .pgp-settings-grid { + grid-template-columns: 1fr + } + + .compose-pgp-details { + grid-template-columns: 1fr + } + + .compose-pgp-details .inline-actions { + justify-content: flex-start + } + + .pgp-reader-bar { + align-items: flex-start; + flex-direction: column + } +} \ No newline at end of file diff --git a/src/web/css/60-responsive.css b/src/web/css/60-responsive.css new file mode 100644 index 0000000..0b6df41 --- /dev/null +++ b/src/web/css/60-responsive.css @@ -0,0 +1,253 @@ +/* Dedicated full-screen composer mode. */ +.compose-dialog.compose-fullscreen { + width: 100vw; + height: 100dvh; + max-width: none; + max-height: none; + margin: 0; + border: 0; + border-radius: 0; + overflow: hidden +} + +.compose-dialog.compose-fullscreen .compose-card { + width: 100%; + height: 100dvh; + max-width: none; + max-height: none; + border-radius: 0 +} + +html[data-preview="off"] .mail-shell { + grid-template-columns: var(--folder-pane-width) minmax(360px, 1fr); +} + +html[data-preview="off"] .reader-pane { + display: none; +} + +html[data-preview="bottom"] .mail-shell { + grid-template-columns: var(--folder-pane-width) minmax(0, 1fr); + grid-template-rows: minmax(260px, 44%) minmax(0, 1fr); +} + +html[data-preview="bottom"] .folder-pane { + grid-row: 1 / 3; +} + +html[data-preview="bottom"] .message-pane { + grid-column: 2; + grid-row: 1; + border-right: 0; + border-bottom: 1px solid var(--line); +} + +html[data-preview="bottom"] .reader-pane { + grid-column: 2; + grid-row: 2; +} + +@media (max-width: 1080px) { + .appbar { + grid-template-columns: minmax(150px, min(22vw, var(--folder-pane-width))) minmax(220px, 1fr) auto; + } + + .mail-shell { + grid-template-columns: minmax(170px, min(24vw, var(--folder-pane-width))) minmax(270px, min(39vw, var(--message-pane-width))) minmax(320px, 1fr); + } + + .account-summary { + display: none; + } +} + +@media (max-width: 820px) { + .mobile-only { + display: inline-grid; + } + + .appbar { + grid-template-columns: auto minmax(0, 1fr) auto; + } + + .app-brand strong { + display: none; + } + + .mail-shell { + display: block; + position: relative; + } + + .folder-pane { + position: absolute; + z-index: 20; + top: 0; + bottom: 0; + left: 0; + width: 250px; + transform: translateX(-100%); + transition: transform .15s ease; + box-shadow: 12px 0 28px var(--shadow); + } + + .folder-pane.open { + transform: translateX(0); + } + + .message-pane { + height: 100%; + border-right: 0; + } + + .reader-pane { + display: none; + position: absolute; + z-index: 15; + inset: 0; + } + + .reader-pane.mobile-open { + display: block; + } + + html[data-preview="off"] .reader-pane.mobile-open { + display: block; + } + + .message-header { + padding: 16px; + } + + .thread-section, + .attachment-list { + padding-left: 16px; + padding-right: 16px; + } + + .settings-layout { + grid-template-columns: 120px minmax(0, 1fr); + } + + .sieve-layout { + grid-template-columns: 1fr; + } +} + +@media (max-width: 560px) { + .appbar { + gap: 5px; + padding: 0 6px; + } + + .search-field kbd { + display: none; + } + + .appbar-actions .icon-button:first-child { + display: none; + } + + .dialog { + width: calc(100vw - 12px); + max-width: none; + } + + .compose-dialog { + width: calc(100vw - 12px); + } + + .compose-card { + height: calc(100dvh - 12px); + } + + .dialog-card { + min-width: 0; + } + + .settings-dialog { + width: calc(100vw - 12px); + } + + .settings-layout { + grid-template-columns: 1fr; + } + + .settings-nav { + flex-direction: row; + flex-wrap: nowrap; + overflow-x: auto; + overflow-y: hidden; + border-right: 0; + border-bottom: 1px solid var(--line); + } + + .settings-tab { + flex: 0 0 auto; + text-align: center; + white-space: nowrap; + } + + .settings-grid { + grid-template-columns: 1fr; + } + + .reader-toolbar .button span { + display: none; + } + + .reader-toolbar .move-control { + display: none; + } +} + +@media (max-width:760px) { + + .identity-dialog, + .sieve-rule-dialog { + width: calc(100vw - 12px); + max-width: none + } + + .identity-card, + .sieve-rule-card { + height: calc(100dvh - 12px); + max-height: calc(100dvh - 12px) + } + + .identity-signature-grid, + .identity-fields-grid, + .identity-options-grid, + .identity-toggle-grid { + grid-template-columns: 1fr + } + + .identity-field-wide { + grid-column: auto + } + + .sieve-rule-dialog-body { + padding: 7px + } +} + +@media (max-width:560px) { + .compose-dialog.compose-fullscreen { + width: 100vw; + height: 100dvh; + max-width: none; + max-height: none + } + + .compose-dialog.compose-fullscreen .compose-card { + height: 100dvh + } +} + +@media (prefers-reduced-motion: reduce) { + * { + scroll-behavior: auto !important; + transition: none !important; + animation: none !important; + } +} \ No newline at end of file diff --git a/src/web/css/components.css b/src/web/css/components.css new file mode 100644 index 0000000..d42a0db --- /dev/null +++ b/src/web/css/components.css @@ -0,0 +1,108 @@ +/* OpenPGP */ +.pgp-reader-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 9px 12px; + border-block: 1px solid var(--border); + background: color-mix(in srgb, var(--accent) 8%, var(--panel)); +} + +.pgp-reader-bar>div:first-child { + display: grid; + gap: 2px; + min-width: 0 +} + +.pgp-reader-status { + display: block +} + +.compose-pgp-panel { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px 16px; + align-items: center; + padding: 9px 14px; + border-top: 1px solid var(--border); + border-bottom: 1px solid var(--border); + background: color-mix(in srgb, var(--accent) 5%, var(--panel)); +} + +.compose-pgp-panel>label:not(.checkbox-row) { + display: grid; + gap: 4px; + min-width: 180px +} + +.compose-pgp-panel .inline-actions { + justify-content: flex-end +} + +.pgp-settings-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px +} + +.pgp-settings-grid .settings-section { + margin: 0 +} + +.pgp-key-list { + display: grid; + gap: 8px +} + +.pgp-key-card { + display: grid; + gap: 8px; + padding: 12px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--panel) +} + +.pgp-key-head { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: start +} + +.pgp-key-head>div { + display: grid; + gap: 3px; + min-width: 0 +} + +.pgp-key-head small { + color: var(--muted) +} + +.pgp-fingerprint { + display: block; + overflow-wrap: anywhere; + font-size: .84em; + color: var(--muted) +} + +@media(max-width:760px) { + .pgp-settings-grid { + grid-template-columns: 1fr + } + + .compose-pgp-panel { + grid-template-columns: 1fr + } + + .compose-pgp-panel .inline-actions { + justify-content: flex-start + } + + .pgp-reader-bar { + align-items: flex-start; + flex-direction: column + } +} \ No newline at end of file diff --git a/src/web/css/manifest.txt b/src/web/css/manifest.txt new file mode 100644 index 0000000..b6ac39d --- /dev/null +++ b/src/web/css/manifest.txt @@ -0,0 +1,7 @@ +00-tokens.css +10-base.css +20-shell.css +30-mail.css +40-compose.css +50-settings.css +60-responsive.css diff --git a/src/web/i18n/en.json b/src/web/i18n/en.json new file mode 100644 index 0000000..85cf93c --- /dev/null +++ b/src/web/i18n/en.json @@ -0,0 +1,40 @@ +{ + "search_mail": "Search mail", + "settings": "Settings", + "sign_out": "Sign out", + "compose": "Compose", + "folders": "Folders", + "appearance": "Appearance", + "filters": "Filters", + "about": "About", + "theme": "Theme", + "density": "Density", + "preview_pane": "Preview pane", + "ui_size": "UI size", + "language": "Language", + "color_preset": "Color preset", + "accent_color": "Accent color", + "font_family": "Font family", + "font_size": "Font size", + "row_height": "Message row height", + "folder_width": "Folder pane width", + "message_width": "Message pane width", + "corner_radius": "Corner radius", + "contrast": "Contrast", + "reduce_motion": "Reduce motion", + "show_avatars": "Show avatars", + "show_attachment_icons": "Show attachment icons", + "show_flags": "Show flags", + "reset_appearance": "Reset appearance", + "spam": "Spam", + "not_spam": "Not spam", + "mark_spam": "Mark as spam", + "mark_not_spam": "Mark as not spam", + "marked_spam": "Marked as spam", + "marked_not_spam": "Marked as not spam", + "source": "Source", + "print_message": "Print message", + "create_filter_sender": "Create filter from sender", + "filter_draft_created": "Filter draft created from message", + "select_all_results": "Select all results" +} diff --git a/src/web/i18n/pl.json b/src/web/i18n/pl.json new file mode 100644 index 0000000..67050da --- /dev/null +++ b/src/web/i18n/pl.json @@ -0,0 +1,40 @@ +{ + "search_mail": "Szukaj w poczcie", + "settings": "Ustawienia", + "sign_out": "Wyloguj", + "compose": "Napisz", + "folders": "Foldery", + "appearance": "Wygląd", + "filters": "Filtry", + "about": "Informacje", + "theme": "Motyw", + "density": "Gęstość", + "preview_pane": "Panel podglądu", + "ui_size": "Rozmiar interfejsu", + "language": "Język", + "color_preset": "Paleta kolorów", + "accent_color": "Kolor akcentu", + "font_family": "Krój pisma", + "font_size": "Rozmiar tekstu", + "row_height": "Wysokość wiersza wiadomości", + "folder_width": "Szerokość panelu folderów", + "message_width": "Szerokość listy wiadomości", + "corner_radius": "Zaokrąglenie narożników", + "contrast": "Kontrast", + "reduce_motion": "Ogranicz animacje", + "show_avatars": "Pokazuj awatary", + "show_attachment_icons": "Pokazuj ikony załączników", + "show_flags": "Pokazuj flagi", + "reset_appearance": "Przywróć wygląd", + "spam": "Spam", + "not_spam": "To nie spam", + "mark_spam": "Oznacz jako spam", + "mark_not_spam": "Oznacz jako nie-spam", + "marked_spam": "Oznaczono jako spam", + "marked_not_spam": "Oznaczono jako nie-spam", + "source": "Źródło", + "print_message": "Drukuj wiadomość", + "create_filter_sender": "Utwórz filtr z nadawcy", + "filter_draft_created": "Utworzono szkic filtra z wiadomości", + "select_all_results": "Zaznacz wszystkie wyniki" +} diff --git a/src/web/icons.svg b/src/web/icons.svg new file mode 100644 index 0000000..5d8389b --- /dev/null +++ b/src/web/icons.svg @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/web/index.html b/src/web/index.html new file mode 100644 index 0000000..337bc56 --- /dev/null +++ b/src/web/index.html @@ -0,0 +1,698 @@ + + + + + + + + RustBox + + + + +
    + + RustBox +
    + + + +
    + +
    +
    + + RustBox +
    + +
    + + + + + +
    +
    + +
    + + +
    +
    +
    Inbox0 messages
    +
    + + + +
    +
    + +
    + + +
    + +
    +
    + + No message selectedSelect a message from the list. +
    + +
    +
    +
    + + +
    +
    New message
    +
    + + + + + +
    Options
    + +
    +
    + + + + + + + + + + + + + + + + + + + + + + + + +
    +
    + +
    +
    +
    + + +
    +
    Markdown preview
    +
    +
    +
    + + +
    +
    Edit inline image
    +
    +
    +
    + + +
    + Crop / cut + + + + +
    +

    Resizing changes only the displayed size. Cropping creates a new CID image and replaces the previous inline attachment.

    +
    +
    +
    +
    +
    + + +
    +
    Folders
    +
    +
    +
    +
    + + +
    +
    Settings
    +
    + +
    +
    +

    Appearance

    Personalize RustBox without changing mail behavior.

    +
    + + + + +
    +
    + + + + + + + + + + + + + + + + + + + +
    +
    + + + + +
    +
    + Advanced interface styling +
    + + + + + + + + + +
    +
    + + + +
    +
    + + + +
    +
    +
    + + + + + + + + + + +
    +
    +
    +
    + + + + +
    +
    Identity & signature
    +
    + +
    +
    Sender identityAddress and headers used when this identity is selected in the composer.
    +
    + + + + + +
    +
    +
    +
    Signature / footerMaintain separate text and HTML versions. Imported images are embedded into outgoing mail as inline CID images.
    +
    + + +
    +
    + + +
    +
    +
    +
    DefaultsChoose where this signature is inserted and, when OpenPGP is configured, its default behavior.
    +
    + + + + + + + +
    +
    +
    +
    +
    +
    + + +
    +
    Create filterBuild the rule visually and verify the preview before applying it.
    +
    +
    +
    +
    +
    RuleGive it a readable name and decide whether it is active.
    +
    + + + +
    +
    +
    +
    WhenChoose what the incoming message must match.
    +
    + + + + + + + + + + + + + + + + + +
    +
    +
    +
    +
    +
    ThenActions run in the order shown below.
    +
    + + + + +
    +
    +
    +
    +
    +
    What will happenLive explanation of the rule before you save it.
    Ready
    +
    Complete the rule to see its effect.
    +
    
    +                    
    + +
    + +
    +
    +
    +
    + + +
    +
    Keyboard shortcuts
    +
    +

    Global

    /
    Focus search
    C / Ctrl + N
    New message
    V
    Toggle selection mode
    ?
    Show shortcuts
    +

    Message

    J / K
    Next / previous message
    X
    Select / unselect current message
    R
    Reply
    A
    Reply all
    F
    Forward
    E
    Archive
    M
    Read / unread
    S
    Flag / unflag
    !
    Spam / not spam
    Delete
    Delete / move to Trash
    +
    +
    +
    +
    + + +
    +
    Label
    +
    + + + +
    +
    +
    +
    + + +
    +
    Advanced search
    +
    +
    + + + + + + + + + + + +
    +
    +
    Saved searches
    +
    +
    +
    + + + + + +
    +
    +
    +
    + + +
    +
    Contacts
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    +
    Photo
    + +
    + + + +
    +
    + + + + + + + + + + + + + +
    Groups
    +
    +
    +
    +
    +
    + + +
    +
    Duplicate contacts
    +

    Duplicates are detected when two contacts share a primary or alternate e-mail address. Choose which record to keep.

    +
    +
    +
    +
    + + +
    +
    Calendar
    +
    +
    + +
    +
    + +
    +
    Message headers
    +
    
    +      
    +
    +
    + + + + + + +
    + + + diff --git a/tests/fixtures/account-session-expired.json b/tests/fixtures/account-session-expired.json new file mode 100644 index 0000000..cd491c7 --- /dev/null +++ b/tests/fixtures/account-session-expired.json @@ -0,0 +1,8 @@ +{ + "error": { + "code": "ACCOUNT_SESSION_EXPIRED", + "message": "Account session has expired", + "status": 401, + "accountId": "expired-account" + } +} diff --git a/ui/app.css b/ui/app.css new file mode 100644 index 0000000..7090d19 --- /dev/null +++ b/ui/app.css @@ -0,0 +1,1373 @@ +/* RustBox UI bundle - generated by build.rs */ + +/* 00-tokens.css */ +:root { + color-scheme: dark; + --bg: #121513; + --panel: #181c19; + --panel-2: #1e231f; + --panel-3: #242a25; + --sidebar: #101310; + --line: #313832; + --line-strong: #485149; + --text: #edf0ec; + --muted: #9da79f; + --subtle: #7c877f; + --accent: #8fb9a4; + --accent-strong: #a4c9b6; + --accent-ink: #102018; + --danger: #e18f8a; + --warning: #d0ad70; + --success: #a7c892; + --field: #121713; + --selected: #28352e; + --hover: #202621; + --shadow: rgba(0, 0, 0, .32); + --font-sans: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --font-mono: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace; + --radius-control: 3px; + --radius-panel: 4px; + --ui-size: 13px; + --row-height: 58px; + --control-height: 30px; + --appbar-height: 44px; + --dialog-header-height: 43px; + --ui-scale-factor: 1; + --density-factor: 1; + --icon-size: 18px; + --button-pad-x: 10px; + --button-pad-y: 5px; + --field-pad-x: 8px; + --field-pad-y: 5px; +} +html[data-theme="light"] { + color-scheme: light; + --bg: #f2f4f1; + --panel: #ffffff; + --panel-2: #f7f8f6; + --panel-3: #edf1ec; + --sidebar: #e9eee9; + --line: #cbd2cb; + --line-strong: #aeb8af; + --text: #202622; + --muted: #647068; + --subtle: #7a847d; + --accent: #507b67; + --accent-strong: #416b58; + --accent-ink: #ffffff; + --danger: #a94c48; + --warning: #8a6930; + --success: #587a49; + --field: #ffffff; + --selected: #dce8e0; + --hover: #e8ede8; + --shadow: rgba(31, 39, 33, .16); +} + +/* User personalization */ +:root { + --folder-pane-width: 228px; + --message-pane-width: 420px; +} +html[data-color-preset="blue"] { --accent: #6f96c9; --accent-strong: #86a9d5; --selected: color-mix(in srgb, var(--accent) 20%, var(--panel)); } +html[data-color-preset="slate"] { --accent: #8e9aa8; --accent-strong: #aab4bf; --selected: color-mix(in srgb, var(--accent) 20%, var(--panel)); } +html[data-color-preset="amber"] { --accent: #c69a55; --accent-strong: #d7ad6b; --selected: color-mix(in srgb, var(--accent) 20%, var(--panel)); } +html[data-color-preset="violet"] { --accent: #9b88c9; --accent-strong: #ad9bd5; --selected: color-mix(in srgb, var(--accent) 20%, var(--panel)); } +html[data-color-preset="rose"] { --accent: #c98591; --accent-strong: #d79aa5; --selected: color-mix(in srgb, var(--accent) 20%, var(--panel)); } +html[data-color-preset="custom"] { --bg: var(--user-bg, #121513); --panel: var(--user-panel, #181c19); --panel-2: color-mix(in srgb, var(--panel) 88%, var(--text)); --panel-3: color-mix(in srgb, var(--panel) 80%, var(--text)); --sidebar: color-mix(in srgb, var(--bg) 88%, black); --text: var(--user-text, #edf0ec); --muted: color-mix(in srgb, var(--text) 66%, var(--panel)); --subtle: color-mix(in srgb, var(--text) 52%, var(--panel)); --field: color-mix(in srgb, var(--bg) 82%, var(--panel)); --line: color-mix(in srgb, var(--text) 18%, var(--panel)); --line-strong: color-mix(in srgb, var(--text) 30%, var(--panel)); --hover: color-mix(in srgb, var(--text) 8%, var(--panel)); --accent: var(--user-accent, #6f9c86); --accent-strong: color-mix(in srgb, var(--accent) 82%, white); --selected: color-mix(in srgb, var(--accent) 22%, var(--panel)); } +html[data-font-family="inter"] { --font-sans: Inter, ui-sans-serif, system-ui, sans-serif; } +html[data-font-family="humanist"] { --font-sans: "Segoe UI", Candara, Calibri, ui-sans-serif, system-ui, sans-serif; } +html[data-font-family="mono"] { --font-sans: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace; } +html[data-contrast="soft"] { --line: color-mix(in srgb, var(--line) 70%, transparent); --line-strong: color-mix(in srgb, var(--line-strong) 78%, transparent); } +html[data-contrast="high"] { --line: color-mix(in srgb, var(--text) 28%, var(--panel)); --line-strong: color-mix(in srgb, var(--text) 48%, var(--panel)); --muted: color-mix(in srgb, var(--text) 74%, var(--panel)); } +html[data-hide-avatars="true"] .avatar { display: none !important; } +html[data-hide-attachments="true"] .message-attachment, html[data-hide-attachments="true"] .attachment-indicator { display: none !important; } +html[data-hide-flags="true"] .message-flag, html[data-hide-flags="true"] .flag-indicator { display: none !important; } +html[data-reduce-motion="true"] * { scroll-behavior: auto !important; transition: none !important; animation: none !important; } +html[data-color-preset="teal"] { --accent:#4f9d93; --accent-strong:#68b0a5; --selected:color-mix(in srgb,var(--accent) 20%,var(--panel)); } +html[data-color-preset="cyan"] { --accent:#4c9fba; --accent-strong:#68b4cb; --selected:color-mix(in srgb,var(--accent) 20%,var(--panel)); } +html[data-color-preset="indigo"] { --accent:#7184c5; --accent-strong:#8b9bd2; --selected:color-mix(in srgb,var(--accent) 20%,var(--panel)); } +html[data-color-preset="graphite"] { --accent:#9aa19c; --accent-strong:#b0b6b2; --selected:color-mix(in srgb,var(--accent) 18%,var(--panel)); } +html[data-color-preset="orange"] { --accent:#c9824e; --accent-strong:#d69a6d; --selected:color-mix(in srgb,var(--accent) 20%,var(--panel)); } +html[data-color-preset="red"] { --accent:#bd6662; --accent-strong:#cf7e7a; --selected:color-mix(in srgb,var(--accent) 20%,var(--panel)); } + +/* Advanced per-user interface controls. */ +:root { + --toolbar-density-factor: 1; + --sidebar-item-height: 29px; + --reader-max-width: none; + --composer-width: 900px; + --unread-font-weight: 730; + --folder-bg: var(--sidebar); + --message-bg: var(--panel); + --reader-bg: var(--panel); +} +html[data-monochrome="true"] { --accent:#929a95; --accent-strong:#aeb4b0; --danger:#9f7775; --warning:#9b8e75; --success:#879482; } +html[data-scrollbar-density="normal"] * { scrollbar-width:auto; } +html[data-scrollbar-density="thin"] * { scrollbar-width:thin; } +html[data-scrollbar-density="hidden"] * { scrollbar-width:none; } +html[data-scrollbar-density="hidden"] *::-webkit-scrollbar { width:0; height:0; } +html[data-show-borders="false"] .folder-pane, +html[data-show-borders="false"] .message-pane, +html[data-show-borders="false"] .pane-toolbar, +html[data-show-borders="false"] .reader-toolbar, +html[data-show-borders="false"] .message-header, +html[data-show-borders="false"] .thread-section, +html[data-show-borders="false"] .attachment-list, +html[data-show-borders="false"] .message-row { border-color:transparent !important; } + + +/* 10-base.css */ +* { box-sizing: border-box; scrollbar-color: var(--line-strong) var(--panel); scrollbar-width: thin; } +html, body { height: 100%; } +body { margin: 0; overflow: hidden; background: var(--bg); color: var(--text); font: var(--ui-size)/1.45 var(--font-sans); } +button, input, select, textarea { font: inherit; color: inherit; } +button { cursor: pointer; } +button:disabled { cursor: default; opacity: .55; } +input, select, textarea { width: 100%; min-width: 0; border: 1px solid var(--line-strong); border-radius: var(--radius-control); background: var(--field); min-height: var(--control-height); padding: var(--field-pad-y) var(--field-pad-x); outline: none; } +input:focus, select:focus, textarea:focus, [contenteditable="true"]:focus { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); outline: 0; } +button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } +svg { width: var(--icon-size); height: var(--icon-size); fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; } +.hidden, [hidden] { display: none !important; } +.muted { color: var(--muted); } +.wide { width: 100%; } +.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; } +.button { min-height: var(--control-height); display: inline-flex; align-items: center; justify-content: center; gap: 7px; border: 1px solid var(--line-strong); border-radius: var(--radius-control); padding: var(--button-pad-y) var(--button-pad-x); background: var(--panel-2); color: var(--text); font-weight: 600; } +.button:hover { border-color: var(--accent); } +.button.primary { border-color: var(--accent); background: var(--accent); color: var(--accent-ink); } +.button.primary:hover { background: var(--accent-strong); } +.button.quiet { background: transparent; } +.button.danger, .icon-button.danger { color: var(--danger); } +.icon-button { width: var(--control-height); height: var(--control-height); display: inline-grid; place-items: center; padding: 0; border: 1px solid transparent; border-radius: var(--radius-control); background: transparent; color: var(--muted); } +.icon-button:hover { color: var(--text); background: var(--hover); border-color: var(--line); } +.icon-button.small { width: 26px; height: 26px; } +.icon-button.small svg { width: 15px; height: 15px; } +.form-error { margin-top: 8px; padding: 8px 9px; border: 1px solid color-mix(in srgb, var(--danger) 55%, var(--line)); color: var(--danger); background: color-mix(in srgb, var(--danger) 8%, var(--panel)); border-radius: var(--radius-control); } +label > span, .field-label { display: block; margin-bottom: 5px; color: var(--muted); font-size: 11px; font-weight: 650; } +.checkbox-row { display: flex; align-items: center; gap: 8px; } +.checkbox-row input { width: 15px; height: 15px; min-height: 0; margin: 0; } +.checkbox-row span { margin: 0; } +kbd { font: 10px var(--font-mono); color: var(--subtle); border: 1px solid var(--line); border-bottom-color: var(--line-strong); border-radius: 3px; padding: 1px 5px; } + + +/* 20-shell.css */ +.boot-screen { height: 100%; display: grid; place-content: center; justify-items: center; gap: 12px; background: var(--bg); } +.boot-screen strong { font-size: 14px; letter-spacing: .2px; } +.boot-icon { width: 32px; height: 32px; color: var(--accent); } +.login-view { height: 100%; display: grid; place-items: center; padding: 24px; background: var(--bg); } +.login-panel { width: min(100%, 360px); border: 1px solid var(--line); background: var(--panel); border-radius: var(--radius-panel); box-shadow: 0 18px 46px var(--shadow); } +.login-brand { display: flex; align-items: center; gap: 11px; padding: 18px 20px; border-bottom: 1px solid var(--line); } +.brand-icon { width: 24px; height: 24px; color: var(--accent); } +.login-brand div { display: grid; gap: 1px; } +.login-brand strong { font-size: 16px; } +.login-brand span { font-size: 11px; color: var(--muted); } +.login-form { display: grid; gap: 13px; padding: 20px; } +.login-panel footer { padding: 10px 20px 14px; color: var(--subtle); font-size: 10px; text-align: center; } +.app-view { height: 100%; display: grid; grid-template-rows: var(--appbar-height, 44px) minmax(0, 1fr); background: var(--bg); } +.appbar { display: grid; grid-template-columns: 220px minmax(260px, 620px) 1fr; align-items: center; gap: 16px; padding: 0 10px; border-bottom: 1px solid var(--line); background: var(--panel); } +.app-brand { display: flex; align-items: center; gap: 8px; padding-left: 3px; } +.app-brand strong { font-size: 14px; letter-spacing: -.2px; } +.search-field { height: 30px; display: grid; grid-template-columns: 22px 1fr auto auto; align-items: center; gap: 4px; padding: 0 7px; border: 1px solid var(--line-strong); border-radius: var(--radius-control); background: var(--field); } +.search-field:focus-within { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); } +.search-field svg { width: 15px; height: 15px; color: var(--muted); } +.search-field input { min-height: 0; height: 28px; border: 0; padding: 0 3px; background: transparent; box-shadow: none; } +.search-field input:focus { box-shadow: none; } +.appbar-actions { justify-self: end; display: flex; align-items: center; gap: 4px; } +.account-summary { display: grid; min-width: 120px; margin-left: 6px; padding-left: 10px; border-left: 1px solid var(--line); line-height: 1.25; } +.account-summary span:first-child { max-width: 190px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 11px; font-weight: 650; } +.account-summary span:last-child { color: var(--success); font-size: 9px; } +.mail-shell { min-height: 0; display: grid; grid-template-columns: var(--folder-pane-width) minmax(300px, var(--message-pane-width)) minmax(420px, 1fr); overflow:hidden; } +.folder-pane, .message-pane, .reader-pane { min-width: 0; min-height: 0; } +.message-pane { overflow:hidden; } +.reader-pane { container-name: reader-pane; container-type: inline-size; } +.folder-pane { display: flex; flex-direction: column; padding: 10px; border-right: 1px solid var(--line); background: var(--sidebar); } +.compose-button { justify-content: flex-start; margin-bottom: 12px; } +.section-heading { min-height: 27px; display: flex; align-items: center; justify-content: space-between; gap: 8px; color: var(--muted); font-size: 10px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; } +.folder-pane-footer { margin-top: auto; padding-top: 10px; border-top: 1px solid var(--line); display: flex; justify-content: space-between; color: var(--subtle); font: 9px var(--font-mono); } +.mobile-only { display: none; } + +.search-filter-button { width:24px; height:24px; display:grid; place-items:center; padding:0; border:0; border-radius:var(--radius-control); background:transparent; color:var(--muted); } +.search-filter-button:hover, .search-filter-button:focus-visible { background:var(--hover); color:var(--text); outline:none; } +.search-filter-button svg { width:14px; height:14px; } + +.folder-pane, +.message-pane { position: relative; } +.pane-resizer { + position: absolute; + z-index: 8; + top: 0; + right: -3px; + bottom: 0; + width: 7px; + cursor: col-resize; + touch-action: none; + background: transparent; +} +.pane-resizer::after { + content: ""; + position: absolute; + top: 0; + bottom: 0; + left: 3px; + width: 1px; + background: transparent; +} +.pane-resizer:hover::after, +.resizing-pane .pane-resizer::after { background: var(--accent); } +.resizing-pane { cursor: col-resize !important; user-select: none !important; } +.resizing-pane iframe { pointer-events: none; } +@media (max-width: 820px) { .pane-resizer { display: none; } } + +.offline-banner { + position: fixed; + z-index: 80; + top: calc(var(--appbar-height, 44px) + 6px); + left: 50%; + max-width: min(620px, calc(100vw - 24px)); + transform: translateX(-50%); + padding: 7px 10px; + border: 1px solid var(--warning); + border-radius: var(--radius-control); + background: var(--panel-3); + color: var(--text); + box-shadow: 0 8px 24px var(--shadow); + font-size: 10px; + text-align: center; +} +.network-offline .account-summary span:last-child { color: var(--warning); } + + +/* 30-mail.css */ +.folder-list { display: grid; gap: 1px; overflow: auto; } +.folder-button { min-height: 29px; width: 100%; display: grid; grid-template-columns: 22px minmax(0,1fr) auto; align-items: center; gap: 5px; padding: 3px 6px 3px calc(6px + (var(--folder-depth, 0) * 12px)); border: 1px solid transparent; border-radius: var(--radius-control); background: transparent; color: var(--muted); text-align: left; } +.folder-button svg { width: 15px; height: 15px; } +.folder-button .folder-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.folder-button:hover { color: var(--text); background: var(--hover); } +.folder-button.active { color: var(--text); background: var(--selected); border-color: var(--line); } +.folder-count { min-width: 20px; text-align: right; color: var(--subtle); font: 10px var(--font-mono); } +.folder-count.unread { color: var(--accent); font-weight: 700; } +.message-pane { display: grid; grid-template-rows: auto minmax(0,1fr) auto; border-right: 1px solid var(--line); background: var(--panel); } +.pane-toolbar { min-height: 48px; display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 7px 9px; border-bottom: 1px solid var(--line); } +.pane-title { display: flex; align-items: center; gap: 6px; min-width: 0; } +.pane-title > div { display: grid; min-width: 0; } +.pane-title strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; } +.pane-title span { color: var(--subtle); font-size: 10px; } +.toolbar-actions, .reader-toolbar { display: flex; align-items: center; gap: 3px; } +.message-list { min-height: 0; overflow-y: auto; overflow-x: hidden; } +.message-row { width: 100%; min-height: var(--row-height); display: grid; grid-template-columns: 19px minmax(0, 1fr) auto; grid-template-rows: auto auto; column-gap: 8px; row-gap: 2px; align-content: center; padding: 7px 9px; border: 0; border-bottom: 1px solid var(--line); background: var(--panel); color: var(--text); text-align: left; } +.message-row:hover { background: var(--hover); } +.message-row.active { background: var(--selected); } +.message-row.unread .message-from, .message-row.unread .message-subject { font-weight: 730; } +.message-state { grid-row: 1 / 3; align-self: center; width: 8px; height: 8px; border: 1px solid var(--line-strong); border-radius: 50%; } +.message-row.unread .message-state { background: var(--accent); border-color: var(--accent); } +.message-main { min-width: 0; display: grid; grid-template-columns: minmax(0,1fr) auto; align-items: baseline; gap: 8px; } +.message-from, .message-subject, .message-snippet { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.message-from { font-size: 11px; } +.message-date { color: var(--subtle); font: 9px var(--font-mono); white-space: nowrap; } +.message-subject-line { min-width: 0; display: flex; align-items: center; gap: 6px; } +.message-subject { font-size: 11px; } +.message-snippet { color: var(--muted); font-size: 10px; } +.message-icons { display: inline-flex; gap: 3px; color: var(--subtle); } +.message-icons svg { width: 12px; height: 12px; } +.load-more { margin: 7px; } +.reader-pane { position: relative; overflow: hidden; background: var(--panel-2); } +.reader-empty { height: 100%; display: grid; place-content: center; justify-items: center; gap: 7px; color: var(--muted); } +.reader-empty svg { width: 34px; height: 34px; color: var(--line-strong); } +.reader-empty strong { color: var(--text); font-size: 13px; } +.reader-empty span { font-size: 11px; } +.reader-content { height: 100%; overflow: auto; background: var(--panel); } +.reader-toolbar { position: sticky; top: 0; z-index: 4; min-height: 42px; padding: 5px 10px; border-bottom: 1px solid var(--line); background: var(--panel); } +.reader-toolbar .button { min-height: 29px; } +.reader-toolbar .button svg { width: 15px; height: 15px; } +.toolbar-spacer { flex: 1; } +.move-control select { width: 118px; height: 29px; min-height: 29px; padding-top: 3px; padding-bottom: 3px; } +.message-header { padding: 20px 22px 14px; border-bottom: 1px solid var(--line); } +.message-header h1 { margin: 0 0 15px; font-size: 20px; line-height: 1.25; font-weight: 620; letter-spacing: -.25px; overflow-wrap: anywhere; } +.message-meta { display: grid; grid-template-columns: 32px minmax(0,1fr) auto; align-items: center; gap: 9px; } +.avatar { width: 32px; height: 32px; display: grid; place-items: center; border: 1px solid var(--line-strong); border-radius: 50%; background: var(--panel-2); color: var(--muted); font-weight: 700; } +.sender { display: grid; min-width: 0; } +.sender strong, .sender span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.sender strong { font-size: 12px; } +.sender span, .message-meta time { color: var(--muted); font-size: 10px; } +.thread-section { padding: 9px 22px; border-bottom: 1px solid var(--line); } +.thread-list { display: grid; gap: 1px; } +.thread-item { display: grid; grid-template-columns: minmax(0,1fr) auto; gap: 8px; padding: 6px 7px; border: 0; border-radius: var(--radius-control); background: transparent; color: var(--muted); text-align: left; } +.thread-item:hover { background: var(--hover); color: var(--text); } +.thread-item.current { background: var(--selected); color: var(--text); } +.thread-item span:first-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.thread-item time { font: 9px var(--font-mono); color: var(--subtle); } +.attachment-list { display: flex; flex-wrap: wrap; gap: 5px; padding: 10px 22px; border-bottom: 1px solid var(--line); } +.attachment-button { display: inline-flex; align-items: center; gap: 6px; border: 1px solid var(--line); border-radius: var(--radius-control); background: var(--panel-2); color: var(--text); padding: 5px 8px; font-size: 10px; } +.attachment-button svg { width: 13px; height: 13px; } +.attachment-download-all { display:inline-flex; align-items:center; gap:6px; border:1px solid var(--line-strong); border-radius:var(--radius-control); background:transparent; color:var(--muted); padding:5px 8px; font-size:10px; } +.attachment-download-all:hover { color:var(--text); background:var(--hover); border-color:var(--accent); } +.attachment-download-all svg { width:13px; height:13px; } +.message-body { min-height: 260px; background: var(--panel); } +.message-body iframe { display: block; width: 100%; min-height: 68vh; border: 0; background: #fff; } +.message-body pre { margin: 0; padding: 22px; white-space: pre-wrap; overflow-wrap: anywhere; font: 13px/1.58 var(--font-sans); color: var(--text); } +.empty-list { padding: 30px 18px; color: var(--muted); text-align: center; font-size: 11px; } +.bulk-toolbar { + position:fixed; + z-index:72; + top:calc(var(--appbar-height, 44px) + 58px); + left:50%; + transform:translateX(-50%); + width:max-content; + max-width:min(900px, calc(100vw - 24px)); + min-height:40px; + display:flex; + align-items:center; + justify-content:center; + flex-wrap:wrap; + gap:5px; + padding:6px; + border:1px solid var(--line-strong); + border-radius:calc(var(--radius-panel) + 4px); + background:color-mix(in srgb, var(--panel-3) 94%, transparent); + box-shadow:0 12px 34px var(--shadow); + backdrop-filter:blur(10px); +} +.bulk-toolbar.hidden { display:none; } +.bulk-toolbar .toolbar-spacer { display:none; } +.bulk-toolbar .button { min-height:28px; padding:4px 8px; } +.bulk-toolbar .move-control select { width:126px; } +.bulk-select { display:flex; align-items:center; gap:7px; color:var(--muted); white-space:nowrap; } +.message-row { grid-template-columns: 19px minmax(0, 1fr) auto; cursor:default; } +.message-select { display:none; grid-row:1 / 3; align-self:center; margin:0; width:14px; height:14px; accent-color:var(--accent); } +.message-state { grid-column:1; } +.message-main { grid-column:2; } +.message-subject-line { grid-column:2; } +.message-icons { grid-column:3; } +.message-list.selection-mode .message-row { grid-template-columns:18px 19px minmax(0, 1fr) auto; cursor:pointer; } +.message-list.selection-mode .message-select { display:block; grid-column:1; } +.message-list.selection-mode .message-state { grid-column:2; } +.message-list.selection-mode .message-main { grid-column:3; } +.message-list.selection-mode .message-subject-line { grid-column:3; } +.message-list.selection-mode .message-icons { grid-column:4; } +.message-row.selected { background:color-mix(in srgb, var(--accent) 14%, var(--panel)); } +#select-mode-btn.active { color:var(--accent); background:var(--selected); border-color:var(--line); } + +.label-heading { margin-top:10px; } +.tag-list { max-height:150px; overflow:auto; } +.tag-item { width:100%; min-height:29px; display:grid; grid-template-columns:10px minmax(0,1fr) auto; align-items:center; gap:7px; padding:4px 7px; border:0; border-radius:var(--radius-control); background:transparent; color:var(--muted); text-align:left; } +.tag-item:hover, .tag-item.active { background:var(--hover); color:var(--text); } +.tag-dot { width:8px; height:8px; border-radius:50%; background:var(--tag-color); } +.tag-count { font-size:10px; color:var(--muted); } +.message-tags { display:inline-flex; flex-wrap:wrap; gap:3px; margin-left:5px; vertical-align:middle; } +.message-tag { display:inline-flex; align-items:center; min-height:15px; max-width:120px; padding:0 5px; border:1px solid color-mix(in srgb,var(--tag-color) 48%,var(--line)); border-radius:999px; background:color-mix(in srgb,var(--tag-color) 10%,transparent); color:var(--text); font-size:9px; line-height:14px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } +.folder-button.drop-target { outline: 1px solid var(--accent); outline-offset: -1px; background: var(--selected); } +.message-row.dragging { opacity: .5; } + +.remote-image-bar { + display: flex; + align-items: center; + gap: 10px; + margin: 0 18px 8px; + padding: 8px 10px; + border: 1px solid var(--line); + border-radius: var(--radius-control); + background: var(--panel-2); + color: var(--muted); + font-size: 12px; +} +.remote-image-bar .button { margin-left: auto; } + +.source-card { width: min(1040px, calc(100vw - 28px)); height: min(82vh, 860px); display: grid; grid-template-rows: auto minmax(0, 1fr) auto; } +.message-source-content { margin: 0; padding: 14px; overflow: auto; white-space: pre; tab-size: 4; font: 12px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; background: var(--panel-2); color: var(--text); border-block: 1px solid var(--line); } + +.attachment-item { + display: inline-flex; + align-items: center; + gap: 4px; +} + +.attachment-item .attachment-button { + min-width: 0; +} + +/* Reader toolbars must remain usable at every UI scale. */ +.reader-toolbar { + max-width:100%; + overflow-x:auto; + overflow-y:hidden; + scrollbar-width:thin; + flex-wrap:nowrap; +} +.reader-toolbar > *, .bulk-toolbar > * { flex:0 0 auto; } +#spam-btn.active { color: var(--warning); background: color-mix(in srgb, var(--warning) 10%, transparent); border-color: color-mix(in srgb, var(--warning) 35%, var(--line)); } + +.message-icons { align-items:center; min-width:40px; justify-content:flex-end; } +.message-static-icon { display:inline-grid; place-items:center; color:var(--subtle); } +.message-quick-actions { display:inline-flex; align-items:center; gap:1px; opacity:0; pointer-events:none; } +.message-row:hover .message-quick-actions, +.message-row:focus-within .message-quick-actions, +.message-row.selected .message-quick-actions { opacity:1; pointer-events:auto; } +.message-quick-action { width:22px; height:22px; display:grid; place-items:center; padding:0; border:0; border-radius:var(--radius-control); background:transparent; color:var(--muted); } +.message-quick-action:hover { background:var(--hover); color:var(--text); } +.message-quick-action.active { color:var(--warning); } +.message-quick-action svg { width:12px; height:12px; } +@media (hover:none) { .message-quick-actions { opacity:1; pointer-events:auto; } } + +/* Hierarchical folder tree. The disclosure control is separate from the folder action. */ +.folder-row { display:grid; grid-template-columns:18px minmax(0,1fr); align-items:center; padding-left:calc(var(--folder-depth, 0) * 12px); min-width:0; } +.folder-expander { width:18px; height:28px; display:grid; place-items:center; padding:0; border:0; border-radius:var(--radius-control); background:transparent; color:var(--subtle); } +.folder-expander:hover, .folder-expander:focus-visible { color:var(--text); background:var(--hover); } +.folder-expander svg { width:13px; height:13px; transition:transform .12s ease; } +.folder-expander.expanded svg { transform:rotate(90deg); } +.folder-expander.placeholder { visibility:hidden; pointer-events:none; } +.folder-row .folder-button { padding-left:4px; } +html[data-reduce-motion="true"] .folder-expander svg { transition:none; } + +#reader-from[role="button"] { cursor:pointer; border-radius:var(--radius-control); outline-offset:3px; } +#reader-from[role="button"]:hover { text-decoration:underline; text-underline-offset:2px; } +#reader-from[role="button"]:focus-visible { outline:1px solid var(--accent); } + +.list-sort-control select { width:auto; max-width:132px; min-height:28px; height:28px; padding:2px 24px 2px 7px; font-size:10px; } + +#thread-toggle svg { transition:transform .12s ease; } +#thread-toggle.expanded svg { transform:rotate(90deg); } +html[data-reduce-motion="true"] #thread-toggle svg { transition:none; } + + +.attachment-preview-dialog { width:min(1040px, calc(100vw - 28px)); max-width:none; } +.attachment-preview-card { height:min(84vh, 880px); display:grid; grid-template-rows:auto minmax(0,1fr) auto; } +.attachment-preview-body { min-height:0; overflow:auto; display:grid; place-items:center; background:var(--field); } +.attachment-preview-body img { display:block; max-width:100%; max-height:100%; object-fit:contain; } +.attachment-preview-body iframe { width:100%; height:100%; min-height:420px; border:0; background:#fff; } +.attachment-preview-body pre { align-self:stretch; justify-self:stretch; margin:0; padding:16px; overflow:auto; white-space:pre-wrap; overflow-wrap:anywhere; font:12px/1.5 var(--font-mono); color:var(--text); } + +/* A flagged message stays visually quiet while remaining easy to scan. */ +.message-row.flagged { box-shadow: inset 0 -2px 0 color-mix(in srgb, var(--danger) 58%, transparent); } + +.search-highlight { + background: var(--selected); + color: inherit; + border-radius: 2px; + padding: 0 1px; +} + +/* Advanced appearance preferences. */ +.folder-pane { background: var(--folder-bg); } +.folder-button, .tag-item { min-height: var(--sidebar-item-height); } +.message-pane, .message-row { background: var(--message-bg); } +.reader-content, .message-body { background: var(--reader-bg); } +.message-row.unread .message-from, .message-row.unread .message-subject { font-weight: var(--unread-font-weight); } +.message-header, .thread-section, .attachment-list, .remote-image-bar, .message-body { width:100%; max-width:var(--reader-max-width); margin-inline:auto; } +html[data-unread-badge="outline"] .message-row.unread .message-state { background:transparent; border:2px solid var(--accent); } +html[data-unread-badge="solid"] .message-row.unread .message-state { width:12px; height:6px; border-radius:999px; background:var(--accent); border-color:var(--accent); } +html[data-compact-headers="true"] .message-header { padding-block:10px 8px; } +html[data-compact-headers="true"] .message-header h1 { margin-bottom:8px; font-size:17px; } +html[data-compact-headers="true"] .message-meta { grid-template-columns:26px minmax(0,1fr) auto; } +html[data-compact-headers="true"] .message-meta .avatar { width:26px; height:26px; } +html[data-toolbar-density="compact"] .pane-toolbar, +html[data-toolbar-density="compact"] .reader-toolbar { min-height:calc(36px * var(--ui-scale-factor)); padding-block:3px; } +html[data-toolbar-density="spacious"] .pane-toolbar, +html[data-toolbar-density="spacious"] .reader-toolbar { min-height:calc(52px * var(--ui-scale-factor)); padding-block:8px; } + + +/* Pane-aware anti-collision rules. These react to the pane width, not only viewport width. */ +@container reader-pane (max-width: 640px) { + .reader-toolbar { gap:1px; padding-inline:6px; } + .reader-toolbar .button span { display:none; } + .reader-toolbar .button { padding-inline:6px; } +} +@container reader-pane (max-width: 460px) { + .reader-toolbar { scrollbar-width:none; } + .reader-toolbar::-webkit-scrollbar { display:none; } +} +@media (max-width: 560px) { + .bulk-toolbar { top:auto; bottom:10px; width:calc(100vw - 20px); max-width:none; } + .bulk-toolbar .move-control { display:block; } + .bulk-toolbar .move-control select { width:116px; } +} + + +/* 40-compose.css */ +.dialog { padding: 0; border: 1px solid var(--line-strong); border-radius: var(--radius-panel); background: transparent; color: var(--text); box-shadow: 0 20px 60px var(--shadow); } +.dialog::backdrop { background: rgba(0,0,0,.58); } +.dialog-card { min-width: 420px; background: var(--panel); } +.dialog-header { min-height: 43px; display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 6px 10px 6px 13px; border-bottom: 1px solid var(--line); } +.dialog-header strong { font-size: 13px; } +.compose-header-actions { display:flex; align-items:center; gap:6px; } +.compose-header-actions .button.small { min-height:28px; padding:4px 8px; font-size:10px; } +.compose-dialog { width: min(900px, calc(100vw - 40px)); max-width: none; } +.compose-card { height:min(760px, calc(100dvh - 48px)); display:grid; grid-template-rows:auto auto auto minmax(240px,1fr) auto auto auto auto; overflow:hidden; container-name:compose-card; container-type:inline-size; } +.compose-addresses { display:grid; gap:6px; max-height:min(46dvh, 380px); padding:10px 12px; border-bottom:1px solid var(--line); background:var(--panel); overflow:auto; } +.compose-addresses > label { display:grid; grid-template-columns:72px minmax(0,1fr); align-items:center; gap:10px; min-height:34px; border:0; } +.compose-addresses > label > span { margin:0; padding:0; font-size:10px; text-align:right; } +.compose-addresses > label input, +.compose-addresses > label select { min-height:34px; border:1px solid var(--line); border-radius:var(--radius-control); background:var(--field); box-shadow:none; } +.compose-addresses > label input:focus, +.compose-addresses > label select:focus { border-color:var(--accent); box-shadow:0 0 0 1px var(--accent); } +.compose-options-row { display:grid; grid-template-columns:72px minmax(0,1fr); align-items:start; gap:10px; min-height:34px; } +.compose-options-row > span { padding-top:8px; font-size:10px; color:var(--muted); text-align:right; } +.compose-options { display:flex; align-items:center; flex-wrap:wrap; gap:7px; padding:0; } +.compose-options label { display:inline-flex; grid-template-columns:none; align-items:center; gap:6px; min-height:34px; margin:0; padding:3px 7px; border:1px solid var(--line); border-radius:var(--radius-control); background:var(--panel-2); font-size:10px; color:var(--muted); } +.compose-options label > span { margin:0; padding:0; font-size:10px; } +.compose-options select { min-width:92px; min-height:26px; border:0; background:transparent; padding-block:2px; } +.compose-options .compose-mdn-option input { min-height:auto; width:14px; height:14px; } +.editor-toolbar { min-height:36px; display:flex; align-items:center; flex-wrap:wrap; gap:2px; row-gap:3px; padding:4px 8px; border-bottom:1px solid var(--line); background:var(--panel-2); } +.editor-toolbar button { width: 28px; height: 27px; display: grid; place-items: center; border: 1px solid transparent; border-radius: var(--radius-control); background: transparent; color: var(--muted); } +.editor-toolbar button:hover { border-color: var(--line); background: var(--hover); color: var(--text); } +.editor-toolbar svg { width: 15px; height: 15px; } +.toolbar-separator { width: 1px; height: 18px; margin: 0 4px; background: var(--line); } +.editor-mode { margin-left: auto; color: var(--subtle); font: 9px var(--font-mono); } +.compose-editor { min-height: 0; overflow: auto; padding: 18px 20px; background: var(--field); border: 0; outline: none; font: 14px/1.6 var(--font-sans); } +.compose-editor:empty::before { content: "Write a message"; color: var(--subtle); } +.compose-editor a { color: var(--accent); } +.dialog-actions { display: flex; align-items: center; gap: 7px; padding: 8px 10px; border-top: 1px solid var(--line); } +.dialog-actions > :first-child { margin-right: auto; } +.compose-upload-progress { display:grid; grid-template-columns:minmax(80px,1fr) auto; align-items:center; gap:8px; padding:7px 10px; border-top:1px solid var(--line); background:var(--panel-2); color:var(--muted); font-size:10px; } +.compose-upload-progress.hidden { display:none; } +.compose-upload-track { height:4px; overflow:hidden; border-radius:999px; background:var(--line); } +.compose-upload-track > span { display:block; width:0; height:100%; background:var(--accent); transition:width .12s linear; } +.compose-attachments { display:flex; flex-wrap:wrap; gap:6px; padding:8px 10px; border-top:1px solid var(--line); background:var(--panel-2); } +.compose-attachment { display:inline-flex; align-items:center; gap:6px; padding:5px 7px; border:1px solid var(--line); border-radius:var(--radius-control); background:var(--field); } +.compose-attachment button { width:20px; height:20px; display:grid; place-items:center; padding:0; border:0; background:transparent; color:var(--muted); } +.compose-mode-control { margin-left:auto; } +.compose-mode-control select { min-width:108px; } +.compose-editor.plain-mode { font-family:var(--font-mono); white-space:pre-wrap; } +.editor-toolbar button:disabled { opacity:.4; cursor:not-allowed; } +.editor-select select { height: 28px; max-width: 104px; border: 1px solid var(--line); background: var(--panel); color: var(--text); font: inherit; } +.editor-color { display: inline-flex; align-items: center; } +.editor-color input { width: 30px; height: 28px; padding: 2px; border: 1px solid var(--line); background: var(--panel); } + +.recipient-field { position: relative; } +.recipient-suggestions { + position: absolute; + z-index: 120; + top: calc(100% + 2px); + left: 0; + right: 0; + max-height: 220px; + overflow: auto; + padding: 3px; + border: 1px solid var(--line-strong); + border-radius: var(--radius-control); + background: var(--panel-3); + box-shadow: 0 10px 26px var(--shadow); +} +.recipient-suggestion { + width: 100%; + min-height: 34px; + display: grid; + gap: 1px; + padding: 5px 7px; + border: 0; + border-radius: var(--radius-control); + background: transparent; + color: var(--text); + text-align: left; +} +.recipient-suggestion:hover, +.recipient-suggestion.active { background: var(--selected); } +.recipient-suggestion strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 11px; } +.recipient-suggestion span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--muted); font-size: 10px; } + + +/* Inline image editing */ +.compose-editor img[data-rustbox-cid] { + max-width: 100%; + height: auto; + cursor: pointer; + border-radius: min(3px, var(--radius-control)); +} +.compose-editor img[data-rustbox-cid].compose-inline-image-selected { + outline: 2px solid var(--accent); + outline-offset: 2px; +} +.image-edit-dialog { width:min(840px, calc(100vw - 28px)); max-width:none; } +.image-edit-card { + width:100%; + height:min(720px, calc(100dvh - 28px)); + display:grid; + grid-template-rows:auto minmax(0,1fr) auto; + overflow:hidden; +} +.image-edit-layout { + min-height:0; + display:grid; + grid-template-columns:minmax(0,1.35fr) minmax(240px,.65fr); + overflow:hidden; +} +.image-edit-preview { + min-width:0; + min-height:0; + display:grid; + place-items:center; + overflow:auto; + padding:16px; + background:var(--field); +} +.image-edit-preview canvas { + display:block; + max-width:100%; + max-height:100%; + width:auto; + height:auto; + border:1px solid var(--line); + background:var(--panel); +} +.image-edit-controls { + min-height:0; + overflow:auto; + padding:16px; + border-left:1px solid var(--line); +} +.image-edit-controls > label, +.image-crop-controls label { display:grid; gap:6px; margin-bottom:14px; } +.image-edit-controls label > span, +.image-crop-controls label > span { color:var(--muted); font-size:10px; } +.image-edit-range-line { display:grid; grid-template-columns:minmax(0,1fr) auto; align-items:center; gap:8px; } +.image-edit-range-line output { min-width:46px; text-align:right; color:var(--muted); font:10px var(--font-mono); } +.image-crop-controls { + margin:8px 0 0; + padding:12px; + border:1px solid var(--line); + border-radius:var(--radius-control); +} +.image-crop-controls legend { padding:0 5px; color:var(--muted); font-size:10px; font-weight:700; } +.image-crop-disabled { opacity:.5; pointer-events:none; } +.image-edit-help { margin:8px 0 0; color:var(--subtle); font-size:10px; line-height:1.45; } +@media (max-width: 720px) { + .image-edit-layout { grid-template-columns:1fr; grid-template-rows:minmax(220px,1fr) auto; } + .image-edit-controls { border-left:0; border-top:1px solid var(--line); max-height:42dvh; } +} +.compose-dialog { width:min(var(--composer-width), calc(100vw - 40px)); } +.markdown-preview-dialog { width:min(860px, calc(100vw - 32px)); } +.markdown-preview-card { max-height:min(82dvh, 820px); display:grid; grid-template-rows:auto minmax(0,1fr); } +.markdown-preview-content { overflow:auto; padding:22px; background:var(--panel); color:var(--text); line-height:1.55; } +.markdown-preview-content > :first-child { margin-top:0; } +.markdown-preview-content > :last-child { margin-bottom:0; } +.markdown-preview-content pre { overflow:auto; padding:10px; background:var(--panel-2); border:1px solid var(--line); border-radius:var(--radius-control); } +.markdown-preview-content code { font-family:var(--font-mono); } +.markdown-preview-content blockquote { margin-left:0; padding-left:12px; border-left:3px solid var(--line-strong); color:var(--muted); } + +#compose-editor { + font-family: var(--compose-font-family, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif); +} +.compose-reply-entry { min-height: 1.35em; } + +/* OpenPGP composer / reader */ +.pgp-reader-bar{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:9px 12px;border-block:1px solid var(--border);background:color-mix(in srgb,var(--accent) 8%,var(--panel));} +.pgp-reader-bar>div:first-child{display:grid;gap:2px;min-width:0}.pgp-reader-status{display:block} +.compose-pgp-panel{margin-left:82px;overflow:hidden;border:1px solid var(--line);border-radius:var(--radius-control);background:color-mix(in srgb,var(--accent) 5%,var(--panel));} +.compose-pgp-panel>summary{min-height:32px;display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:9px;padding:5px 9px;cursor:pointer;list-style:none;user-select:none;} +.compose-pgp-panel>summary::-webkit-details-marker{display:none}.compose-pgp-summary-title{font-size:10px;font-weight:700;color:var(--text)} +.compose-pgp-summary-status{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:10px}.compose-pgp-summary-action{color:var(--accent);font-size:10px} +.compose-pgp-details{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:8px 14px;align-items:center;padding:8px 9px;border-top:1px solid var(--line);} +.compose-pgp-details>label:not(.checkbox-row){display:grid;gap:4px;min-width:0}.compose-pgp-details .inline-actions{justify-content:flex-end;margin-top:0}.compose-pgp-wide{grid-column:1 / -1} +.compose-pgp-passphrase{grid-column:1 / -1}.compose-pgp-passphrase input{width:100%} +.compose-fullscreen-toggle{font-size:17px;line-height:1;font-family:var(--font-sans);} + + +/* Signature/footer editor */ +.signature-editor-shell { overflow:hidden; border:1px solid var(--line-strong); border-radius:var(--radius-control); background:var(--field); } +.signature-editor-toolbar { min-height:34px; display:flex; align-items:center; gap:2px; padding:3px 5px; border-bottom:1px solid var(--line); background:var(--panel-2); } +.signature-editor-toolbar button { width:28px; height:27px; display:grid; place-items:center; padding:0; border:1px solid transparent; border-radius:var(--radius-control); background:transparent; color:var(--muted); } +.signature-editor-toolbar button:hover { border-color:var(--line); background:var(--hover); color:var(--text); } +.signature-editor-toolbar svg { width:15px; height:15px; } +.signature-editor-shell .signature-editor { min-height:150px; max-height:320px; overflow:auto; padding:10px; border:0; border-radius:0; background:var(--field); outline:none; } +.signature-editor-shell .signature-editor:focus { box-shadow:inset 0 0 0 1px var(--accent); } +.signature-editor img { max-width:min(100%, 420px); height:auto; } +.signature-help { display:block; margin-top:5px; line-height:1.45; } +.compose-signature img { max-width:100%; height:auto; } + +@container compose-card (max-width: 620px) { + .compose-addresses { padding:8px; } + .compose-addresses > label, .compose-options-row { grid-template-columns:54px minmax(0,1fr); gap:7px; } + .compose-pgp-panel { margin-left:61px; } +} +@media (max-width: 560px) { + .compose-addresses > label, .compose-options-row { grid-template-columns:1fr; gap:3px; } + .compose-addresses > label > span, .compose-options-row > span { text-align:left; padding-top:0; } + .compose-pgp-panel { margin-left:0; } + .editor-toolbar { max-height:112px; overflow:auto; } +} + + +/* 50-settings.css */ +.settings-dialog { width: min(900px, calc(100vw - 40px)); max-width: none; } +.settings-card { min-height: 560px; } +.settings-layout { display: grid; grid-template-columns: 160px minmax(0,1fr); min-height: 510px; } +.settings-nav { min-height:0; display: flex; flex-direction: column; gap: 1px; padding: 9px; border-right: 1px solid var(--line); background: var(--sidebar); overflow:auto; overscroll-behavior:contain; } +.settings-tab { min-height: 30px; border: 1px solid transparent; border-radius: var(--radius-control); background: transparent; color: var(--muted); text-align: left; padding: 4px 8px; } +.settings-tab:hover { background: var(--hover); color: var(--text); } +.settings-tab.active { background: var(--selected); border-color: var(--line); color: var(--text); } +.settings-content { min-width: 0; min-height:0; padding: 18px 20px; overflow: auto; overscroll-behavior:contain; } +.settings-panel h2 { margin: 0 0 18px; font-size: 16px; font-weight: 620; } +.settings-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 260px)); gap: 14px 18px; } +.settings-section { padding: 14px; border-bottom: 1px solid var(--line); } +.inline-field { display: grid; grid-template-columns: 1fr auto; gap: 7px; } +.inline-actions { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 9px; } +.folder-card { width: min(470px, calc(100vw - 30px)); } +.sieve-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; margin-bottom: 14px; } +.sieve-header h2 { margin-bottom: 2px; } +.sieve-header p { margin: 0; } +.sieve-layout { display: grid; grid-template-columns: 190px minmax(0,1fr); gap: 12px; min-height: 390px; } +.sieve-scripts { min-height: 260px; max-height: 390px; overflow: auto; border: 1px solid var(--line); background: var(--panel-2); } +.sieve-script { width: 100%; min-height: 30px; display: grid; grid-template-columns: minmax(0,1fr) auto; align-items: center; gap: 7px; border: 0; border-bottom: 1px solid var(--line); background: transparent; color: var(--muted); text-align: left; padding: 5px 7px; } +.sieve-script:hover, .sieve-script.active { background: var(--selected); color: var(--text); } +.sieve-script span:first-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.sieve-script span:last-child { color: var(--success); font: 8px var(--font-mono); } +.sieve-editor-wrap { display:grid; align-content:start; gap:10px; min-width:0; } +.code-editor { min-height: 310px; resize: vertical; font: 11px/1.55 var(--font-mono); tab-size: 4; } +.about-list { display: grid; grid-template-columns: 120px minmax(0,1fr); margin: 0; border-top: 1px solid var(--line); } +.about-list dt, .about-list dd { margin: 0; padding: 8px 0; border-bottom: 1px solid var(--line); } +.about-list dt { color: var(--muted); } +.about-list code { font: 10px var(--font-mono); overflow-wrap: anywhere; } +.toast-stack { position: fixed; right: 14px; bottom: 14px; z-index: 1000; display: grid; gap: 6px; width: min(340px, calc(100vw - 28px)); } +.toast { border: 1px solid var(--line-strong); border-left: 3px solid var(--accent); border-radius: var(--radius-control); background: var(--panel-3); color: var(--text); box-shadow: 0 10px 28px var(--shadow); padding: 9px 10px; font-size: 11px; } +.toast.error { border-left-color: var(--danger); } + +.settings-panel-heading { display:flex; align-items:flex-start; justify-content:space-between; gap:16px; margin-bottom:16px; } +.settings-panel-heading h2 { margin-bottom:3px; } +.appearance-grid { grid-template-columns: repeat(2,minmax(220px,1fr)); max-width:760px; } +.appearance-grid input[type="range"] { padding:0; border:0; background:transparent; min-height:24px; } +.color-input { display:grid; grid-template-columns:44px 1fr; align-items:center; gap:8px; } +.color-input input[type="color"] { width:44px; height:30px; padding:2px; } +.color-input output { font:11px var(--font-mono); color:var(--muted); } +.appearance-toggles { display:grid; grid-template-columns:repeat(2,minmax(220px,1fr)); gap:10px 18px; margin-top:18px; padding-top:14px; border-top:1px solid var(--line); max-width:760px; } +.appearance-presets { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:8px; max-width:760px; margin-bottom:18px; } +.appearance-preset { min-width:0; display:grid; gap:4px; padding:10px; border:1px solid var(--line); border-radius:var(--radius-panel); background:var(--panel-2); color:var(--text); text-align:left; } +.appearance-preset:hover, .appearance-preset:focus-visible { border-color:var(--accent); background:var(--hover); } +.appearance-preset strong { font-size:11px; } +.appearance-preset span { color:var(--muted); font-size:9px; line-height:1.35; } +.app-prompt-card { width:min(460px,calc(100vw - 24px)); display:grid; grid-template-rows:auto minmax(0,1fr) auto; } +.app-prompt-body { display:grid; gap:10px; padding:16px; overflow:auto; } +.app-prompt-message { margin:0; color:var(--muted); line-height:1.5; } +.app-prompt-control { width:100%; } +.app-prompt-actions > :first-child { margin-right:auto; } +.print-frame { position:fixed; right:0; bottom:0; width:1px; height:1px; border:0; opacity:0; pointer-events:none; } +.headers-card { width:min(820px,calc(100vw - 32px)); max-height:min(720px,calc(100dvh - 32px)); } +.headers-content { margin:0; padding:14px; overflow:auto; white-space:pre-wrap; overflow-wrap:anywhere; background:var(--field); border-top:1px solid var(--line); border-bottom:1px solid var(--line); font:11px/1.55 var(--font-mono); } +.context-menu { position:fixed; z-index:1000; min-width:210px; max-width:300px; padding:4px; border:1px solid var(--line-strong); border-radius:var(--radius-control); background:var(--panel-2); box-shadow:0 12px 28px var(--shadow); } +.context-menu button { width:100%; min-height:30px; display:flex; align-items:center; gap:8px; padding:5px 8px; border:0; border-radius:var(--radius-control); background:transparent; color:var(--text); text-align:left; } +.context-menu button:hover { background:var(--hover); } +.context-menu button.danger { color:var(--danger); } +.context-menu .separator { height:1px; margin:4px; background:var(--line); } +.identity-overview{display:grid;gap:10px;min-width:0}.identity-overview-hint{display:grid;gap:3px;padding:11px 12px;border:1px solid var(--line);border-radius:var(--radius-control);background:var(--panel-2)} +.identity-list{min-width:0;max-height:430px;overflow:auto;border:1px solid var(--line);border-radius:var(--radius-control);background:var(--field)} +.identity-item{width:100%;min-width:0;display:block;padding:10px 11px;border:0;border-bottom:1px solid var(--line);background:transparent;color:var(--text);text-align:left} +.identity-item:last-child{border-bottom:0}.identity-item:hover,.identity-item.active{background:var(--hover)}.identity-item strong,.identity-item small{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.identity-item small{margin-top:2px;color:var(--muted)} +.identity-signature-field{min-width:0}.signature-editor{min-height:150px;padding:9px;border:1px solid var(--line-strong);border-radius:var(--radius-control);background:var(--field);overflow:auto} + +.dialog.identity-dialog{width:min(980px,calc(100vw - 28px));max-width:none;overflow:hidden}.identity-card{width:100%;height:min(820px,calc(100dvh - 28px));max-height:calc(100dvh - 28px);display:grid;grid-template-rows:auto minmax(0,1fr) auto;overflow:hidden} +.identity-form-body{min-width:0;min-height:0;overflow-y:auto;overflow-x:hidden;display:grid;align-content:start;gap:12px;padding:14px}.identity-modal-section{min-width:0;display:grid;gap:11px;padding:13px;border:1px solid var(--line);border-radius:var(--radius-panel);background:var(--panel-2)} +.identity-modal-heading{display:grid;gap:2px}.identity-modal-heading>span{line-height:1.4}.identity-fields-grid,.identity-options-grid,.identity-toggle-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px 14px}.identity-field-wide{grid-column:1 / -1} +.identity-signature-grid{display:grid;grid-template-columns:minmax(240px,.8fr) minmax(360px,1.2fr);gap:14px;min-width:0}.identity-signature-grid>label{min-width:0}.identity-signature-grid textarea{min-height:210px;resize:vertical}.identity-signature-grid .signature-editor-shell .signature-editor{min-height:210px;max-height:360px} +.identity-options-grid{margin-top:2px}.identity-toggle-grid .checkbox-row{min-width:0}.identity-dialog-actions{padding:9px 12px}.identity-dialog-actions #identity-delete{margin-right:auto} +.notification-settings { display:grid; gap:14px; max-width:620px; } +.notification-folder-list { display:grid; grid-template-columns:repeat(2,minmax(180px,1fr)); gap:7px 14px; max-height:220px; overflow:auto; padding:9px; border:1px solid var(--line); background:var(--field); } +.contacts-card, .calendar-card { width:min(900px,calc(100vw - 32px)); min-height:560px; max-height:calc(100dvh - 32px); } +.contacts-layout { display:grid; grid-template-columns:280px minmax(0,1fr); min-height:500px; } +.contacts-sidebar { border-right:1px solid var(--line); padding:10px; background:var(--panel-2); } +.contact-list { margin-top:10px; max-height:430px; overflow:auto; border:1px solid var(--line); background:var(--field); } +.contact-item { width:100%; display:block; padding:8px 9px; border:0; border-bottom:1px solid var(--line); background:transparent; color:var(--text); text-align:left; } +.contact-item:hover, .contact-item.active { background:var(--hover); } +.contact-item small { display:block; color:var(--muted); margin-top:2px; } +.contact-form { display:grid; align-content:start; gap:12px; padding:16px; } +.calendar-toolbar { display:grid; grid-template-columns:auto 1fr auto auto; align-items:center; gap:8px; padding:10px 12px; border-bottom:1px solid var(--line); } +.calendar-toolbar strong { text-align:center; } +.calendar-events { max-height:360px; overflow:auto; padding:10px 12px; } +.calendar-event { width:100%; display:grid; grid-template-columns:150px 1fr auto; gap:10px; align-items:center; padding:8px 9px; border:0; border-bottom:1px solid var(--line); background:transparent; color:var(--text); text-align:left; } +.calendar-event:hover { background:var(--hover); } +.calendar-event time, .calendar-event small { color:var(--muted); } +.calendar-form { display:grid; gap:12px; padding:14px 16px; border-top:1px solid var(--line); background:var(--panel-2); } +@media (max-width:720px) { .contacts-layout { grid-template-columns:1fr; } .contacts-sidebar { border-right:0; border-bottom:1px solid var(--line); } .calendar-event { grid-template-columns:1fr; } } + +.advanced-search-card { width:min(760px,calc(100vw - 32px)); max-height:calc(100dvh - 32px); } +.advanced-search-form { display:grid; gap:16px; padding:16px; overflow:auto; } +.advanced-search-grid { grid-template-columns:repeat(2,minmax(220px,1fr)); max-width:none; } +.advanced-search-grid .wide-field { grid-column:1 / -1; } +.saved-search-section { border-top:1px solid var(--line); padding-top:12px; } +.saved-search-list { display:grid; gap:4px; max-height:160px; overflow:auto; } +.saved-search-item { display:grid; grid-template-columns:minmax(0,1fr) auto auto; align-items:center; gap:6px; min-height:32px; padding:4px 6px; border:1px solid var(--line); background:var(--field); } +.saved-search-item button { min-height:26px; } +.saved-search-item strong { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } +.advanced-search-actions { padding:0; border-top:0; } +@media (max-width:720px) { .advanced-search-grid { grid-template-columns:1fr; } .advanced-search-grid .wide-field { grid-column:auto; } } + +.tag-card { width:min(390px,calc(100vw - 32px)); } +.tag-form { display:grid; gap:12px; padding:16px; } + +.quota-summary { display:grid; gap:6px; max-width:520px; margin-top:16px; padding:10px 12px; border:1px solid var(--line); background:var(--field); } +.quota-summary > span { color:var(--muted); font-size:11px; } +.quota-track { height:6px; overflow:hidden; border-radius:999px; background:var(--panel-3); } +.quota-track span { display:block; width:0; height:100%; background:var(--accent); transition:width .16s ease; } + +.contact-group-toolbar { display:grid; grid-template-columns:minmax(0,1fr) auto auto auto; gap:5px; margin-bottom:8px; } +.contact-group-toolbar .button { padding-inline:7px; } +.contact-group-membership { display:grid; grid-template-columns:repeat(2,minmax(140px,1fr)); gap:5px 10px; margin-top:5px; padding:8px; border:1px solid var(--line); background:var(--field); max-height:130px; overflow:auto; } +.field-label { display:block; margin-bottom:4px; color:var(--muted); font-size:11px; } +@media (max-width:720px) { .contact-group-toolbar { grid-template-columns:1fr auto; } .contact-group-toolbar #contact-group-rename, .contact-group-toolbar #contact-group-delete { grid-row:2; } } + +.sieve-rules-overview { display:grid; gap:8px; padding:11px; border:1px solid var(--line); border-radius:var(--radius-panel); background:var(--panel-2); } +.sieve-section-heading { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; } +.sieve-section-heading > div { display:grid; gap:2px; } +.sieve-section-heading small { display:block; line-height:1.35; } +.sieve-count-badge, .sieve-status-pill { display:inline-flex; align-items:center; min-height:22px; padding:2px 8px; border:1px solid var(--line); border-radius:999px; background:var(--field); color:var(--muted); font-size:10px; white-space:nowrap; } +.sieve-status-pill.ok { border-color:color-mix(in srgb,var(--success) 45%,var(--line)); color:var(--success); } +.sieve-status-pill.error { border-color:color-mix(in srgb,var(--danger) 45%,var(--line)); color:var(--danger); } +.sieve-builder { overflow:hidden; border:1px solid var(--line); border-radius:var(--radius-panel); background:var(--panel-2); } +.sieve-builder > summary { cursor:pointer; padding:9px 11px; font-weight:650; user-select:none; } +.sieve-builder[open] > summary { border-bottom:1px solid var(--line); } +.sieve-builder-body { display:grid; gap:0; } +.sieve-builder-section { display:grid; gap:8px; padding:11px; border-bottom:1px solid var(--line); } +.sieve-builder-grid { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:8px; padding:0; } +.sieve-builder-grid-rule { grid-template-columns:minmax(220px,1.5fr) minmax(150px,1fr) minmax(170px,1fr); } +.sieve-builder label { min-width:0; } +.sieve-builder input, .sieve-builder select { width:100%; } +.sieve-builder .checkbox-row { align-self:end; min-height:var(--control-height); } +.sieve-builder-wide { grid-column:1 / -1; } +.sieve-builder-dynamic-list { display:grid; gap:7px; } +.sieve-dynamic-row { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:7px; align-items:end; padding:8px; border:1px solid var(--line); border-radius:var(--radius-control); background:var(--field); } +.sieve-dynamic-row .sieve-dynamic-remove { justify-self:start; } +.sieve-rule-preview { display:grid; gap:9px; margin:11px; padding:11px; border:1px solid var(--line); border-radius:var(--radius-control); background:var(--field); } +.sieve-rule-preview-text { display:grid; gap:5px; line-height:1.45; } +.sieve-rule-preview-text strong { color:var(--text); } +.sieve-rule-preview-code { max-height:180px; margin:0; padding:9px; overflow:auto; border:1px solid var(--line); border-radius:var(--radius-control); background:var(--panel); color:var(--muted); font:10px/1.45 var(--font-mono); white-space:pre-wrap; } +.sieve-builder-footer { display:flex; align-items:center; justify-content:space-between; gap:12px; padding:0 11px 11px; } +.sieve-builder-footer .inline-actions { padding:0; } +.sieve-raw-source { border:1px solid var(--line); border-radius:var(--radius-control); background:var(--panel-2); } +.sieve-raw-source > summary { cursor:pointer; padding:8px 10px; font-weight:600; } +.sieve-raw-source[open] > summary { border-bottom:1px solid var(--line); } +.sieve-raw-source p { margin:8px 10px 0; } +.sieve-raw-source .code-editor { width:calc(100% - 20px); min-height:260px; margin:8px 10px 10px; resize:vertical; } +.sieve-result { min-height:18px; } +@media (max-width:900px) { .sieve-builder-grid, .sieve-builder-grid-rule, .sieve-dynamic-row { grid-template-columns:1fr 1fr; } } +@media (max-width:620px) { .sieve-builder-grid, .sieve-builder-grid-rule, .sieve-dynamic-row { grid-template-columns:1fr; } .sieve-builder-footer, .sieve-section-heading { align-items:stretch; flex-direction:column; } } +.sieve-rule-launch{display:flex;align-items:center;gap:10px;flex-wrap:wrap;padding:10px 11px;border:1px dashed var(--line-strong);border-radius:var(--radius-control);background:var(--panel-2)}.sieve-rule-launch .muted{flex:1 1 260px;line-height:1.4} +.dialog.sieve-rule-dialog{width:min(1040px,calc(100vw - 28px));max-width:none;overflow:hidden}.sieve-rule-card{width:100%;height:min(900px,calc(100dvh - 28px));max-height:calc(100dvh - 28px);display:grid;grid-template-rows:auto minmax(0,1fr);overflow:hidden}.sieve-rule-card>.dialog-header>div:first-child{display:grid;gap:2px;min-width:0}.sieve-rule-dialog-subtitle{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:10px}.sieve-rule-dialog-body{min-width:0;min-height:0;overflow-y:auto;overflow-x:hidden;padding:12px;background:var(--panel)} +.sieve-builder-modal{overflow:visible}.sieve-builder-modal .sieve-builder-body{overflow:visible}.sieve-builder-modal .sieve-builder-section{padding:14px}.sieve-builder-modal .sieve-rule-preview{margin:14px}.sieve-builder-modal .sieve-builder-footer{position:sticky;bottom:-12px;z-index:3;padding:10px 14px;border-top:1px solid var(--line);background:var(--panel-2);box-shadow:0 -8px 18px color-mix(in srgb,var(--shadow) 45%,transparent)} +.contact-transfer { display: flex; gap: 6px; flex-wrap: wrap; margin: 6px 0; } + +/* Diagnostics and mailbox behavior */ +.diagnostics-grid { + display: grid; + gap: 12px; +} +.diagnostic-card { + border: 1px solid var(--line); + border-radius: var(--radius-panel); + background: var(--panel-2); + padding: 12px; +} +.diagnostic-card .section-heading { + margin: 0 0 10px; +} +.capability-grid { + display: flex; + flex-wrap: wrap; + gap: 6px; +} +.capability-badge { + display: inline-flex; + align-items: center; + gap: 6px; + min-height: 26px; + padding: 3px 7px; + border: 1px solid var(--line); + border-radius: var(--radius-control); + background: var(--panel-3); + font-size: 12px; +} +.capability-badge strong { + font-size: 11px; + font-weight: 650; +} +.capability-badge.supported strong { color: var(--success); } +.capability-badge.unsupported { color: var(--muted); } +.capability-list { + margin-top: 10px; + color: var(--muted); + font-size: 12px; + line-height: 1.55; + overflow-wrap: anywhere; +} + +.account-settings-list { + display: grid; + gap: 8px; +} +.account-settings-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; + gap: 8px; + align-items: center; + padding: 10px; + border: 1px solid var(--line); + border-radius: var(--radius-control); + background: var(--panel-2); +} +.account-settings-row .account-email { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.account-settings-row.active .account-email { font-weight: 650; } +.add-account-card { width: min(440px, calc(100vw - 28px)); } + +.contact-photo-editor { display: grid; grid-template-columns: 72px minmax(0, 1fr); gap: 10px; align-items: center; padding-bottom: 8px; border-bottom: 1px solid var(--line); } +.contact-photo-preview, .contact-photo-placeholder { width: 72px; height: 72px; border: 1px solid var(--line); border-radius: var(--radius-panel); background: var(--panel-2); } +.contact-photo-preview { object-fit: cover; } +.contact-photo-placeholder { display: grid; place-items: center; color: var(--muted); font-size: 12px; } +.contact-photo-editor .inline-actions { align-self: center; } + +.folder-properties-card { width: min(520px, calc(100vw - 28px)); } +.folder-properties-list { display: grid; padding: 10px 14px 14px; } +.folder-property-row { display: grid; grid-template-columns: 120px minmax(0, 1fr); gap: 12px; padding: 8px 0; border-bottom: 1px solid var(--line); } +.folder-property-row:last-child { border-bottom: 0; } +.folder-property-row strong { overflow-wrap: anywhere; font-weight: 550; } + +.account-session-heading { + margin-top: 22px; +} + +.account-session-details { + min-width: 0; + display: grid; + gap: 3px; +} + +.account-session-details .muted { + font-size: 0.82rem; +} +.duplicates-card { width: min(720px, calc(100vw - 32px)); } +.duplicate-contact-list { display: grid; gap: 12px; max-height: 60vh; overflow: auto; } +.duplicate-contact-group { display: grid; gap: 8px; padding: 12px; border: 1px solid var(--border); border-radius: var(--radius); } +.duplicate-contact-options { display: grid; gap: 6px; } +.duplicate-contact-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding-top: 6px; border-top: 1px solid var(--border); } +.duplicate-contact-summary { display: grid; min-width: 0; } + +/* Calendar views */ +.calendar-toolbar { display:flex; flex-wrap:wrap; align-items:center; gap:8px; } +.calendar-toolbar #calendar-range { flex:1 1 220px; text-align:center; } +.calendar-toolbar #calendar-view { min-width:100px; } +.calendar-events { max-height:440px; } +.calendar-grid { display:grid; grid-template-columns:repeat(7,minmax(0,1fr)); border-top:1px solid var(--line); border-left:1px solid var(--line); } +.calendar-weekday { padding:6px 8px; border-right:1px solid var(--line); border-bottom:1px solid var(--line); color:var(--muted); font-size:11px; font-weight:600; text-transform:uppercase; letter-spacing:.04em; } +.calendar-day-cell { min-height:110px; padding:5px; border-right:1px solid var(--line); border-bottom:1px solid var(--line); background:var(--panel); overflow:hidden; } +.calendar-week-grid .calendar-day-cell { min-height:260px; } +.calendar-day-cell.outside-month { opacity:.52; } +.calendar-day-cell.today { box-shadow:inset 0 0 0 1px var(--accent); } +.calendar-day-number { width:28px; height:24px; border:0; background:transparent; color:var(--muted); text-align:center; border-radius:var(--radius); cursor:pointer; } +.calendar-day-number:hover { background:var(--hover); color:var(--text); } +.calendar-event-chip { width:100%; display:grid; grid-template-columns:auto minmax(0,1fr); gap:5px; align-items:center; margin-top:4px; padding:4px 5px; border:0; border-left:3px solid var(--event-color); background:var(--panel-2); color:var(--text); text-align:left; cursor:pointer; } +.calendar-event-chip:hover { background:var(--hover); } +.calendar-event-chip span { color:var(--muted); font-size:10px; white-space:nowrap; } +.calendar-event-chip strong { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:11px; } +.calendar-more { display:block; margin-top:4px; color:var(--muted); font-size:10px; } +.calendar-agenda-day { display:grid; gap:4px; margin-bottom:12px; } +.calendar-agenda-day h4 { margin:0; padding:7px 4px; border-bottom:1px solid var(--line); font-size:12px; } +.calendar-event { border-left:3px solid var(--event-color, var(--accent)); } +@media (max-width:720px) { + .calendar-grid { grid-template-columns:1fr; border-left:0; } + .calendar-weekday { display:none; } + .calendar-day-cell { min-height:auto; border-left:1px solid var(--line); } + .calendar-day-cell.outside-month { display:none; } + .calendar-toolbar #calendar-range { order:-1; flex-basis:100%; } +} + +/* 0.0.1 UI reliability pass: viewport-safe dialogs, toasts and calendar layout */ +.dialog { + max-width: calc(100vw - 16px); + max-height: calc(100dvh - 16px); + overflow: auto; + overscroll-behavior: contain; + scrollbar-gutter: auto; +} +.dialog-card { + max-width: 100%; + max-height: calc(100dvh - 18px); + min-height: 0; + overflow: auto; + overscroll-behavior: contain; + scrollbar-gutter: auto; +} +/* Grid dialogs own their internal scroll regions; simple/legacy dialogs fall + back to scrolling the complete card so actions can never be trapped below + the viewport. */ +.compose-card, +.settings-card, +.identity-card, +.sieve-rule-card, +.contacts-card, +.calendar-card, +.headers-card, +.source-card, +.shortcuts-card, +.attachment-preview-card, +.app-prompt-card, +.quick-filter-card { overflow: hidden; } +.settings-card { + min-height: 0; + height: min(720px, calc(100dvh - 18px)); + display: grid; + grid-template-rows: auto minmax(0, 1fr); +} +.settings-layout { min-height: 0; height: 100%; overflow:hidden; } +.dialog-header { + min-height: var(--dialog-header-height, 43px); + flex: 0 0 auto; + background: var(--panel); +} +.dialog-actions { + flex: 0 0 auto; + margin: 0; + background: var(--panel); + position: relative; + z-index: 2; +} + +.contacts-card, +.calendar-card { + width: min(980px, calc(100vw - 32px)); + height: min(780px, calc(100dvh - 32px)); + min-height: 0; + max-height: calc(100dvh - 32px); + display: grid; + overflow: hidden; +} +.contacts-card { grid-template-rows: auto minmax(0, 1fr); } +.contacts-layout { + min-height: 0; + height: 100%; + overflow: hidden; + grid-template-columns: minmax(240px, 300px) minmax(0, 1fr); +} +.contacts-sidebar { + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} +.contact-list { + min-height: 120px; + max-height: none; + flex: 1 1 auto; + overflow: auto; +} +.contact-form { + min-height: 0; + overflow: auto; + overscroll-behavior: contain; + padding-bottom: 0; +} +.contact-form > .inline-actions:last-child { + position: sticky; + bottom: 0; + z-index: 3; + margin: 0 -16px; + padding: 10px 16px; + border-top: 1px solid var(--line); + background: var(--panel); +} + +.calendar-card { grid-template-rows: auto auto minmax(0, 1fr) auto; } +.calendar-toolbar { + display: grid; + grid-template-columns: auto minmax(180px, 1fr) auto auto auto auto auto; + align-items: center; + gap: 6px; + padding: 8px 10px; + overflow-x: auto; + scrollbar-width: thin; +} +.calendar-toolbar #calendar-range { min-width: 0; text-align: center; } +.calendar-toolbar #calendar-view { min-width: 96px; } +.calendar-events { + min-height: 0; + max-height: none; + overflow: auto; + padding: 0; + overscroll-behavior: contain; +} +.calendar-grid { + width: 100%; + min-width: 680px; + display: grid !important; + grid-template-columns: repeat(7, minmax(86px, 1fr)) !important; + grid-auto-flow: row; + align-items: stretch; +} +.calendar-weekday { min-width: 0; } +.calendar-day-cell { min-width: 0; } +.calendar-form { + min-height: 0; + max-height: min(48dvh, 430px); + overflow: auto; + overscroll-behavior: contain; +} + +.headers-card, +.source-card { + width: min(1040px, calc(100vw - 28px)); + height: min(82dvh, 860px); + max-height: calc(100dvh - 28px); + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto; + overflow: hidden; +} +.headers-content, +.message-source-content { + min-height: 0; + max-height: none; + overflow: auto; + overscroll-behavior: contain; +} + +.toast-stack { + z-index: 2147483000; + pointer-events: none; +} +.toast { + min-height: 42px; + display: grid; + grid-template-columns: 20px minmax(0, 1fr) auto auto; + align-items: center; + gap: 8px; + padding: 8px 8px 8px 10px; + pointer-events: auto; + opacity: 0; + transform: translateY(6px); + transition: opacity .15s ease, transform .15s ease; +} +.toast.visible { opacity: 1; transform: translateY(0); } +.toast-icon { display: grid; place-items: center; color: var(--accent); } +.toast-icon svg { width: 17px; height: 17px; } +.toast-message { min-width: 0; overflow-wrap: anywhere; } +.toast-countdown { min-width: 24px; color:var(--subtle); font:10px var(--font-mono); text-align:right; } +.toast-controls { display:flex; align-items:center; justify-content:flex-end; gap:2px; white-space:nowrap; } +.toast-action { border:0; border-radius:var(--radius-control); background:transparent; color:var(--accent); padding:4px 6px; font-weight:700; font-size:10px; cursor:pointer; } +.toast-action:hover, .toast-action:focus-visible { background:var(--hover); color:var(--text); outline:none; } +.toast-close { + width: 24px; + height: 24px; + display: grid; + place-items: center; + border: 0; + border-radius: var(--radius-control); + background: transparent; + color: var(--muted); +} +.toast-close:hover { background: var(--hover); color: var(--text); } +.toast-close svg { width: 14px; height: 14px; } +.toast-success { border-left-color: var(--success); } +.toast-success .toast-icon { color: var(--success); } +.toast-error { border-left-color: var(--danger); } +.toast-error .toast-icon { color: var(--danger); } +.toast-warning { border-left-color: var(--warning); } +.toast-warning .toast-icon { color: var(--warning); } +.toast-info { border-left-color: var(--accent); } + +@media (max-width: 720px) { + .contacts-card, .calendar-card { width: calc(100vw - 12px); height: calc(100dvh - 12px); max-height: calc(100dvh - 12px); } + .contacts-layout { grid-template-columns: 1fr; grid-template-rows: minmax(180px, 34%) minmax(0, 1fr); overflow: hidden; } + .contacts-sidebar { border-right: 0; border-bottom: 1px solid var(--line); } + .contact-form { padding: 12px; } + .contact-form > .inline-actions:last-child { margin: 0 -12px; padding: 9px 12px; } + .calendar-toolbar { grid-template-columns: auto minmax(150px, 1fr) auto auto; } + .calendar-toolbar #calendar-import, .calendar-toolbar #calendar-export, .calendar-toolbar #calendar-new { grid-row: 2; } + .calendar-grid { min-width: 0; grid-template-columns: 1fr !important; border-left: 0; } + .calendar-weekday { display: none; } + .calendar-day-cell { border-left: 1px solid var(--line); } + .calendar-day-cell.outside-month { display: none; } +} +.dialog-header .inline-actions { margin-top: 0; } +.headers-card { grid-template-rows: auto auto minmax(0, 1fr) auto; } +.headers-summary { + max-height: min(30dvh, 240px); + overflow: auto; + padding: 8px 14px; + border-bottom: 1px solid var(--line); + background: var(--panel); +} +.header-summary-row { + display: grid; + grid-template-columns: 130px minmax(0, 1fr); + gap: 12px; + padding: 5px 0; + border-bottom: 1px solid color-mix(in srgb, var(--line) 70%, transparent); +} +.header-summary-row:last-child { border-bottom: 0; } +.header-summary-row code { min-width: 0; overflow-wrap: anywhere; white-space: pre-wrap; font: 10px/1.45 var(--font-mono); } +@media (max-width: 560px) { .header-summary-row { grid-template-columns: 1fr; gap: 2px; } } + +/* Dialog/layout hardening */ +.contacts-card, +.calendar-card, +.headers-card, +.source-card { + box-sizing: border-box; + contain: layout paint; +} +.contacts-card { + grid-template-rows: auto minmax(0, 1fr); +} +.contact-form { + scrollbar-gutter: auto; + padding-bottom: 12px; +} +.contact-form > .inline-actions:last-child { + box-shadow: 0 -1px 0 var(--line); +} +.calendar-card { + grid-template-rows: auto auto minmax(0, 1fr) auto; +} +.calendar-events { + width: 100%; + min-width: 0; + overflow: auto; + scrollbar-gutter: auto; +} +.calendar-grid { + min-width: 700px; + grid-template-columns: repeat(7, minmax(96px, 1fr)) !important; +} +.calendar-week-grid { min-width: 700px; } +.calendar-form:not(.hidden) { + border-top: 1px solid var(--line); + scrollbar-gutter: auto; +} +.headers-content, +.message-source-content { + scrollbar-gutter: auto; +} +.source-card .dialog-header .button { margin-left: auto; } +.reader-toolbar { + min-width: 0; + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: thin; +} +.duplicate-contact-group { border-color: var(--line); border-radius: var(--radius-panel); } +.duplicate-contact-row { border-color: var(--line); } + +@media (max-width: 720px) { + .calendar-grid, + .calendar-week-grid { min-width: 0; grid-template-columns: 1fr !important; } +} + +.shortcuts-card { width:min(680px, calc(100vw - 32px)); max-height:calc(100dvh - 32px); display:grid; grid-template-rows:auto minmax(0,1fr) auto; } +.shortcuts-grid { overflow:auto; padding:18px; display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:24px; } +.shortcuts-grid h3 { margin:0 0 10px; font-size:12px; } +.shortcuts-grid dl { margin:0; display:grid; grid-template-columns:minmax(92px,max-content) minmax(0,1fr); gap:7px 14px; align-items:center; } +.shortcuts-grid dt, .shortcuts-grid dd { margin:0; } +kbd { display:inline-block; min-width:24px; padding:2px 6px; border:1px solid var(--line); border-bottom-width:2px; border-radius:var(--radius-control); background:var(--field); color:var(--text); font:10px var(--font-mono); text-align:center; } +@media (max-width:620px) { .shortcuts-grid { grid-template-columns:1fr; } } + +.quota-summary.quota-warning { border-color:color-mix(in srgb, var(--warning) 55%, var(--line)); } +.quota-summary.quota-warning .quota-track span { background:var(--warning); } +.quota-summary.quota-danger { border-color:color-mix(in srgb, var(--danger) 62%, var(--line)); } +.quota-summary.quota-danger .quota-track span { background:var(--danger); } + +@media (max-width: 900px) { .appearance-presets { grid-template-columns:repeat(2,minmax(0,1fr)); } } +@media (max-width: 520px) { .appearance-presets { grid-template-columns:1fr; } } + +.sieve-managed-rules { display:grid; gap:7px; } +.sieve-managed-rule { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px 12px; padding:9px 10px; border:1px solid var(--line); border-radius:var(--radius-control); background:var(--panel); } +.sieve-managed-rule.selected { border-color:var(--accent); box-shadow:inset 3px 0 0 var(--accent); } +.sieve-managed-rule.disabled { opacity:.68; } +.sieve-managed-rule.unsupported { border-style:dashed; } +.sieve-managed-rule-main { display:grid; gap:5px; min-width:0; } +.sieve-managed-rule-title { display:flex; align-items:center; gap:7px; min-width:0; } +.sieve-managed-rule-title strong { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } +.sieve-rule-state { flex:0 0 auto; padding:1px 6px; border:1px solid var(--line); border-radius:999px; color:var(--muted); font-size:9px; text-transform:uppercase; letter-spacing:.04em; } +.sieve-rule-state.enabled { color:var(--success); border-color:color-mix(in srgb,var(--success) 40%,var(--line)); } +.sieve-managed-rule-summary { display:grid; gap:2px; color:var(--muted); font-size:11px; line-height:1.35; } +.sieve-managed-rule-summary b { color:var(--text); font-weight:600; } +.sieve-managed-rule-actions { display:flex; align-items:flex-start; gap:3px; flex-wrap:wrap; justify-content:flex-end; } +.sieve-managed-rule-actions button { min-width:27px; min-height:25px; padding:2px 6px; } +.sieve-rule-warning { color:var(--warning); font-size:10px; } +@media (max-width:720px) { .sieve-managed-rule { grid-template-columns:1fr; } .sieve-managed-rule-actions { justify-content:flex-start; } } + +.quick-filter-dialog { width:min(700px, calc(100vw - 32px)); } +.quick-filter-card { width:100%; max-height:min(720px, calc(100dvh - 32px)); } +.quick-filter-body { padding:16px; overflow:auto; } +.quick-filter-grid { display:grid; grid-template-columns:repeat(2, minmax(0, 1fr)); gap:12px; } +.quick-filter-grid label { min-width:0; } +.quick-filter-grid input, .quick-filter-grid select { width:100%; } +.quick-filter-toggles { margin-top:14px; } +.quick-filter-hint { min-height:20px; margin-top:10px; } +@media (max-width: 620px) { .quick-filter-grid { grid-template-columns:1fr; } } +.quick-filter-preview { margin-top:14px; padding:10px 12px; border:1px solid var(--line); border-radius:var(--radius-control); background:var(--panel-2); font-size:12px; line-height:1.45; overflow-wrap:anywhere; } +.appearance-advanced { margin-top:12px; border-top:1px solid var(--line); padding-top:10px; } +.appearance-advanced > summary { cursor:pointer; color:var(--text); font-weight:600; margin-bottom:10px; } +.pane-background-grid { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:8px; margin-top:10px; } +.pane-background-control { display:flex; align-items:center; justify-content:space-between; gap:10px; min-width:0; } +.pane-background-control > span { display:flex; align-items:center; gap:6px; } +@media (max-width:820px) { .pane-background-grid { grid-template-columns:1fr; } } + +/* OpenPGP settings */ +.pgp-settings-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}.pgp-settings-grid .settings-section{margin:0} +.pgp-key-list{display:grid;gap:8px}.pgp-key-card{display:grid;gap:8px;padding:12px;border:1px solid var(--border);border-radius:var(--radius);background:var(--panel)} +.pgp-key-head{display:flex;justify-content:space-between;gap:12px;align-items:start}.pgp-key-head>div{display:grid;gap:3px;min-width:0}.pgp-key-head small{color:var(--muted)} +.pgp-fingerprint{display:block;overflow-wrap:anywhere;font-size:.84em;color:var(--muted)} +@media(max-width:760px){.pgp-settings-grid{grid-template-columns:1fr}.compose-pgp-details{grid-template-columns:1fr}.compose-pgp-details .inline-actions{justify-content:flex-start}.pgp-reader-bar{align-items:flex-start;flex-direction:column}} + + +/* 60-responsive.css */ +/* Dedicated full-screen composer mode. */ +.compose-dialog.compose-fullscreen{width:100vw;height:100dvh;max-width:none;max-height:none;margin:0;border:0;border-radius:0;overflow:hidden} +.compose-dialog.compose-fullscreen .compose-card{width:100%;height:100dvh;max-width:none;max-height:none;border-radius:0} + +html[data-preview="off"] .mail-shell { grid-template-columns: var(--folder-pane-width) minmax(360px,1fr); } +html[data-preview="off"] .reader-pane { display: none; } +html[data-preview="bottom"] .mail-shell { grid-template-columns: var(--folder-pane-width) minmax(0,1fr); grid-template-rows: minmax(260px, 44%) minmax(0,1fr); } +html[data-preview="bottom"] .folder-pane { grid-row: 1 / 3; } +html[data-preview="bottom"] .message-pane { grid-column: 2; grid-row: 1; border-right: 0; border-bottom: 1px solid var(--line); } +html[data-preview="bottom"] .reader-pane { grid-column: 2; grid-row: 2; } +@media (max-width: 1080px) { + .appbar { grid-template-columns: minmax(150px, min(22vw, var(--folder-pane-width))) minmax(220px,1fr) auto; } + .mail-shell { grid-template-columns: minmax(170px, min(24vw, var(--folder-pane-width))) minmax(270px, min(39vw, var(--message-pane-width))) minmax(320px,1fr); } + .account-summary { display: none; } +} +@media (max-width: 820px) { + .mobile-only { display: inline-grid; } + .appbar { grid-template-columns: auto minmax(0,1fr) auto; } + .app-brand strong { display: none; } + .mail-shell { display: block; position: relative; } + .folder-pane { position: absolute; z-index: 20; top: 0; bottom: 0; left: 0; width: 250px; transform: translateX(-100%); transition: transform .15s ease; box-shadow: 12px 0 28px var(--shadow); } + .folder-pane.open { transform: translateX(0); } + .message-pane { height: 100%; border-right: 0; } + .reader-pane { display: none; position: absolute; z-index: 15; inset: 0; } + .reader-pane.mobile-open { display: block; } + html[data-preview="off"] .reader-pane.mobile-open { display: block; } + .message-header { padding: 16px; } + .thread-section, .attachment-list { padding-left: 16px; padding-right: 16px; } + .settings-layout { grid-template-columns: 120px minmax(0,1fr); } + .sieve-layout { grid-template-columns: 1fr; } +} +@media (max-width: 560px) { + .appbar { gap: 5px; padding: 0 6px; } + .search-field kbd { display: none; } + .appbar-actions .icon-button:first-child { display: none; } + .dialog { width: calc(100vw - 12px); max-width: none; } + .compose-dialog { width: calc(100vw - 12px); } + .compose-card { height: calc(100dvh - 12px); } + .dialog-card { min-width: 0; } + .settings-dialog { width: calc(100vw - 12px); } + .settings-layout { grid-template-columns: 1fr; } + .settings-nav { flex-direction: row; flex-wrap:nowrap; overflow-x:auto; overflow-y:hidden; border-right: 0; border-bottom: 1px solid var(--line); } + .settings-tab { flex:0 0 auto; text-align: center; white-space:nowrap; } + .settings-grid { grid-template-columns: 1fr; } + .reader-toolbar .button span { display: none; } + .reader-toolbar .move-control { display: none; } +} +@media (max-width:760px){ + .identity-dialog,.sieve-rule-dialog{width:calc(100vw - 12px);max-width:none} + .identity-card,.sieve-rule-card{height:calc(100dvh - 12px);max-height:calc(100dvh - 12px)} + .identity-signature-grid,.identity-fields-grid,.identity-options-grid,.identity-toggle-grid{grid-template-columns:1fr} + .identity-field-wide{grid-column:auto} + .sieve-rule-dialog-body{padding:7px} +} +@media (max-width:560px){ + .compose-dialog.compose-fullscreen{width:100vw;height:100dvh;max-width:none;max-height:none} + .compose-dialog.compose-fullscreen .compose-card{height:100dvh} +} +@media (prefers-reduced-motion: reduce) { + * { scroll-behavior: auto !important; transition: none !important; animation: none !important; } +} + diff --git a/ui/app.js b/ui/app.js new file mode 100644 index 0000000..f08b88c --- /dev/null +++ b/ui/app.js @@ -0,0 +1,7048 @@ +(() => { + "use strict"; + + const root = document.documentElement; + const configuredBase = root.dataset.basePath || "/"; + const BASE = configuredBase === "/" ? "" : configuredBase.replace(/\/$/, ""); + const API = `${BASE}/api`; + const ICONS = `${BASE}/assets/icons.svg`; + + const state = { + accounts: [], + accountId: localStorage.getItem("rustbox.activeAccount") || null, + folders: [], + folder: "INBOX", + collapsedFolders: new Set(), + page: 0, + perPage: 50, + total: 0, + messages: [], + selected: null, + selectedMessages: new Set(), + selectionMode: false, + searchContext: null, + searchAllResults: null, + currentDetail: null, + specialFolders: { sent: null, drafts: null, trash: null, junk: null, archive: null }, + tags: [], + sieveScripts: [], + sieveInfo: null, + sieveEditingRuleId: null, + identities: [], + pgpKeys: [], + pgpPrefs: { wkdEnabled: true, hkpEnabled: false, requireTrustedEncryption: false }, + pgpRecipientStatus: [], + authPolicy: { rememberMeEnabled: true, rememberMeDays: 30, sessionTimeoutHours: 24 }, + contacts: [], + contactGroups: [], + contactPhotoUrl: null, + calendarCursor: new Date(), + calendarView: localStorage.getItem("rustbox.calendarView") || "month", + threadExpanded: localStorage.getItem("rustbox.threadExpanded") !== "false", + calendarEvents: [], + savedSearches: [], + notificationPrefs: { enabled: true, sound: false, folders: ["INBOX"], dnd_enabled: false, dnd_start: "22:00", dnd_end: "07:00" }, + mailPrefs: { messages_per_page: 50, mark_read_delay_ms: 0, remote_images: "ask", preferred_body: "auto", thread_mode: "threaded", collapse_quotes: true, show_snippets: true, default_sort: "date_desc", flagged_on_top: false, sync_imap_keywords: false }, + currentFolderFlaggedOnTop: false, + markReadTimer: null, + ws: null, + wsRetryTimer: null, + realtimeRefreshTimer: null, + composeDraftId: null, + composeAttachments: [], + composeDirty: false, + composeNeedsRemoteSync: false, + composeUploading: false, + composeReplyMeta: null, + composeInsertRange: null, + composeSelectedImage: null, + composeSignatureGeneration: 0, + identitySignatureRange: null, + inlineAttachmentIds: new Set(), + inlineImageCleanupTimer: null, + imageEditorBitmap: null, + imageEditorBlob: null, + draftSaveTimer: null, + prefs: { + theme: "system", density: "compact", preview: "right", scale: "normal", language: "en", composeFormat: "html", + composeAutosaveMs: 3000, defaultPriority: "normal", requestMdnDefault: false, attachmentReminder: true, composeFontFamily: "system", replyPosition: "above", quoteStyle: "standard", forwardMode: "inline", spellcheckEnabled: true, + colorPreset: "sage", accentColor: "#6f9c86", backgroundColor: "#121513", panelColor: "#181c19", textColor: "#edf0ec", fontFamily: "system", fontSize: 13, + rowHeight: 58, folderPaneWidth: 228, messagePaneWidth: 420, borderRadius: 4, + contrast: "normal", showAvatars: true, showAttachmentIcons: true, showFlags: true, reduceMotion: false, dateFormat: "locale", timeFormat: "locale", + iconSize: 18, toolbarDensity: "normal", sidebarItemHeight: 29, readerMaxWidth: 0, composerWidth: 900, selectionColor: "#28352e", unreadFontWeight: 730, unreadBadgeStyle: "dot", compactHeaders: false, showBorders: true, scrollbarDensity: "thin", monochrome: false, folderBackground: "", messageBackground: "", readerBackground: "", + }, + translations: {}, + quotaWarningLevel: null, + networkOnline: navigator.onLine, + listStateTimer: null, + }; + + const $ = (selector, parent = document) => parent.querySelector(selector); + const $$ = (selector, parent = document) => [...parent.querySelectorAll(selector)]; + const el = (tag, className, text) => { + const node = document.createElement(tag); + if (className) node.className = className; + if (text !== undefined) node.textContent = text; + return node; + }; + + function svgIcon(name) { + return ``; + } + + function escapeHtml(value) { + return String(value ?? "") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); + } + + function stripRustBoxUiLeak(value) { + let text = String(value ?? ""); + // Strip only RustBox's own embedded SVG sprite fragments if malformed MIME + // content captured adjacent UI markup. Legitimate SVG/code in mail remains. + text = text.replace(/]*>\s*]*(?:\/assets\/icons\.svg|assets\/icons\.svg)[^>]*><\/use>\s*<\/svg>/gi, ""); + text = text.replace(/<svg\b[\s\S]*?(?:\/assets\/icons\.svg|assets\/icons\.svg)[\s\S]*?<\/svg>/gi, ""); + return text; + } + + function cleanPlainMessageText(value) { + return stripRustBoxUiLeak(value); + } + + function sanitizeMessageHtml(html) { + const doc = new DOMParser().parseFromString(stripRustBoxUiLeak(html), "text/html"); + doc.querySelectorAll("script,iframe,frame,frameset,object,embed,applet,form,input,button,select,textarea,base,meta,link,svg,math").forEach((node) => node.remove()); + doc.querySelectorAll("*").forEach((node) => { + for (const attr of [...node.attributes]) { + const name = attr.name.toLowerCase(); + const value = attr.value.trim(); + if (name.startsWith("on") || ["srcdoc", "formaction"].includes(name)) node.removeAttribute(attr.name); + if (["href", "src", "background", "action", "poster"].includes(name) && /^\s*javascript:/i.test(value)) node.removeAttribute(attr.name); + } + }); + return doc.body.innerHTML; + } + + function apiPath(path) { + return `${API}${path.startsWith("/") ? path : `/${path}`}`; + } + + function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + function setConnectivityState(online, detail = "") { + state.networkOnline = Boolean(online); + document.body.classList.toggle("network-offline", !state.networkOnline); + const banner = $("#offline-banner"); + if (banner) { + banner.classList.toggle("hidden", state.networkOnline); + banner.textContent = detail || (navigator.onLine ? "RustBox cannot reach the server. Read-only cached content may still be visible." : "You are offline. RustBox will reconnect when the network returns."); + } + const label = $("#connection-label"); + if (!label) return; + if (!state.networkOnline) label.textContent = navigator.onLine ? "Reconnecting" : "Offline"; + else if (["Offline", "Reconnecting", "Connection error"].includes(label.textContent)) label.textContent = "Connected"; + } + + function listStateStorageKey(folder = state.folder) { + return `rustbox.listState.${state.accountId || "anonymous"}.${encodeURIComponent(folder || "INBOX")}`; + } + + function lastFolderStorageKey() { + return `rustbox.lastFolder.${state.accountId || "anonymous"}`; + } + + function activeSearchStorageKey() { + return `rustbox.activeSearch.${state.accountId || "anonymous"}`; + } + + function saveActiveSearchContext() { + if (!state.searchContext) return sessionStorage.removeItem(activeSearchStorageKey()); + sessionStorage.setItem(activeSearchStorageKey(), JSON.stringify(state.searchContext)); + } + + function readActiveSearchContext() { + try { + const parsed = JSON.parse(sessionStorage.getItem(activeSearchStorageKey()) || "null"); + return parsed && typeof parsed.params === "string" ? parsed : null; + } catch (_) { + return null; + } + } + + function clearActiveSearchContext() { + state.searchContext = null; + state.searchAllResults = null; + sessionStorage.removeItem(activeSearchStorageKey()); + } + + function readMessageListState(folder = state.folder) { + try { + const parsed = JSON.parse(localStorage.getItem(listStateStorageKey(folder)) || "null"); + if (!parsed || typeof parsed !== "object") return null; + return { + scrollTop: Math.max(0, Number(parsed.scrollTop) || 0), + page: Math.max(0, Number(parsed.page) || 0), + }; + } catch (_) { + return null; + } + } + + function saveMessageListState() { + if (!state.accountId) return; + const list = $("#message-list"); + if (!list) return; + if (state.searchContext) { + state.searchContext.scrollTop = list.scrollTop; + saveActiveSearchContext(); + return; + } + localStorage.setItem(listStateStorageKey(), JSON.stringify({ scrollTop:list.scrollTop, page:state.page })); + localStorage.setItem(lastFolderStorageKey(), state.folder); + } + + function scheduleMessageListStateSave() { + clearTimeout(state.listStateTimer); + state.listStateTimer = setTimeout(saveMessageListState, 120); + } + + async function request(path, options = {}) { + const headers = new Headers(options.headers || {}); + if (state.accountId) headers.set("X-Active-Account", state.accountId); + if (options.body && !(options.body instanceof FormData)) headers.set("Content-Type", "application/json"); + if (options.method && options.method !== "GET") headers.set("X-Requested-With", "XMLHttpRequest"); + + const method = String(options.method || "GET").toUpperCase(); + const maxAttempts = method === "GET" || method === "HEAD" ? 3 : 1; + let lastNetworkError = null; + for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + try { + const response = await fetch(apiPath(path), { + credentials: "include", + ...options, + headers, + }); + if ([502, 503, 504].includes(response.status) && attempt + 1 < maxAttempts) { + await sleep(250 * (attempt + 1)); + continue; + } + const contentType = response.headers.get("content-type") || ""; + const payload = contentType.includes("application/json") + ? await response.json().catch(() => ({})) + : await response.text().catch(() => ""); + if (!response.ok) { + const message = payload?.error?.message || (typeof payload === "string" && payload) || `Request failed (${response.status})`; + const error = new Error(message); + error.status = response.status; + throw error; + } + setConnectivityState(true); + return payload; + } catch (error) { + if (error?.status) throw error; + lastNetworkError = error; + setConnectivityState(false); + if (attempt + 1 < maxAttempts) { + await sleep(350 * (attempt + 1)); + continue; + } + } + } + const error = new Error(navigator.onLine ? "RustBox server is temporarily unreachable" : "You are offline"); + error.cause = lastNetworkError; + error.network = true; + throw error; + } + + function uploadRequest(path, formData, onProgress = null) { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open("POST", apiPath(path), true); + xhr.withCredentials = true; + if (state.accountId) xhr.setRequestHeader("X-Active-Account", state.accountId); + xhr.setRequestHeader("X-Requested-With", "XMLHttpRequest"); + if (typeof onProgress === "function") { + xhr.upload.addEventListener("progress", (event) => { + if (event.lengthComputable && event.total > 0) onProgress(Math.round((event.loaded / event.total) * 100)); + }); + } + xhr.addEventListener("error", () => reject(new Error("Upload failed: network error"))); + xhr.addEventListener("abort", () => reject(new Error("Upload cancelled"))); + xhr.addEventListener("load", () => { + const contentType = xhr.getResponseHeader("content-type") || ""; + let payload = xhr.responseText || ""; + if (contentType.includes("application/json")) { + try { payload = payload ? JSON.parse(payload) : {}; } catch (_) { payload = {}; } + } + if (xhr.status < 200 || xhr.status >= 300) { + const message = payload?.error?.message || (typeof payload === "string" && payload) || `Upload failed (${xhr.status})`; + const error = new Error(message); + error.status = xhr.status; + reject(error); + return; + } + resolve(payload); + }); + xhr.send(formData); + }); + } + + function toast(message, kind = "info", duration = null, action = null) { + const normalized = ["success", "error", "warning", "info"].includes(kind) ? kind : "info"; + const lifetime = Number(duration) > 0 ? Number(duration) : normalized === "error" ? 6500 : normalized === "warning" ? 5200 : 3800; + const node = el("div", `toast toast-${normalized}`); + node.setAttribute("role", normalized === "error" ? "alert" : "status"); + const iconName = normalized === "success" ? "check-circle" : normalized === "error" || normalized === "warning" ? "circle-alert" : "info"; + const icon = el("span", "toast-icon"); + icon.innerHTML = svgIcon(iconName); + const text = el("span", "toast-message", message); + let actionButton = null; + if (action?.label && typeof action.onClick === "function") { + actionButton = el("button", "toast-action", action.label); + actionButton.type = "button"; + } + const countdown = el("span", "toast-countdown"); + countdown.setAttribute("aria-hidden", "true"); + const close = el("button", "toast-close"); + close.type = "button"; + close.setAttribute("aria-label", "Dismiss notification"); + close.innerHTML = svgIcon("x"); + const controls = el("span", "toast-controls"); + if (actionButton) controls.append(actionButton); + controls.append(close); + node.append(icon, text, countdown, controls); + const openDialogs = $$("dialog[open]"); + let stack = $("#toast-stack"); + if (openDialogs.length) { + const dialog = openDialogs[openDialogs.length - 1]; + stack = dialog.querySelector(":scope > .modal-toast-stack"); + if (!stack) { + stack = el("div", "toast-stack modal-toast-stack"); + stack.setAttribute("aria-live", "polite"); + dialog.append(stack); + } + } + stack.append(node); + requestAnimationFrame(() => node.classList.add("visible")); + + let remaining = lifetime; + let startedAt = 0; + let timeout = null; + let ticker = null; + let dismissed = false; + const renderCountdown = () => { countdown.textContent = `${Math.max(1, Math.ceil(remaining / 1000))}s`; }; + const stopTimers = () => { clearTimeout(timeout); clearInterval(ticker); timeout = null; ticker = null; }; + const dismiss = (immediate = false) => { + if (dismissed) return; + dismissed = true; + stopTimers(); + if (immediate) return node.remove(); + node.classList.remove("visible"); + setTimeout(() => node.remove(), 160); + }; + const pause = () => { + if (dismissed || !startedAt) return; + remaining = Math.max(0, remaining - (performance.now() - startedAt)); + startedAt = 0; + stopTimers(); + renderCountdown(); + }; + const resume = () => { + if (dismissed || remaining <= 0) return dismiss(); + stopTimers(); + startedAt = performance.now(); + renderCountdown(); + timeout = setTimeout(() => dismiss(), remaining); + ticker = setInterval(() => { + if (!startedAt) return; + const elapsed = performance.now() - startedAt; + const visibleRemaining = Math.max(0, remaining - elapsed); + countdown.textContent = `${Math.max(1, Math.ceil(visibleRemaining / 1000))}s`; + }, 200); + }; + close.addEventListener("click", (event) => { + event.preventDefault(); + event.stopPropagation(); + dismiss(true); + }); + if (actionButton) { + actionButton.addEventListener("click", async (event) => { + event.preventDefault(); + event.stopPropagation(); + dismiss(true); + try { await action.onClick(); } + catch (error) { toast(error?.message || "Action failed", "error"); } + }); + } + node.addEventListener("mouseenter", pause); + node.addEventListener("mouseleave", resume); + node.addEventListener("focusin", pause); + node.addEventListener("focusout", (event) => { if (!node.contains(event.relatedTarget)) resume(); }); + resume(); + } + + function appDialog({ title = "RustBox", message = "", input = null, selectOptions = null, confirmLabel = "OK", cancelLabel = "Cancel", danger = false } = {}) { + return new Promise((resolve) => { + const dialog = el("dialog", "dialog app-prompt-dialog"); + const form = el("form", "dialog-card app-prompt-card"); + form.method = "dialog"; + const header = el("header", "dialog-header"); + header.append(el("strong", "", title)); + const close = el("button", "icon-button"); + close.type = "button"; + close.setAttribute("aria-label", cancelLabel); + close.innerHTML = svgIcon("x"); + header.append(close); + const body = el("div", "app-prompt-body"); + if (message) body.append(el("p", "app-prompt-message", message)); + let control = null; + if (Array.isArray(selectOptions)) { + control = document.createElement("select"); + control.className = "app-prompt-control"; + for (const option of selectOptions) control.add(new Option(option.label ?? option.value, option.value)); + if (input?.value !== undefined) control.value = String(input.value); + body.append(control); + } else if (input) { + control = input.multiline ? document.createElement("textarea") : document.createElement("input"); + control.className = "app-prompt-control"; + if (!input.multiline) control.type = input.type || "text"; + control.value = input.value ?? ""; + if (input.placeholder) control.placeholder = input.placeholder; + if (input.required) control.required = true; + body.append(control); + } + const actions = el("footer", "dialog-actions app-prompt-actions"); + const cancel = el("button", "button quiet", cancelLabel); + cancel.type = "button"; + const confirm = el("button", danger ? "button danger" : "button primary", confirmLabel); + confirm.type = "submit"; + actions.append(cancel, confirm); + form.append(header, body, actions); + dialog.append(form); + document.body.append(dialog); + + let settled = false; + const finish = (value) => { + if (settled) return; + settled = true; + resolve(value); + if (dialog.open) dialog.close(); + else dialog.remove(); + }; + close.addEventListener("click", () => finish(null)); + cancel.addEventListener("click", () => finish(null)); + dialog.addEventListener("cancel", (event) => { event.preventDefault(); finish(null); }); + dialog.addEventListener("close", () => { if (!settled) { settled = true; resolve(null); } dialog.remove(); }, { once: true }); + form.addEventListener("submit", (event) => { + event.preventDefault(); + if (control && !control.reportValidity()) return; + finish(control ? control.value : true); + }); + dialog.showModal(); + setTimeout(() => (control || confirm).focus(), 20); + }); + } + + async function appConfirm(message, { title = "Confirm action", confirmLabel = "Continue", danger = false } = {}) { + return (await appDialog({ title, message, confirmLabel, danger })) === true; + } + + function appPrompt(title, { message = "", value = "", placeholder = "", type = "text", confirmLabel = "OK", required = false } = {}) { + return appDialog({ title, message, input: { value, placeholder, type, required }, confirmLabel }); + } + + function appSelect(title, options, { message = "", value = "", confirmLabel = "Choose" } = {}) { + return appDialog({ title, message, input: { value }, selectOptions: options, confirmLabel }); + } + + function setBusy(button, busy) { + if (!button) return; + button.disabled = busy; + button.setAttribute("aria-busy", String(busy)); + } + + function resolveTheme(theme) { + if (theme !== "system") return theme; + return matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark"; + } + + const preferenceMap = { + theme: "theme", density: "density", preview: "preview_pane", scale: "ui_scale", language: "language", + colorPreset: "color_preset", accentColor: "accent_color", backgroundColor: "background_color", panelColor: "panel_color", textColor: "text_color", fontFamily: "font_family", fontSize: "font_size", + rowHeight: "row_height", folderPaneWidth: "folder_pane_width", messagePaneWidth: "message_pane_width", + borderRadius: "border_radius", contrast: "contrast", showAvatars: "show_avatars", + showAttachmentIcons: "show_attachment_icons", showFlags: "show_flags", reduceMotion: "reduce_motion", composeFormat: "compose_format", + composeAutosaveMs: "compose_autosave_ms", defaultPriority: "default_priority", requestMdnDefault: "request_mdn_default", attachmentReminder: "attachment_reminder", composeFontFamily: "compose_font_family", replyPosition: "reply_position", quoteStyle: "quote_style", forwardMode: "forward_mode", spellcheckEnabled: "spellcheck_enabled", + dateFormat: "date_format", timeFormat: "time_format", iconSize: "icon_size", toolbarDensity: "toolbar_density", sidebarItemHeight: "sidebar_item_height", + readerMaxWidth: "reader_max_width", composerWidth: "composer_width", selectionColor: "selection_color", unreadFontWeight: "unread_font_weight", unreadBadgeStyle: "unread_badge_style", + compactHeaders: "compact_headers", showBorders: "show_borders", scrollbarDensity: "scrollbar_density", monochrome: "monochrome", folderBackground: "folder_background", messageBackground: "message_background", readerBackground: "reader_background", + }; + const preferenceTimers = new Map(); + + async function loadTranslations(language) { + const locale = ["en", "pl"].includes(language) ? language : "en"; + try { + const response = await fetch(`${BASE}/assets/i18n/${locale}.json`, { credentials: "same-origin" }); + state.translations = response.ok ? await response.json() : {}; + } catch (_) { state.translations = {}; } + root.lang = locale; + $$('[data-i18n]').forEach((node) => { const key = node.dataset.i18n; if (state.translations[key]) node.textContent = state.translations[key]; }); + $$('[data-i18n-placeholder]').forEach((node) => { const key = node.dataset.i18nPlaceholder; if (state.translations[key]) node.placeholder = state.translations[key]; }); + $$('[data-i18n-title]').forEach((node) => { const key = node.dataset.i18nTitle; if (state.translations[key]) node.title = state.translations[key]; }); + $$('[data-i18n-aria-label]').forEach((node) => { const key = node.dataset.i18nAriaLabel; if (state.translations[key]) node.setAttribute('aria-label', state.translations[key]); }); + } + + function tr(key, fallback = key) { + return state.translations[key] || fallback; + } + + function applyPreferences() { + const p = state.prefs; + root.dataset.theme = resolveTheme(p.theme); + root.dataset.density = p.density; + root.dataset.preview = p.preview; + root.dataset.scale = p.scale; + root.dataset.colorPreset = p.colorPreset; + root.dataset.fontFamily = p.fontFamily; + root.dataset.contrast = p.contrast; + root.dataset.hideAvatars = String(!p.showAvatars); + root.dataset.hideAttachments = String(!p.showAttachmentIcons); + root.dataset.hideFlags = String(!p.showFlags); + root.dataset.reduceMotion = String(p.reduceMotion); + root.dataset.toolbarDensity = p.toolbarDensity; + root.dataset.unreadBadge = p.unreadBadgeStyle; + root.dataset.compactHeaders = String(p.compactHeaders); + root.dataset.showBorders = String(p.showBorders); + root.dataset.scrollbarDensity = p.scrollbarDensity; + root.dataset.monochrome = String(p.monochrome); + root.style.setProperty("--user-accent", p.accentColor); + root.style.setProperty("--user-bg", p.backgroundColor); + root.style.setProperty("--user-panel", p.panelColor); + root.style.setProperty("--user-text", p.textColor); + const scaleFactor = ({ small: 0.90, normal: 1, large: 1.12 })[p.scale] || 1; + const densityFactor = p.density === "comfortable" ? 1.13 : 0.93; + root.style.setProperty("--ui-scale-factor", String(scaleFactor)); + root.style.setProperty("--density-factor", String(densityFactor)); + root.style.setProperty("--ui-size", `${Math.round(Number(p.fontSize || 13) * scaleFactor * 100) / 100}px`); + root.style.setProperty("--row-height", `${Math.max(42, Math.round(Number(p.rowHeight || 58) * scaleFactor))}px`); + root.style.setProperty("--folder-pane-width", `${Math.max(170, Math.round(Number(p.folderPaneWidth || 228) * scaleFactor))}px`); + root.style.setProperty("--message-pane-width", `${Math.max(300, Math.round(Number(p.messagePaneWidth || 420) * scaleFactor))}px`); + root.style.setProperty("--control-height", `${Math.max(26, Math.round(30 * scaleFactor * densityFactor))}px`); + root.style.setProperty("--appbar-height", `${Math.max(38, Math.round(44 * scaleFactor * densityFactor))}px`); + root.style.setProperty("--dialog-header-height", `${Math.max(38, Math.round(43 * scaleFactor * densityFactor))}px`); + root.style.setProperty("--icon-size", `${Math.max(14, Math.round(Number(p.iconSize || 18) * scaleFactor))}px`); + const toolbarFactor = ({ compact: 0.88, normal: 1, spacious: 1.14 })[p.toolbarDensity] || 1; + root.style.setProperty("--toolbar-density-factor", String(toolbarFactor)); + root.style.setProperty("--sidebar-item-height", `${Math.max(24, Math.round(Number(p.sidebarItemHeight || 29) * scaleFactor))}px`); + root.style.setProperty("--reader-max-width", Number(p.readerMaxWidth) > 0 ? `${Number(p.readerMaxWidth)}px` : "none"); + root.style.setProperty("--composer-width", `${Math.max(640, Number(p.composerWidth || 900))}px`); + root.style.setProperty("--compose-font-family", composeFontCss(p.composeFontFamily)); + root.style.setProperty("--selected", p.selectionColor || "#28352e"); + root.style.setProperty("--unread-font-weight", String(Number(p.unreadFontWeight || 730))); + root.style.setProperty("--folder-bg", p.folderBackground || "var(--sidebar)"); + root.style.setProperty("--message-bg", p.messageBackground || "var(--panel)"); + root.style.setProperty("--reader-bg", p.readerBackground || "var(--panel)"); + root.style.setProperty("--button-pad-x", `${Math.max(8, Math.round(10 * scaleFactor))}px`); + root.style.setProperty("--button-pad-y", `${Math.max(3, Math.round(5 * scaleFactor * densityFactor))}px`); + root.style.setProperty("--field-pad-x", `${Math.max(7, Math.round(8 * scaleFactor))}px`); + root.style.setProperty("--field-pad-y", `${Math.max(3, Math.round(5 * scaleFactor * densityFactor))}px`); + root.style.setProperty("--radius-control", `${p.borderRadius}px`); + root.style.setProperty("--radius-panel", `${Math.min(p.borderRadius + 1, 13)}px`); + + const values = { + "#pref-theme": p.theme, "#pref-density": p.density, "#pref-preview": p.preview, "#pref-scale": p.scale, + "#pref-language": p.language, "#pref-color-preset": p.colorPreset, "#pref-accent": p.accentColor, + "#pref-background": p.backgroundColor, "#pref-panel": p.panelColor, "#pref-text-color": p.textColor, + "#pref-font-family": p.fontFamily, "#pref-font-size": p.fontSize, "#pref-row-height": p.rowHeight, + "#pref-folder-width": p.folderPaneWidth, "#pref-message-width": p.messagePaneWidth, "#pref-radius": p.borderRadius, + "#pref-contrast": p.contrast, "#pref-date-format": p.dateFormat, "#pref-time-format": p.timeFormat, + "#compose-pref-format": p.composeFormat, "#compose-pref-autosave": String(p.composeAutosaveMs), "#compose-pref-priority": p.defaultPriority, "#compose-pref-font": p.composeFontFamily, "#compose-pref-reply-position": p.replyPosition, "#compose-pref-quote-style": p.quoteStyle, "#compose-pref-forward-mode": p.forwardMode, + "#pref-icon-size": p.iconSize, "#pref-toolbar-density": p.toolbarDensity, "#pref-sidebar-height": p.sidebarItemHeight, "#pref-reader-width": String(p.readerMaxWidth), "#pref-composer-width": p.composerWidth, + "#pref-selection-color": p.selectionColor, "#pref-unread-weight": p.unreadFontWeight, "#pref-unread-badge": p.unreadBadgeStyle, "#pref-scrollbar-density": p.scrollbarDensity, + }; + Object.entries(values).forEach(([selector, value]) => { const node = $(selector); if (node) node.value = value; }); + const checks = { "#pref-avatars": p.showAvatars, "#pref-attachment-icons": p.showAttachmentIcons, "#pref-flags": p.showFlags, "#pref-reduce-motion": p.reduceMotion, "#compose-pref-mdn": p.requestMdnDefault, "#compose-pref-attachment-reminder": p.attachmentReminder, "#compose-pref-spellcheck": p.spellcheckEnabled, "#pref-compact-headers": p.compactHeaders, "#pref-show-borders": p.showBorders, "#pref-monochrome": p.monochrome, "#pref-folder-bg-enabled": Boolean(p.folderBackground), "#pref-message-bg-enabled": Boolean(p.messageBackground), "#pref-reader-bg-enabled": Boolean(p.readerBackground) }; + Object.entries(checks).forEach(([selector, value]) => { const node = $(selector); if (node) node.checked = Boolean(value); }); + const outputs = { "#pref-accent-value": p.accentColor, "#pref-background-value": p.backgroundColor, "#pref-panel-value": p.panelColor, "#pref-text-color-value": p.textColor, "#pref-font-size-value": `${p.fontSize} px`, "#pref-row-height-value": `${p.rowHeight} px`, "#pref-folder-width-value": `${p.folderPaneWidth} px`, "#pref-message-width-value": `${p.messagePaneWidth} px`, "#pref-radius-value": `${p.borderRadius} px`, "#pref-icon-size-value": `${p.iconSize} px`, "#pref-sidebar-height-value": `${p.sidebarItemHeight} px`, "#pref-composer-width-value": `${p.composerWidth} px`, "#pref-selection-color-value": p.selectionColor, "#pref-unread-weight-value": String(p.unreadFontWeight) }; + Object.entries(outputs).forEach(([selector, value]) => { const node = $(selector); if (node) node.textContent = value; }); + [["#pref-folder-bg", p.folderBackground || "#101310"], ["#pref-message-bg", p.messageBackground || "#181c19"], ["#pref-reader-bg", p.readerBackground || "#181c19"]].forEach(([selector,value]) => { const node=$(selector); if (node) node.value=value; }); + } + + function normalizeServerPreferences(data) { + if (!data) return; + state.prefs = { + ...state.prefs, + theme: data.theme || "system", density: data.density || "compact", preview: data.preview_pane || "right", composeFormat: data.compose_format || "html", + composeAutosaveMs: Number(data.compose_autosave_ms || 3000), defaultPriority: data.default_priority || "normal", requestMdnDefault: Boolean(data.request_mdn_default), attachmentReminder: data.attachment_reminder !== false, composeFontFamily: data.compose_font_family || "system", replyPosition: data.reply_position || "above", quoteStyle: data.quote_style || "standard", forwardMode: data.forward_mode || "inline", spellcheckEnabled: data.spellcheck_enabled !== false, + scale: data.ui_scale || "normal", language: data.language || "en", colorPreset: data.color_preset || "sage", + accentColor: data.accent_color || "#6f9c86", backgroundColor: data.background_color || "#121513", panelColor: data.panel_color || "#181c19", textColor: data.text_color || "#edf0ec", fontFamily: data.font_family || "system", fontSize: data.font_size || 13, + rowHeight: data.row_height || 58, folderPaneWidth: data.folder_pane_width || 228, messagePaneWidth: data.message_pane_width || 420, + borderRadius: data.border_radius ?? 4, contrast: data.contrast || "normal", showAvatars: data.show_avatars !== false, + showAttachmentIcons: data.show_attachment_icons !== false, showFlags: data.show_flags !== false, reduceMotion: Boolean(data.reduce_motion), dateFormat: data.date_format || "locale", timeFormat: data.time_format || "locale", + iconSize: Number(data.icon_size || 18), toolbarDensity: data.toolbar_density || "normal", sidebarItemHeight: Number(data.sidebar_item_height || 29), readerMaxWidth: Number(data.reader_max_width || 0), composerWidth: Number(data.composer_width || 900), selectionColor: data.selection_color || "#28352e", unreadFontWeight: Number(data.unread_font_weight || 730), unreadBadgeStyle: data.unread_badge_style || "dot", compactHeaders: Boolean(data.compact_headers), showBorders: data.show_borders !== false, scrollbarDensity: data.scrollbar_density || "thin", monochrome: Boolean(data.monochrome), folderBackground: data.folder_background || "", messageBackground: data.message_background || "", readerBackground: data.reader_background || "", + }; + } + + async function loadPreferences() { + try { normalizeServerPreferences(await request("/settings/display", { method: "GET" })); } + catch (error) { console.warn("Could not load display preferences", error); } + applyPreferences(); + await loadTranslations(state.prefs.language); + } + + async function loadMailPreferences() { + try { + const prefs = await request("/settings/mailbox", { method:"GET" }); + state.mailPrefs = { ...state.mailPrefs, ...prefs }; + state.perPage = Number(state.mailPrefs.messages_per_page || 50); + const values = { + "#mail-pref-per-page": String(state.perPage), + "#mail-pref-read-delay": String(state.mailPrefs.mark_read_delay_ms || 0), + "#mail-pref-remote-images": state.mailPrefs.remote_images || "ask", + "#mail-pref-preferred-body": state.mailPrefs.preferred_body || "auto", + "#mail-pref-thread-mode": state.mailPrefs.thread_mode || "threaded", + "#mail-pref-sort": state.mailPrefs.default_sort || "date_desc", + }; + Object.entries(values).forEach(([selector, value]) => { const node = $(selector); if (node) node.value = value; }); + const messageSort = $("#message-sort"); + if (messageSort) messageSort.value = state.mailPrefs.default_sort || "date_desc"; + const collapse = $("#mail-pref-collapse-quotes"); + if (collapse) collapse.checked = state.mailPrefs.collapse_quotes !== false; + const snippets = $("#mail-pref-show-snippets"); + if (snippets) snippets.checked = state.mailPrefs.show_snippets !== false; + const flaggedTop = $("#mail-pref-flagged-top"); + if (flaggedTop) flaggedTop.checked = Boolean(state.mailPrefs.flagged_on_top); + const syncKeywords = $("#mail-pref-sync-keywords"); + if (syncKeywords) syncKeywords.checked = Boolean(state.mailPrefs.sync_imap_keywords); + } catch (error) { console.warn("Could not load mailbox preferences", error); } + } + + async function saveMailPreferences() { + const payload = { + messages_per_page: Number($("#mail-pref-per-page").value), + mark_read_delay_ms: Number($("#mail-pref-read-delay").value), + remote_images: $("#mail-pref-remote-images").value, + preferred_body: $("#mail-pref-preferred-body").value, + thread_mode: $("#mail-pref-thread-mode").value, + collapse_quotes: $("#mail-pref-collapse-quotes").checked, + show_snippets: $("#mail-pref-show-snippets").checked, + default_sort: $("#mail-pref-sort").value, + flagged_on_top: $("#mail-pref-flagged-top").checked, + sync_imap_keywords: $("#mail-pref-sync-keywords").checked, + }; + try { + state.mailPrefs = await request("/settings/mailbox", { method:"PUT", body:JSON.stringify(payload) }); + state.perPage = Number(state.mailPrefs.messages_per_page || 50); + toast("Mailbox settings saved"); + if (state.currentDetail) renderMessageDetail(state.currentDetail); + await loadMessages(true); + } catch (error) { toast(error.message, "error"); } + } + + async function reconcileImapLabels() { + const button = $("#mail-pref-reconcile-labels"); + const status = $("#mail-pref-reconcile-status"); + if (!$("#mail-pref-sync-keywords")?.checked) { + toast("Enable IMAP keyword synchronization first", "warning"); + return; + } + if (button) button.disabled = true; + if (status) status.textContent = "Reconciling…"; + try { + const result = await request("/tags/reconcile", { method:"POST", body:"{}" }); + const added = Number(result.labels_added || 0); + const removed = Number(result.labels_removed || 0); + const folders = Number(result.folders_checked || 0); + const skipped = Number(result.skipped_folders || 0); + if (status) status.textContent = `${folders} folder(s) checked · +${added} / −${removed}${skipped ? ` · ${skipped} skipped` : ""}`; + toast(`Labels reconciled: +${added}, −${removed}`, "success"); + await loadTags(); + await loadMessages(true); + } catch (error) { + if (status) status.textContent = error.message; + toast(error.message, "error"); + } finally { + if (button) button.disabled = false; + } + } + + function savePreference(key, value) { + state.prefs[key] = value; + if (["accentColor", "backgroundColor", "panelColor", "textColor"].includes(key)) state.prefs.colorPreset = "custom"; + applyPreferences(); + if (key === "language") loadTranslations(value); + const previousTimer = preferenceTimers.get(key); + if (previousTimer) clearTimeout(previousTimer); + const timer = setTimeout(async () => { + preferenceTimers.delete(key); + const field = preferenceMap[key]; + if (!field) return; + const payload = { [field]: state.prefs[key] }; + if (["accentColor", "backgroundColor", "panelColor", "textColor"].includes(key)) payload.color_preset = "custom"; + try { await request("/settings/display", { method: "PUT", body: JSON.stringify(payload) }); } + catch (error) { toast(error.message, "error"); } + }, 180); + preferenceTimers.set(key, timer); + } + + async function resetAppearance() { + const payload = { theme:"system", density:"compact", preview_pane:"right", ui_scale:"normal", color_preset:"sage", accent_color:"#6f9c86", background_color:"#121513", panel_color:"#181c19", text_color:"#edf0ec", font_family:"system", font_size:13, row_height:58, folder_pane_width:228, message_pane_width:420, border_radius:4, contrast:"normal", show_avatars:true, show_attachment_icons:true, show_flags:true, reduce_motion:false, date_format:"locale", time_format:"locale", icon_size:18, toolbar_density:"normal", sidebar_item_height:29, reader_max_width:0, composer_width:900, selection_color:"#28352e", unread_font_weight:730, unread_badge_style:"dot", compact_headers:false, show_borders:true, scrollbar_density:"thin", monochrome:false, folder_background:"", message_background:"", reader_background:"" }; + try { normalizeServerPreferences(await request("/settings/display", { method:"PUT", body:JSON.stringify(payload) })); applyPreferences(); toast("Appearance reset"); } + catch (error) { toast(error.message, "error"); } + } + + const appearancePresets = { + compact: { density:"compact", scale:"small", fontSize:12, rowHeight:46, folderPaneWidth:196, messagePaneWidth:350, borderRadius:2, preview:"right" }, + balanced: { density:"compact", scale:"normal", fontSize:13, rowHeight:56, folderPaneWidth:228, messagePaneWidth:420, borderRadius:4, preview:"right" }, + comfortable: { density:"comfortable", scale:"normal", fontSize:14, rowHeight:66, folderPaneWidth:244, messagePaneWidth:460, borderRadius:6, preview:"right" }, + reading: { density:"comfortable", scale:"large", fontSize:15, rowHeight:72, folderPaneWidth:236, messagePaneWidth:520, borderRadius:6, preview:"bottom" }, + }; + + async function applyAppearancePreset(name) { + const preset = appearancePresets[name]; + if (!preset) return; + state.prefs = { ...state.prefs, ...preset }; + applyPreferences(); + const payload = {}; + for (const [key, value] of Object.entries(preset)) { + const field = preferenceMap[key]; + if (field) payload[field] = value; + } + try { + normalizeServerPreferences(await request("/settings/display", { method:"PUT", body:JSON.stringify(payload) })); + applyPreferences(); + const label = $(`[data-appearance-preset="${name}"]`)?.dataset.presetLabel || name; + toast(`Applied interface preset: ${label}`, "success"); + } catch (error) { toast(error.message, "error"); } + } + + function formatDate(raw, long = false) { + const date = new Date(raw); + if (Number.isNaN(date.getTime())) return raw || ""; + const timeOptions = state.prefs.timeFormat === "24h" ? { hour:"2-digit", minute:"2-digit", hour12:false } : state.prefs.timeFormat === "12h" ? { hour:"numeric", minute:"2-digit", hour12:true } : { hour:"2-digit", minute:"2-digit" }; + if (long) { + if (state.prefs.dateFormat === "iso") return `${date.getFullYear()}-${String(date.getMonth()+1).padStart(2,"0")}-${String(date.getDate()).padStart(2,"0")} ${date.toLocaleTimeString([], timeOptions)}`; + const locale = state.prefs.dateFormat === "eu" ? "en-GB" : state.prefs.dateFormat === "us" ? "en-US" : undefined; + return `${date.toLocaleDateString(locale)} ${date.toLocaleTimeString([], timeOptions)}`; + } + const now = new Date(); + if (date.toDateString() === now.toDateString()) return date.toLocaleTimeString([], timeOptions); + if (state.prefs.dateFormat === "iso") return `${date.getFullYear()}-${String(date.getMonth()+1).padStart(2,"0")}-${String(date.getDate()).padStart(2,"0")}`; + if (state.prefs.dateFormat === "eu") return date.toLocaleDateString("en-GB"); + if (state.prefs.dateFormat === "us") return date.toLocaleDateString("en-US"); + if (date.getFullYear() === now.getFullYear()) return date.toLocaleDateString([], { month:"short", day:"numeric" }); + return date.toLocaleDateString([], { year:"numeric", month:"short", day:"numeric" }); + } + + function formatBytes(value) { + const bytes = Number(value || 0); + if (!Number.isFinite(bytes) || bytes < 1) return "0 B"; + const units = ["B", "KB", "MB", "GB", "TB"]; + const index = Math.min(units.length - 1, Math.floor(Math.log(bytes) / Math.log(1024))); + return `${(bytes / (1024 ** index)).toFixed(index ? 1 : 0)} ${units[index]}`; + } + + function initial(value) { + return (value || "?").trim().charAt(0).toUpperCase() || "?"; + } + + function showLogin() { + $("#boot").classList.add("hidden"); + $("#app-view").classList.add("hidden"); + $("#login-view").classList.remove("hidden"); + } + + function showApp() { + $("#boot").classList.add("hidden"); + $("#login-view").classList.add("hidden"); + $("#app-view").classList.remove("hidden"); + } + + async function loadAuthPolicy() { + try { + state.authPolicy = await request("/auth/policy", { method:"GET" }); + } catch (_) { + state.authPolicy = { rememberMeEnabled:true, rememberMeDays:30, sessionTimeoutHours:24 }; + } + const enabled = state.authPolicy.rememberMeEnabled !== false; + ["#login-remember-row", "#add-account-remember-row"].forEach((selector) => $(selector)?.classList.toggle("hidden", !enabled)); + ["#login-remember", "#add-account-remember"].forEach((selector) => { const input=$(selector); if (input && !enabled) { input.checked=false; input.disabled=true; } }); + const days = Number(state.authPolicy.rememberMeDays || 30); + ["#login-remember-label", "#add-account-remember-label"].forEach((selector) => { const label=$(selector); if (label) label.textContent = `Keep me signed in (${days} days)`; }); + } + + async function loadAccounts() { + try { + const data = await request("/auth/accounts", { method: "GET" }); + state.accounts = data.accounts || []; + if (!state.accounts.length) { + state.accountId = null; + localStorage.removeItem("rustbox.activeAccount"); + state.folders = []; + updateUnreadAppBadge(); + showLogin(); + return false; + } + if (!state.accountId || !state.accounts.some((account) => account.id === state.accountId)) { + state.accountId = state.accounts[0].id; + localStorage.setItem("rustbox.activeAccount", state.accountId); + } + const account = state.accounts.find((item) => item.id === state.accountId) || state.accounts[0]; + $("#account-email").textContent = account.email; + showApp(); + return true; + } catch (error) { + console.error(error); + showLogin(); + return false; + } + } + + function renderAccountSettings() { + const list = $("#account-settings-list"); + if (!list) return; + list.replaceChildren(); + if (!state.accounts.length) { + list.append(el("div", "empty-list", "No active accounts.")); + return; + } + for (const account of state.accounts) { + const row = el("div", `account-settings-row ${account.id === state.accountId ? "active" : ""}`); + const email = el("span", "account-email", account.email || "Account"); + const use = el("button", "button quiet", account.id === state.accountId ? "Current" : "Switch"); + use.type = "button"; + use.disabled = account.id === state.accountId; + use.addEventListener("click", () => switchAccount(account.id)); + const remove = el("button", "button quiet danger", "Remove"); + remove.type = "button"; + remove.addEventListener("click", () => removeBrowserAccount(account.id)); + row.append(email, use, remove); + list.append(row); + } + } + + async function loadActiveSessions() { + const list = $("#account-session-list"); + if (!list) return; + list.replaceChildren(el("div", "empty-list", "Loading sessions…")); + try { + const data = await request("/auth/sessions", { method:"GET" }); + const sessions = data.sessions || []; + list.replaceChildren(); + if (!sessions.length) { + list.append(el("div", "empty-list", "No active sessions.")); + return; + } + for (const session of sessions) { + const row = el("div", `account-settings-row ${session.current ? "active" : ""}`); + const details = el("div", "account-session-details"); + const expiry = Number(session.expiresAt || 0); + const expiryText = expiry ? ` · expires ${new Date(expiry * 1000).toLocaleString()}` : ""; + const rememberedText = session.remembered ? " · remembered" : ""; + details.append( + el("strong", "account-email", session.current ? `${session.email} · current` : session.email), + el("span", "muted", `Created ${new Date(session.createdAt * 1000).toLocaleString()} · active ${new Date(session.lastActive * 1000).toLocaleString()}${expiryText}${rememberedText}`), + ); + const client = el("span", "muted", session.clientName || "Unknown device"); + if (session.userAgent) client.title = session.userAgent; + details.append(client); + const action = el("button", "button quiet danger", session.current ? "Current" : "Revoke"); + action.type = "button"; + action.disabled = Boolean(session.current); + action.addEventListener("click", () => revokeActiveSession(session.id)); + row.append(details, action); + list.append(row); + } + } catch (error) { + list.replaceChildren(el("div", "empty-list", error.message)); + } + } + + async function revokeActiveSession(id) { + if (!await appConfirm("Revoke this RustBox session?", { title:"Revoke session", confirmLabel:"Revoke", danger:true })) return; + try { + await request(`/auth/sessions/${encodeURIComponent(id)}`, { method:"DELETE" }); + toast("Session revoked"); + await loadActiveSessions(); + } catch (error) { toast(error.message, "error"); } + } + + async function revokeOtherSessions() { + if (!await appConfirm("Revoke every other active RustBox session for this account?", { title:"Revoke other sessions", confirmLabel:"Revoke sessions", danger:true })) return; + try { + const result = await request("/auth/sessions/revoke-others", { method:"POST", body:"{}" }); + toast(`Revoked ${result.count || 0} session(s)`); + await loadActiveSessions(); + } catch (error) { toast(error.message, "error"); } + } + + async function reloadActiveAccount() { + state.folder = "INBOX"; + state.selected = null; + state.currentDetail = null; + state.selectedMessages.clear(); + state.selectionMode = false; + closeReader(); + await loadPreferences(); + await loadMailPreferences(); + await loadOpenPgp(); + await loadIdentities(); + await loadFolders(true); + await loadSpecialFolders(); + await loadTags(); + await loadNotificationPreferences(); + renderAccountSettings(); + } + + async function switchAccount(accountId) { + if (!state.accounts.some((account) => account.id === accountId) || accountId === state.accountId) return; + state.accountId = accountId; + localStorage.setItem("rustbox.activeAccount", accountId); + const account = state.accounts.find((item) => item.id === accountId); + if (account) $("#account-email").textContent = account.email; + try { await reloadActiveAccount(); } catch (error) { toast(error.message, "error"); } + } + + async function removeBrowserAccount(accountId) { + const account = state.accounts.find((item) => item.id === accountId); + if (!account || !await appConfirm(`Remove ${account.email} from this browser session?`, { title:"Remove account", confirmLabel:"Remove", danger:true })) return; + try { + await request(`/auth/accounts/${encodeURIComponent(accountId)}`, { method:"DELETE" }); + const removingCurrent = accountId === state.accountId; + await loadAccounts(); + if (!state.accounts.length) return; + if (removingCurrent) await reloadActiveAccount(); + else renderAccountSettings(); + } catch (error) { toast(error.message, "error"); } + } + + async function addBrowserAccount(event) { + event.preventDefault(); + const errorBox = $("#add-account-error"); + const submit = $("#add-account-form button[type=submit]"); + errorBox.classList.add("hidden"); + setBusy(submit, true); + try { + const data = await request("/auth/login", { method:"POST", body:JSON.stringify({ email:$("#add-account-email").value.trim(), password:$("#add-account-password").value, remember:$("#add-account-remember").checked }) }); + state.accountId = data.account.id; + localStorage.setItem("rustbox.activeAccount", state.accountId); + $("#add-account-dialog").close(); + $("#add-account-form").reset(); + await loadAccounts(); + await reloadActiveAccount(); + disconnectRealtime(); + connectRealtime(); + toast("Account added"); + } catch (error) { + errorBox.textContent = error.message; + errorBox.classList.remove("hidden"); + } finally { setBusy(submit, false); } + } + + async function login(event) { + event.preventDefault(); + const submit = $("#login-form button[type=submit]"); + const errorBox = $("#login-error"); + errorBox.classList.add("hidden"); + setBusy(submit, true); + try { + const data = await request("/auth/login", { + method: "POST", + body: JSON.stringify({ + email: $("#login-email").value.trim(), + password: $("#login-password").value, + remember: $("#login-remember").checked, + }), + }); + state.accountId = data.account.id; + localStorage.setItem("rustbox.activeAccount", state.accountId); + await loadAccounts(); + await loadPreferences(); + await loadMailPreferences(); + await loadOpenPgp(); + await loadIdentities(); + await loadFolders(true); + await loadNotificationPreferences(); + connectRealtime(); + } catch (error) { + errorBox.textContent = error.message; + errorBox.classList.remove("hidden"); + } finally { + setBusy(submit, false); + } + } + + async function logout() { + disconnectRealtime(); + try { await request("/auth/logout", { method: "POST", body: "{}" }); } catch (_) {} + state.accountId = null; + state.accounts = []; + localStorage.removeItem("rustbox.activeAccount"); + showLogin(); + } + + function folderIconName(folder) { + const attrs = (folder.attributes || []).join(" ").toLowerCase(); + const name = String(folder.name || "").toLowerCase(); + if (name === "inbox" || attrs.includes("\\inbox")) return "inbox"; + if (attrs.includes("\\sent") || name.includes("sent")) return "send"; + if (attrs.includes("\\drafts") || name.includes("draft")) return "file-text"; + if (attrs.includes("\\archive") || name.includes("archive")) return "archive"; + if (attrs.includes("\\trash") || name.includes("trash") || name.includes("deleted")) return "trash"; + if (attrs.includes("\\junk") || name.includes("spam") || name.includes("junk")) return "shield-alert"; + return "folder"; + } + + function orderedFolders() { + return [...state.folders].sort((a, b) => { + if (a.name.toUpperCase() === "INBOX") return -1; + if (b.name.toUpperCase() === "INBOX") return 1; + return a.name.localeCompare(b.name); + }); + } + + function folderDepth(folder) { + const delimiter = folder.delimiter || "/"; + if (!delimiter || !folder.name.includes(delimiter)) return 0; + return Math.min(8, folder.name.split(delimiter).length - 1); + } + + function folderLeafName(folder) { + const delimiter = folder.delimiter || "/"; + if (!delimiter || !folder.name.includes(delimiter)) return folder.name; + return folder.name.split(delimiter).filter(Boolean).at(-1) || folder.name; + } + + function folderHasChildren(folder, folders = state.folders) { + const delimiter = folder.delimiter || "/"; + if (!delimiter) return false; + const prefix = `${folder.name}${delimiter}`; + return folders.some((candidate) => candidate.name !== folder.name && candidate.name.startsWith(prefix)); + } + + function folderAncestors(folder) { + const delimiter = folder.delimiter || "/"; + if (!delimiter || !folder.name.includes(delimiter)) return []; + const parts = folder.name.split(delimiter); + const result = []; + for (let index = 1; index < parts.length; index += 1) result.push(parts.slice(0, index).join(delimiter)); + return result; + } + + function collapsedFolderStorageKey() { + return `rustbox.collapsedFolders.${state.accountId || "guest"}`; + } + + function loadCollapsedFolders() { + try { + const raw = JSON.parse(localStorage.getItem(collapsedFolderStorageKey()) || "[]"); + state.collapsedFolders = new Set(Array.isArray(raw) ? raw.filter((value) => typeof value === "string") : []); + } catch (_) { + state.collapsedFolders = new Set(); + } + } + + function saveCollapsedFolders() { + localStorage.setItem(collapsedFolderStorageKey(), JSON.stringify([...state.collapsedFolders])); + } + + function folderIsHiddenByCollapsedAncestor(folder) { + return folderAncestors(folder).some((name) => state.collapsedFolders.has(name)); + } + + function toggleFolderBranch(folder) { + if (state.collapsedFolders.has(folder.name)) state.collapsedFolders.delete(folder.name); + else state.collapsedFolders.add(folder.name); + saveCollapsedFolders(); + renderFolders(); + } + + function renderFolders() { + const list = $("#folder-list"); + list.replaceChildren(); + const folders = orderedFolders(); + for (const folder of folders) { + if (folderIsHiddenByCollapsedAncestor(folder)) continue; + const row = el("div", "folder-row"); + row.style.setProperty("--folder-depth", String(folderDepth(folder))); + + const hasChildren = folderHasChildren(folder, folders); + const expander = el("button", `folder-expander ${hasChildren ? "" : "placeholder"}`); + expander.type = "button"; + expander.tabIndex = hasChildren ? 0 : -1; + expander.setAttribute("aria-label", state.collapsedFolders.has(folder.name) ? `Expand ${folder.name}` : `Collapse ${folder.name}`); + expander.setAttribute("aria-expanded", String(hasChildren && !state.collapsedFolders.has(folder.name))); + if (hasChildren) { + expander.innerHTML = svgIcon("chevron-right"); + expander.classList.toggle("expanded", !state.collapsedFolders.has(folder.name)); + expander.addEventListener("click", (event) => { event.stopPropagation(); toggleFolderBranch(folder); }); + } + + const button = el("button", `folder-button ${folder.name === state.folder ? "active" : ""}`); + button.type = "button"; + button.innerHTML = `${svgIcon(folderIconName(folder))}${escapeHtml(folderLeafName(folder))}${folder.unread_count || folder.total_count || ""}`; + button.addEventListener("contextmenu", (event) => { event.preventDefault(); openFolderContextMenu(event.clientX, event.clientY, folder); }); + button.addEventListener("dragover", (event) => { + if (!event.dataTransfer?.types?.includes("application/x-rustbox-message")) return; + event.preventDefault(); + event.dataTransfer.dropEffect = "move"; + button.classList.add("drop-target"); + }); + button.addEventListener("dragleave", () => button.classList.remove("drop-target")); + button.addEventListener("drop", async (event) => { + event.preventDefault(); + button.classList.remove("drop-target"); + try { + const payload = JSON.parse(event.dataTransfer.getData("application/x-rustbox-message")); + if (!payload?.folder || !payload?.uid || payload.folder === folder.name) return; + await moveMessageByDrag(payload.folder, Number(payload.uid), folder.name); + } catch (error) { toast(error.message || "Could not move message", "error"); } + }); + button.addEventListener("click", async () => { + saveMessageListState(); + state.folder = folder.name; + state.selected = null; + state.selectedMessages.clear(); + clearActiveSearchContext(); + state.currentDetail = null; + renderFolders(); + closeReader(); + $("#folder-pane").classList.remove("open"); + await loadMessages(true, { restoreState:true }); + }); + row.append(expander, button); + list.append(row); + } + renderMoveOptions(); + } + + function updateUnreadAppBadge() { + const unread = state.folders.reduce((sum, folder) => sum + Math.max(0, Number(folder.unread_count || 0)), 0); + document.title = unread > 0 ? `(${unread}) RustBox` : "RustBox"; + try { + if (unread > 0 && typeof navigator.setAppBadge === "function") navigator.setAppBadge(unread); + else if (unread === 0 && typeof navigator.clearAppBadge === "function") navigator.clearAppBadge(); + } catch (_) {} + } + + async function loadFolders(restoreView = false) { + $("#connection-label").textContent = "Connecting"; + try { + const data = await request("/folders", { method: "GET" }); + state.folders = data.folders || []; + updateUnreadAppBadge(); + loadCollapsedFolders(); + const inbox = state.folders.find((folder) => folder.name.toUpperCase() === "INBOX"); + if (restoreView) { + const remembered = localStorage.getItem(lastFolderStorageKey()); + if (remembered && state.folders.some((folder) => folder.name === remembered)) state.folder = remembered; + } + if (!state.folders.some((folder) => folder.name === state.folder)) { + state.folder = inbox?.name || state.folders[0]?.name || "INBOX"; + } + renderFolders(); + $("#connection-label").textContent = "Connected"; + if (restoreView) { + const restoredSearch = readActiveSearchContext(); + if (restoredSearch) { + state.searchContext = restoredSearch; + const restoredParams = new URLSearchParams(restoredSearch.params); + $("#search-input").value = restoredParams.get("q") || ""; + await runSearch(restoredParams, restoredSearch.label || "Search", { restoreScrollTop:restoredSearch.scrollTop }); + return; + } + } + if (state.searchContext && !restoreView) await runSearch(new URLSearchParams(state.searchContext.params), state.searchContext.label, { restoreScrollTop:state.searchContext.scrollTop }); + else await loadMessages(true, { restoreState:restoreView }); + } catch (error) { + $("#connection-label").textContent = "Connection error"; + if (error.status === 401) return showLogin(); + toast(error.message, "error"); + $("#message-list").innerHTML = `
    ${escapeHtml(error.message)}
    `; + } + } + + function renderMoveOptions() { + const selects = [ + ["#move-select", "Move to…"], + ["#copy-select", "Copy to…"], + ["#bulk-move-select", "Move to…"], + ["#bulk-copy-select", "Copy to…"], + ]; + for (const [selector, placeholder] of selects) { + const select = $(selector); + if (!select) continue; + select.replaceChildren(new Option(placeholder, "")); + for (const folder of orderedFolders()) { + if ((selector === "#move-select" || selector === "#copy-select") && folder.name === state.currentDetail?.folder) continue; + select.add(new Option(folder.name, folder.name)); + } + } + } + + function messageKey(message) { + return `${message.folder || state.folder}\u0000${message.uid}`; + } + + function activeSearchHighlightTerms() { + if (!state.searchContext?.params) return []; + const params = new URLSearchParams(state.searchContext.params); + const terms = []; + for (const key of ["from", "to", "subject"]) { + const value = (params.get(key) || "").trim(); + if (value) terms.push(value); + } + const query = (params.get("q") || "").trim(); + const tokens = query.match(/(?:[^\s"]+|"[^"]*")+/g) || []; + for (const token of tokens) { + const cleaned = token.replace(/^"|"$/g, ""); + const colon = cleaned.indexOf(":"); + if (colon > 0) { + const op = cleaned.slice(0, colon).toLowerCase(); + const value = cleaned.slice(colon + 1).replace(/^"|"$/g, "").trim(); + if (["from", "to", "subject"].includes(op) && value) terms.push(value); + } else if (cleaned.length >= 2) { + terms.push(cleaned); + } + } + return [...new Set(terms.map((term) => term.trim()).filter((term) => term.length >= 2))] + .sort((a, b) => b.length - a.length) + .slice(0, 12); + } + + function renderHighlightedText(node, value, terms) { + const text = String(value || ""); + node.replaceChildren(); + if (!terms.length || !text) { + node.textContent = text; + return; + } + const lower = text.toLocaleLowerCase(); + let offset = 0; + while (offset < text.length) { + let matchIndex = -1; + let matchTerm = ""; + for (const term of terms) { + const index = lower.indexOf(term.toLocaleLowerCase(), offset); + if (index >= 0 && (matchIndex < 0 || index < matchIndex || (index === matchIndex && term.length > matchTerm.length))) { + matchIndex = index; + matchTerm = term; + } + } + if (matchIndex < 0) { + node.append(document.createTextNode(text.slice(offset))); + break; + } + if (matchIndex > offset) node.append(document.createTextNode(text.slice(offset, matchIndex))); + const mark = el("mark", "search-highlight", text.slice(matchIndex, matchIndex + matchTerm.length)); + node.append(mark); + offset = matchIndex + matchTerm.length; + } + } + + function selectedMessages() { + const source = state.searchAllResults || state.messages; + return source.filter((message) => state.selectedMessages.has(messageKey(message))); + } + + function renderBulkToolbar() { + const selected = selectedMessages(); + const toolbar = $("#bulk-toolbar"); + toolbar.classList.toggle("hidden", !state.selectionMode); + $("#bulk-count").textContent = `${selected.length} selected`; + const selectionUniverse = state.searchAllResults || state.messages; + const allLoaded = selectionUniverse.length > 0 && selectionUniverse.every((message) => state.selectedMessages.has(messageKey(message))); + $("#bulk-select-all").checked = allLoaded; + $("#bulk-select-all").indeterminate = selected.length > 0 && !allLoaded; + toolbar.querySelectorAll("button, select").forEach((control) => { if (control.id !== "selection-done" && control.id !== "bulk-select-search") control.disabled = selected.length === 0; }); + const selectSearch = $("#bulk-select-search"); + if (selectSearch) { + selectSearch.classList.toggle("hidden", !state.searchContext || Boolean(state.searchAllResults)); + selectSearch.disabled = !state.searchContext; + } + const junkButton = $("#bulk-junk"); + if (junkButton) junkButton.textContent = selected.length > 0 && selected.every((message) => isJunkFolder(message.folder || state.folder)) ? tr("not_spam", "Not spam") : tr("spam", "Spam"); + const toggle = $("#select-mode-btn"); + if (toggle) { + toggle.classList.toggle("active", state.selectionMode); + toggle.setAttribute("aria-pressed", String(state.selectionMode)); + toggle.title = state.selectionMode ? "Exit selection mode" : "Select messages"; + toggle.setAttribute("aria-label", toggle.title); + } + } + + function setSelectionMode(enabled) { + state.selectionMode = Boolean(enabled); + if (!state.selectionMode) { + state.selectedMessages.clear(); + state.searchAllResults = null; + } + renderMessages(); + } + + function setMessageSelected(message, selected) { + if (!state.selectionMode) state.selectionMode = true; + const key = messageKey(message); + selected ? state.selectedMessages.add(key) : state.selectedMessages.delete(key); + renderMessages(); + } + + function clearBulkSelection() { + state.selectedMessages.clear(); + state.searchAllResults = null; + renderMessages(); + } + + async function selectAllSearchResults() { + if (!state.searchContext) return; + const button = $("#bulk-select-search"); + setBusy(button, true, "Selecting…"); + try { + const params = new URLSearchParams(state.searchContext.params); + params.set("all", "true"); + params.delete("offset"); + const data = await request(`/search?${params.toString()}`, { method:"GET" }); + state.searchAllResults = data.results || []; + for (const message of state.searchAllResults) state.selectedMessages.add(messageKey(message)); + state.total = state.searchAllResults.length; + renderMessages(); + toast(`Selected all ${state.searchAllResults.length} search result${state.searchAllResults.length === 1 ? "" : "s"}`, "success"); + } catch (error) { + toast(error.message, "error"); + } finally { + setBusy(button, false); + } + } + + function renderTagOptions() { + const select = $("#bulk-tag-select"); + if (!select) return; + select.replaceChildren(new Option("Add label…", "")); + for (const tag of state.tags) select.add(new Option(tag.name, tag.id)); + } + + function renderTags() { + const list = $("#tag-list"); + if (!list) return; + list.replaceChildren(); + for (const tag of state.tags) { + const button = el("button", "tag-item"); + button.type = "button"; + const dot = el("span", "tag-dot"); dot.style.setProperty("--tag-color", tag.color || "#6b7280"); + button.append(dot, el("span", "", tag.name), el("span", "tag-count", String(tag.message_count || 0))); + button.addEventListener("click", () => loadTagMessages(tag)); + button.addEventListener("contextmenu", (event) => { event.preventDefault(); openTagDialog(tag); }); + list.append(button); + } + renderTagOptions(); + } + + async function loadTags() { + try { + const data = await request("/tags", { method:"GET" }); + state.tags = data.tags || []; + renderTags(); + } catch (error) { toast(error.message, "error"); } + } + + async function loadTagMessages(tag) { + try { + const data = await request(`/tags/${encodeURIComponent(tag.id)}/messages?page=0&per_page=250`, { method:"GET" }); + state.messages = data.messages || []; + state.total = data.total_count || state.messages.length; + state.selectedMessages.clear(); + clearActiveSearchContext(); + renderMessages(); + $("#folder-title").textContent = `Label: ${tag.name}`; + $("#message-count").textContent = `${state.total} results`; + } catch (error) { toast(error.message, "error"); } + } + + function openTagDialog(tag = null) { + $("#tag-id").value = tag?.id || ""; + $("#tag-name").value = tag?.name || ""; + $("#tag-color").value = /^#[0-9a-f]{6}$/i.test(tag?.color || "") ? tag.color : "#6b7280"; + $("#tag-delete").disabled = !tag; + $("#tag-dialog").showModal(); + setTimeout(() => $("#tag-name").focus(), 20); + } + + async function saveTag(event) { + event.preventDefault(); + const id = $("#tag-id").value; + const payload = { name:$("#tag-name").value.trim(), color:$("#tag-color").value }; + if (!payload.name) return; + try { + await request(id ? `/tags/${encodeURIComponent(id)}` : "/tags", { method:id ? "PUT" : "POST", body:JSON.stringify(payload) }); + $("#tag-dialog").close(); + await loadTags(); + toast("Label saved"); + } catch (error) { toast(error.message, "error"); } + } + + async function deleteTag() { + const id = $("#tag-id").value; + if (!id || !await appConfirm("Delete this label? Messages will not be deleted.", { title:"Delete label", confirmLabel:"Delete", danger:true })) return; + try { await request(`/tags/${encodeURIComponent(id)}`, { method:"DELETE" }); $("#tag-dialog").close(); await loadTags(); toast("Label deleted"); } + catch (error) { toast(error.message, "error"); } + } + + async function promptTag(label, tags = state.tags) { + if (!tags.length) { toast("Create a label first", "error"); return null; } + const answer = await appSelect(label, tags.map((tag) => ({ value:tag.id, label:tag.name })), { value:tags[0].id, confirmLabel:"Choose label" }); + return tags.find((tag) => String(tag.id) === String(answer)) || null; + } + + async function addTagToMessage(message, tag) { + const folder = message.folder || state.folder; + await request(`/tags/${encodeURIComponent(tag.id)}/messages`, { method:"POST", body:JSON.stringify({ message_uid:message.uid, message_folder:folder }) }); + message.tags = [...(message.tags || []).filter((item) => item.id !== tag.id), { id:tag.id, name:tag.name, color:tag.color }]; + renderMessages(); + await loadTags(); + } + + async function removeTagFromMessage(message, tag) { + const folder = message.folder || state.folder; + await request(`/tags/${encodeURIComponent(tag.id)}/messages/${encodeURIComponent(folder)}/${message.uid}`, { method:"DELETE" }); + message.tags = (message.tags || []).filter((item) => item.id !== tag.id); + renderMessages(); + await loadTags(); + } + + async function bulkAddTag(tagId) { + const items = selectedMessages(); + const tag = state.tags.find((item) => item.id === tagId); + if (!items.length || !tag) return; + try { + await request(`/tags/${encodeURIComponent(tag.id)}/messages/bulk`, { method:"POST", body:JSON.stringify({ messages:items.map((message) => ({ uid:message.uid, folder:message.folder || state.folder })) }) }); + for (const message of items) message.tags = [...(message.tags || []).filter((item) => item.id !== tag.id), { id:tag.id, name:tag.name, color:tag.color }]; + renderMessages(); + await loadTags(); + toast(`Label “${tag.name}” added`); + } catch (error) { toast(error.message, "error"); } + } + + async function loadMessages(reset = false, options = {}) { + const list = $("#message-list"); + const restoreState = Boolean(options.restoreState); + const savedState = restoreState ? readMessageListState(state.folder) : null; + const previousScrollTop = reset && !restoreState && list ? list.scrollTop : null; + if (reset) { + state.page = 0; + state.messages = []; + state.selectedMessages.clear(); + clearActiveSearchContext(); + list.innerHTML = `
    Loading messages…
    `; + } + $("#folder-title").textContent = state.folder; + try { + const sort = encodeURIComponent(state.mailPrefs.default_sort || "date_desc"); + const maxRestorePage = Math.max(0, Math.ceil(500 / Math.max(20, state.perPage)) - 1); + const targetPage = reset && savedState ? Math.min(savedState.page, maxRestorePage) : state.page; + const pages = reset && targetPage > 0 ? Array.from({ length:targetPage + 1 }, (_, index) => index) : [state.page]; + let loaded = []; + let totalCount = 0; + let flaggedOnTop = state.currentFolderFlaggedOnTop; + let lastLoadedPage = state.page; + for (const page of pages) { + const data = await request(`/folders/${encodeURIComponent(state.folder)}/messages?page=${page}&per_page=${state.perPage}&sort=${sort}`, { method: "GET" }); + totalCount = data.total_count || 0; + flaggedOnTop = Boolean(data.flagged_on_top); + loaded.push(...(data.messages || [])); + lastLoadedPage = page; + if (loaded.length >= totalCount) break; + } + state.page = reset ? lastLoadedPage : state.page; + state.total = totalCount; + state.currentFolderFlaggedOnTop = flaggedOnTop; + state.messages = reset ? loaded : [...state.messages, ...loaded]; + $("#connection-label").textContent = "Connected"; + renderMessages(); + const desiredScroll = savedState?.scrollTop ?? previousScrollTop; + if (Number.isFinite(desiredScroll) && desiredScroll > 0) { + requestAnimationFrame(() => { list.scrollTop = Math.min(desiredScroll, Math.max(0, list.scrollHeight - list.clientHeight)); }); + } + saveMessageListState(); + } catch (error) { + $("#connection-label").textContent = error.network ? (navigator.onLine ? "Reconnecting" : "Offline") : "Connection error"; + if (!state.messages.length) list.innerHTML = `
    ${escapeHtml(error.message)}
    `; + $("#message-count").textContent = state.messages.length ? `${state.total} messages · cached view` : "Unavailable"; + } + } + + async function quickUpdateMessageFlag(message, flag, add, successMessage) { + const folder = message.folder || state.folder; + try { + await request(`/messages/${encodeURIComponent(folder)}/${message.uid}/flags`, { method:"PATCH", body:JSON.stringify({ flags:[flag], add }) }); + const flags = new Set(Array.isArray(message.flags) ? message.flags : String(message.flags || "").split(/[,\s]+/).filter(Boolean)); + add ? flags.add(flag) : flags.delete(flag); + message.flags = [...flags].join(" "); + if (state.currentDetail?.uid === message.uid && state.currentDetail?.folder === folder) { + const detailFlags = new Set(state.currentDetail.flags || []); + add ? detailFlags.add(flag) : detailFlags.delete(flag); + state.currentDetail.flags = [...detailFlags]; + renderMessageDetail(state.currentDetail); + } + if (flag === "\\Flagged" && state.currentFolderFlaggedOnTop && !state.searchContext && folder === state.folder) await loadMessages(true); + else renderMessages(); + toast(successMessage, "success"); + } catch (error) { toast(error.message, "error"); } + } + + function renderMessages() { + $("#folder-title").textContent = state.folder; + $("#message-count").textContent = `${state.total} ${state.total === 1 ? "message" : "messages"}`; + const list = $("#message-list"); + const highlightTerms = activeSearchHighlightTerms(); + list.classList.toggle("selection-mode", state.selectionMode); + list.replaceChildren(); + if (!state.messages.length) list.innerHTML = `
    ${state.searchContext ? "No matching messages." : "No messages in this folder."}
    `; + for (const message of state.messages) { + const folder = message.folder || state.folder; + const flags = Array.isArray(message.flags) ? message.flags.join(" ") : (message.flags || ""); + const unread = !flags.includes("\\Seen"); + const flagged = flags.includes("\\Flagged"); + const checked = state.selectedMessages.has(messageKey(message)); + const active = state.selected?.uid === message.uid && state.selected?.folder === folder; + const row = el("div", `message-row ${unread ? "unread" : ""} ${flagged ? "flagged" : ""} ${active ? "active" : ""} ${checked ? "selected" : ""}`); + row.tabIndex = 0; + row.setAttribute("role", "button"); + row.setAttribute("aria-label", `${message.from_name || message.from_address || "Unknown"}: ${message.subject || "(no subject)"}`); + const checkbox = document.createElement("input"); + checkbox.type = "checkbox"; + checkbox.className = "message-select"; + checkbox.checked = checked; + checkbox.disabled = !state.selectionMode; + checkbox.setAttribute("aria-label", "Select message"); + checkbox.addEventListener("click", (event) => { event.stopPropagation(); }); + checkbox.addEventListener("change", () => setMessageSelected(message, checkbox.checked)); + row.innerHTML = `
    `; + renderHighlightedText(row.querySelector(".message-from"), message.from_name || message.from_address || "Unknown", highlightTerms); + renderHighlightedText(row.querySelector(".message-subject"), message.subject || "(no subject)", highlightTerms); + renderHighlightedText(row.querySelector(".message-snippet"), state.mailPrefs.show_snippets !== false ? (message.snippet || "") : "", highlightTerms); + const icons = row.querySelector(".message-icons"); + if (message.has_attachments) { + const attachmentIcon = el("span", "message-static-icon attachment-indicator"); + attachmentIcon.innerHTML = svgIcon("paperclip"); + attachmentIcon.title = "Has attachments"; + icons.append(attachmentIcon); + } + const quick = el("span", "message-quick-actions"); + const readButton = el("button", "message-quick-action"); + readButton.type = "button"; + readButton.title = unread ? "Mark read" : "Mark unread"; + readButton.setAttribute("aria-label", readButton.title); + readButton.innerHTML = svgIcon("mail"); + readButton.addEventListener("click", (event) => { event.stopPropagation(); quickUpdateMessageFlag(message, "\\Seen", unread, unread ? "Marked read" : "Marked unread"); }); + const flagButton = el("button", `message-quick-action message-flag ${flagged ? "active" : ""}`); + flagButton.type = "button"; + flagButton.title = flagged ? "Unflag" : "Flag"; + flagButton.setAttribute("aria-label", flagButton.title); + flagButton.innerHTML = svgIcon("star"); + flagButton.addEventListener("click", (event) => { event.stopPropagation(); quickUpdateMessageFlag(message, "\\Flagged", !flagged, flagged ? "Flag removed" : "Message flagged"); }); + quick.append(readButton, flagButton); + icons.append(quick); + if (Array.isArray(message.tags) && message.tags.length) { + const wrap = el("span", "message-tags"); + for (const tag of message.tags) { + const chip = el("span", "message-tag", tag.name); + chip.style.setProperty("--tag-color", tag.color || "#6b7280"); + wrap.append(chip); + } + row.querySelector(".message-subject").after(wrap); + } + row.prepend(checkbox); + row.draggable = true; + row.addEventListener("dragstart", (event) => { + event.dataTransfer.effectAllowed = "move"; + event.dataTransfer.setData("application/x-rustbox-message", JSON.stringify({ folder, uid: message.uid })); + row.classList.add("dragging"); + }); + row.addEventListener("dragend", () => row.classList.remove("dragging")); + row.addEventListener("click", (event) => { + if (state.selectionMode || event.ctrlKey || event.metaKey) return setMessageSelected(message, !state.selectedMessages.has(messageKey(message))); + openMessage(folder, message.uid); + }); + row.addEventListener("keydown", (event) => { if (event.key === "Enter" && !state.selectionMode) openMessage(folder, message.uid); if (event.key === " ") { event.preventDefault(); setMessageSelected(message, !state.selectedMessages.has(messageKey(message))); } }); + row.addEventListener("contextmenu", (event) => { event.preventDefault(); openMessageContextMenu(event.clientX, event.clientY, message); }); + list.append(row); + } + $("#load-more").classList.toggle("hidden", Boolean(state.searchContext) || state.messages.length >= state.total); + renderBulkToolbar(); + } + + function navigateMessage(delta) { + if (!state.selected) return; + const index = state.messages.findIndex((message) => message.uid === state.selected.uid && (message.folder || state.folder) === state.selected.folder); + if (index < 0) return; + const next = state.messages[index + delta]; + if (!next) return toast(delta > 0 ? "No next loaded message" : "No previous loaded message"); + openMessage(next.folder || state.folder, next.uid); + } + + function updateMessageNavigation() { + const index = state.selected ? state.messages.findIndex((message) => message.uid === state.selected.uid && (message.folder || state.folder) === state.selected.folder) : -1; + $("#reader-prev").disabled = index <= 0; + $("#reader-next").disabled = index < 0 || index >= state.messages.length - 1; + } + + async function senderIsSavedContact(email) { + if (!email) return false; + try { + const data = await request(`/contacts?q=${encodeURIComponent(email)}&limit=10`, { method:"GET" }); + const wanted = String(email).trim().toLowerCase(); + return (data.contacts || []).some((contact) => { + const addresses = [contact.email, ...(contact.emails || [])].filter(Boolean).map((value) => String(value).trim().toLowerCase()); + return addresses.includes(wanted); + }); + } catch (_) { + return false; + } + } + + async function openMessage(folder, uid) { + clearTimeout(state.markReadTimer); + state.markReadTimer = null; + state.selected = { folder, uid }; + renderMessages(); + updateMessageNavigation(); + $("#reader-empty").classList.add("hidden"); + $("#reader-content").classList.remove("hidden"); + $("#reader-subject").textContent = "Loading…"; + $("#message-body").replaceChildren(); + $("#reader").classList.add("mobile-open"); + try { + const detail = await request(`/messages/${encodeURIComponent(folder)}/${uid}`, { method: "GET" }); + if (state.mailPrefs.remote_images === "contacts" && detail.from_address) { + detail._senderInContacts = await senderIsSavedContact(detail.from_address); + } + state.currentDetail = detail; + renderMessageDetail(detail); + renderMoveOptions(); + scheduleMarkRead(detail); + } catch (error) { + $("#reader-subject").textContent = "Unable to open message"; + $("#message-body").innerHTML = `
    ${escapeHtml(error.message)}
    `; + } + } + + function scheduleMarkRead(detail) { + clearTimeout(state.markReadTimer); + state.markReadTimer = null; + if (!detail || (detail.flags || []).includes("\\Seen")) return; + const delay = Math.max(0, Number(state.mailPrefs.mark_read_delay_ms || 0)); + const run = async () => { + if (!state.currentDetail || state.currentDetail.uid !== detail.uid || state.currentDetail.folder !== detail.folder) return; + try { + await request(`/messages/${encodeURIComponent(detail.folder)}/${detail.uid}/flags`, { method:"PATCH", body:JSON.stringify({ flags:["\\Seen"], add:true }) }); + detail.flags = [...new Set([...(detail.flags || []), "\\Seen"])]; + const item = state.messages.find((msg) => msg.uid === detail.uid && (msg.folder || state.folder) === detail.folder); + if (item) { + const flags = Array.isArray(item.flags) ? item.flags : String(item.flags || "").split(/[,\s]+/).filter(Boolean); + item.flags = [...new Set([...flags, "\\Seen"])].join(" "); + } + renderMessages(); + } catch (_) {} + }; + if (delay === 0) run(); else state.markReadTimer = setTimeout(run, delay); + } + + function renderThread(detail) { + const thread = detail.thread || []; + const section = $("#thread-section"); + const list = $("#thread-list"); + list.replaceChildren(); + const enabled = state.mailPrefs.thread_mode !== "flat"; + section.classList.toggle("hidden", !enabled || thread.length <= 1); + if (!enabled || thread.length <= 1) return; + $("#thread-title").textContent = `Thread (${thread.length})`; + list.classList.toggle("hidden", !state.threadExpanded); + $("#thread-toggle").classList.toggle("expanded", state.threadExpanded); + $("#thread-toggle").setAttribute("aria-expanded", String(state.threadExpanded)); + for (const item of thread) { + const button = el("button", `thread-item ${item.uid === detail.uid && item.folder === detail.folder ? "current" : ""}`); + button.type = "button"; + button.innerHTML = `${escapeHtml(item.from_name || item.from_address || "Unknown")} — ${escapeHtml(item.subject || "(no subject)")}`; + button.addEventListener("click", () => openMessage(item.folder, item.uid)); + list.append(button); + } + } + + function renderMessageDetail(detail) { + $("#reader-subject").textContent = detail.subject || "(no subject)"; + $("#reader-from").textContent = detail.from_name ? `${detail.from_name} <${detail.from_address}>` : detail.from_address; + $("#reader-from").title = detail.from_address ? "Sender actions" : ""; + $("#reader-from").tabIndex = detail.from_address ? 0 : -1; + if (detail.from_address) $("#reader-from").setAttribute("role", "button"); + else $("#reader-from").removeAttribute("role"); + const to = (detail.to_addresses || []).map((entry) => entry.name ? `${entry.name} <${entry.address}>` : entry.address).join(", "); + $("#reader-to").textContent = `to ${to || "me"}`; + $("#reader-date").textContent = formatDate(detail.date, true); + $("#reader-avatar").textContent = initial(detail.from_name || detail.from_address); + $("#flag-btn").classList.toggle("active", (detail.flags || []).includes("\\Flagged")); + const spamButton = $("#spam-btn"); + const inJunk = isJunkFolder(detail.folder); + spamButton.classList.toggle("active", inJunk); + spamButton.title = inJunk ? tr("mark_not_spam", "Mark as not spam") : tr("mark_spam", "Mark as spam"); + spamButton.setAttribute("aria-label", spamButton.title); + renderThread(detail); + renderPgpReaderState(detail); + + const attachments = $("#attachment-list"); + attachments.replaceChildren(); + attachments.classList.toggle("hidden", !(detail.attachments || []).length); + for (const attachment of detail.attachments || []) { + const item = el("div", "attachment-item"); + const button = el("button", "attachment-button"); + button.type = "button"; + button.innerHTML = `${svgIcon("paperclip")}${escapeHtml(attachment.filename || "attachment")} · ${Math.max(1, Math.ceil((attachment.size || 0) / 1024))} KB`; + button.addEventListener("click", () => downloadAttachment(detail.folder, detail.uid, attachment)); + button.addEventListener("contextmenu", (event) => { + event.preventDefault(); + const filename = String(attachment.filename || "").toLowerCase(); + const contentType = String(attachment.content_type || attachment.contentType || "").toLowerCase(); + const actions = [ + { label:"Open", action:() => openAttachment(detail.folder, detail.uid, attachment) }, + { label:"Download", action:() => downloadAttachment(detail.folder, detail.uid, attachment) }, + ]; + if (contentType.includes("text/calendar") || filename.endsWith(".ics")) actions.push("separator", { label:"Add to calendar", action:() => importCalendarAttachment(detail.folder, detail.uid, attachment) }); + if (contentType.includes("text/vcard") || contentType.includes("text/x-vcard") || filename.endsWith(".vcf")) actions.push("separator", { label:"Import contact", action:() => importVcardAttachment(detail.folder, detail.uid, attachment) }); + if ((detail.attachments || []).length > 1) actions.push("separator", { label:"Download all as ZIP", action:() => downloadAllAttachmentsZip(detail) }); + showContextMenu(event.clientX, event.clientY, actions); + }); + item.append(button); + const filename = String(attachment.filename || "").toLowerCase(); + const contentType = String(attachment.content_type || attachment.contentType || "").toLowerCase(); + if (contentType.includes("text/calendar") || filename.endsWith(".ics")) { + const add = el("button", "icon-button small"); + add.innerHTML = svgIcon("calendar-days"); + add.type = "button"; + add.title = "Add to calendar"; + add.setAttribute("aria-label", "Add calendar attachment"); + add.addEventListener("click", () => importCalendarAttachment(detail.folder, detail.uid, attachment)); + item.append(add); + } + if (contentType.includes("text/vcard") || contentType.includes("text/x-vcard") || filename.endsWith(".vcf")) { + const add = el("button", "icon-button small"); + add.innerHTML = svgIcon("user-round"); + add.type = "button"; + add.title = "Import contact"; + add.setAttribute("aria-label", "Import vCard attachment"); + add.addEventListener("click", () => importVcardAttachment(detail.folder, detail.uid, attachment)); + item.append(add); + } + attachments.append(item); + } + if ((detail.attachments || []).length > 1) { + const all = el("button", "attachment-download-all"); + all.type = "button"; + all.innerHTML = `${svgIcon("package")}Download all as ZIP`; + all.addEventListener("click", () => downloadAllAttachmentsZip(detail)); + attachments.prepend(all); + } + + renderMessageBody(detail); + } + + function pgpMessageKind(detail) { + const headers = String(detail?.raw_headers || "").toLowerCase(); + const text = String(detail?.text || "").toLowerCase(); + const encrypted = (headers.includes("multipart/encrypted") && headers.includes("application/pgp-encrypted")) || text.includes("-----begin pgp message-----"); + const signed = (headers.includes("multipart/signed") && headers.includes("application/pgp-signature")) || text.includes("-----begin pgp signed message-----"); + return { pgp: encrypted || signed, encrypted, signed }; + } + + function renderPgpReaderState(detail) { + const bar = $("#pgp-reader-bar"); + const status = $("#pgp-reader-status"); + const title = $("#pgp-reader-title"); + const find = $("#pgp-reader-discover"); + const kind = pgpMessageKind(detail); + const configured = state.pgpKeys.length > 0; + bar.classList.toggle("hidden", !kind.pgp || !configured); + find.classList.add("hidden"); + if (!kind.pgp || !configured) return; + const processed = detail?._pgpProcessed; + if (!processed) { + title.textContent = kind.encrypted ? "OpenPGP encrypted message" : "OpenPGP signed message"; + status.textContent = kind.encrypted ? "Decrypt to read and verify the protected MIME payload." : "Signature has not been verified yet."; + $("#pgp-reader-process").textContent = kind.encrypted ? "Decrypt / verify" : "Verify signature"; + return; + } + const signer = processed.signerFingerprint ? ` · ${pgpFingerprintLabel(processed.signerFingerprint)}` : ""; + const signerIdentity = processed.signerEmail ? ` · ${processed.signerEmail}` : ""; + const trust = processed.signerTrust ? ` · trust: ${processed.signerTrust}` : ""; + if (processed.encrypted && processed.verified) { title.textContent="OpenPGP decrypted + verified"; status.textContent=`Encrypted content was decrypted and its signature is valid${signer}${signerIdentity}${trust}`; } + else if (processed.encrypted) { title.textContent="OpenPGP decrypted"; status.textContent=processed.signed ? `Content decrypted; embedded signature could not be verified${signer}${signerIdentity}${trust}` : "Content decrypted successfully"; } + else if (processed.verified) { title.textContent="OpenPGP signature verified"; status.textContent=`Valid signature${signer}${signerIdentity}${trust}`; } + else { + title.textContent="OpenPGP signature not verified"; + status.textContent="No matching public key verified this signature."; + if (detail?.from_address && (state.pgpPrefs.wkdEnabled || state.pgpPrefs.hkpEnabled)) find.classList.remove("hidden"); + } + $("#pgp-reader-process").textContent = "Reprocess"; + } + + async function processCurrentPgpMessage() { + const detail = state.currentDetail; + if (!detail) return; + const kind = pgpMessageKind(detail); + let passphrase = ""; + if (kind.encrypted) { + const value = await appPrompt("Decrypt OpenPGP message", { message:"Enter the private-key passphrase if this key is protected. It is used only for this request and is not stored.", type:"password", confirmLabel:"Decrypt" }); + if (value === null) return; + passphrase = value; + } + const button = $("#pgp-reader-process"); setBusy(button,true); + try { + const result = await request(`/pgp/messages/${encodeURIComponent(detail.folder)}/${detail.uid}/process`, { method:"POST", body:JSON.stringify({ passphrase }) }); + detail._pgpProcessed = result; + renderPgpReaderState(detail); + if (result.text != null || result.html != null) renderMessageBody({ ...detail, text:result.text, html:result.html }); + renderPgpProcessedAttachments(result.attachments || []); + if (result.verified && result.signerTrust === "untrusted") toast("OpenPGP signature is cryptographically valid, but this key is marked untrusted", "warning"); + else if (result.verified) toast("OpenPGP signature verified", "success"); + else if (result.decrypted) toast("OpenPGP message decrypted", "success"); + else if (result.signed) toast("OpenPGP signature could not be verified", "warning"); + } catch (error) { toast(error.message,"error"); } + finally { setBusy(button,false); } + } + + function renderPgpProcessedAttachments(items) { + if (!items.length) return; + const list = $("#attachment-list"); + list.replaceChildren(); list.classList.remove("hidden"); + for (const attachment of items) { + const item = el("div", "attachment-item"); + const button = el("button", "attachment-button"); button.type="button"; + button.innerHTML=`${svgIcon("paperclip")}${escapeHtml(attachment.filename || "attachment")} · ${Math.max(1,Math.ceil((attachment.size||0)/1024))} KB`; + button.addEventListener("click", () => { + try { + const binary = atob(attachment.dataBase64 || ""); const bytes = new Uint8Array(binary.length); for (let i=0;iURL.revokeObjectURL(url),30000); + } catch (_) { toast("Unable to decode decrypted attachment", "error"); } + }); + item.append(button); list.append(item); + } + } + + async function discoverCurrentPgpSigner() { + const email = state.currentDetail?.from_address; + if (!email) return; + const key = await discoverPgpEmail(email, false); + if (key) await processCurrentPgpMessage(); + } + + function renderMessageBody(detail, forceRemote = false) { + const body = $("#message-body"); + const remoteBar = $("#remote-image-bar"); + body.replaceChildren(); + remoteBar.classList.add("hidden"); + const preferredBody = state.mailPrefs.preferred_body || "auto"; + if (preferredBody === "text" || !detail?.html) { + body.append(el("pre", "", cleanPlainMessageText(detail?.text || "(empty message)"))); + return; + } + + const hasRemote = /(?:src|background)\s*=\s*["']https?:\/\//i.test(detail.html) || /url\(\s*["']?https?:\/\//i.test(detail.html); + const policy = state.mailPrefs.remote_images || "ask"; + const allowRemote = forceRemote || policy === "always" || (policy === "contacts" && detail?._senderInContacts === true); + if (hasRemote && !allowRemote && (policy === "ask" || policy === "contacts")) remoteBar.classList.remove("hidden"); + + const iframe = document.createElement("iframe"); + iframe.setAttribute("sandbox", ""); + iframe.setAttribute("referrerpolicy", "no-referrer"); + const imageSources = allowRemote ? "data: cid: https: http:" : "data: cid:"; + const csp = ``; + const quoteStyle = state.mailPrefs.collapse_quotes !== false ? "blockquote{max-height:16em;overflow:auto;border-left:2px solid #d9ddda;padding-left:12px;margin-left:0}" : "blockquote{border-left:2px solid #d9ddda;padding-left:12px;margin-left:0}"; + const safeHtml = sanitizeMessageHtml(detail.html); + iframe.srcdoc = `${csp}${safeHtml}`; + body.append(iframe); + } + + async function downloadAttachment(folder, uid, attachment) { + try { + const headers = new Headers(); + if (state.accountId) headers.set("X-Active-Account", state.accountId); + const response = await fetch(apiPath(`/messages/${encodeURIComponent(folder)}/${uid}/attachments/${encodeURIComponent(attachment.id)}`), { credentials: "include", headers }); + if (!response.ok) throw new Error(`Download failed (${response.status})`); + const blob = await response.blob(); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = attachment.filename || "attachment"; + link.click(); + setTimeout(() => URL.revokeObjectURL(url), 30000); + toast(`Downloading ${attachment.filename || "attachment"}`, "info"); + } catch (error) { toast(error.message, "error"); } + } + + async function fetchAttachmentBlob(folder, uid, attachment) { + const headers = new Headers(); + if (state.accountId) headers.set("X-Active-Account", state.accountId); + const response = await fetch(apiPath(`/messages/${encodeURIComponent(folder)}/${uid}/attachments/${encodeURIComponent(attachment.id)}`), { credentials:"include", headers }); + if (!response.ok) throw new Error(`Attachment request failed (${response.status})`); + return response.blob(); + } + + function attachmentMime(attachment, blob = null) { + return String(blob?.type || attachment?.content_type || attachment?.contentType || "application/octet-stream").toLowerCase(); + } + + async function openAttachment(folder, uid, attachment) { + try { + const blob = await fetchAttachmentBlob(folder, uid, attachment); + const mime = attachmentMime(attachment, blob); + const safe = mime.startsWith("image/") || mime === "application/pdf" || mime.startsWith("text/"); + if (!safe) return toast("Preview is not available for this attachment type", "warning"); + if (blob.size > 30 * 1024 * 1024) return toast("Attachment is too large for preview", "warning"); + + const dialog = el("dialog", "dialog attachment-preview-dialog"); + const card = el("div", "dialog-card attachment-preview-card"); + const header = el("header", "dialog-header"); + header.append(el("strong", "", attachment.filename || "Attachment preview")); + const close = el("button", "icon-button"); + close.type = "button"; + close.setAttribute("aria-label", "Close preview"); + close.innerHTML = svgIcon("x"); + header.append(close); + const body = el("div", "attachment-preview-body"); + let objectUrl = null; + + if (mime.startsWith("image/")) { + objectUrl = URL.createObjectURL(blob); + const image = document.createElement("img"); + image.src = objectUrl; + image.alt = attachment.filename || "Attachment image"; + body.append(image); + } else if (mime === "application/pdf") { + objectUrl = URL.createObjectURL(blob); + const frame = document.createElement("iframe"); + frame.src = objectUrl; + frame.title = attachment.filename || "PDF preview"; + frame.referrerPolicy = "no-referrer"; + body.append(frame); + } else { + if (blob.size > 5 * 1024 * 1024) return toast("Text attachment is too large for inline preview", "warning"); + const pre = document.createElement("pre"); + pre.textContent = await blob.text(); + body.append(pre); + } + + const footer = el("footer", "dialog-actions"); + const download = el("button", "button quiet", "Download"); + download.type = "button"; + download.addEventListener("click", () => downloadAttachment(folder, uid, attachment)); + const done = el("button", "button primary", "Close"); + done.type = "button"; + const dismiss = () => { if (dialog.open) dialog.close(); }; + close.addEventListener("click", dismiss); + done.addEventListener("click", dismiss); + footer.append(download, done); + card.append(header, body, footer); + dialog.append(card); + document.body.append(dialog); + dialog.addEventListener("close", () => { if (objectUrl) URL.revokeObjectURL(objectUrl); dialog.remove(); }, { once:true }); + dialog.showModal(); + } catch (error) { toast(error.message, "error"); } + } + + function safeZipFilename(value, index) { + const cleaned = String(value || `attachment-${index + 1}`) + .replace(/[\x00-\x1f\x7f\\/:*?"<>|]/g, "_") + .replace(/^\.+$/, "attachment") + .slice(0, 180); + return cleaned || `attachment-${index + 1}`; + } + + let crcTable = null; + function crc32(bytes) { + if (!crcTable) { + crcTable = new Uint32Array(256); + for (let n = 0; n < 256; n += 1) { + let c = n; + for (let k = 0; k < 8; k += 1) c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1); + crcTable[n] = c >>> 0; + } + } + let crc = 0xffffffff; + for (const byte of bytes) crc = crcTable[(crc ^ byte) & 0xff] ^ (crc >>> 8); + return (crc ^ 0xffffffff) >>> 0; + } + + function zipDosDateTime(date = new Date()) { + const year = Math.max(1980, date.getFullYear()); + const dosTime = ((date.getHours() & 31) << 11) | ((date.getMinutes() & 63) << 5) | ((Math.floor(date.getSeconds() / 2)) & 31); + const dosDate = (((year - 1980) & 127) << 9) | (((date.getMonth() + 1) & 15) << 5) | (date.getDate() & 31); + return { dosTime, dosDate }; + } + + function zipRecord(size, fill) { + const bytes = new Uint8Array(size); + const view = new DataView(bytes.buffer); + fill(view); + return bytes; + } + + function concatBytes(parts) { + const total = parts.reduce((sum, part) => sum + part.byteLength, 0); + const out = new Uint8Array(total); + let offset = 0; + for (const part of parts) { out.set(part, offset); offset += part.byteLength; } + return out; + } + + async function downloadAllAttachmentsZip(detail = state.currentDetail) { + const attachments = detail?.attachments || []; + if (!attachments.length) return toast("This message has no attachments", "warning"); + if (attachments.length > 100) return toast("Too many attachments for a browser ZIP", "warning"); + try { + toast(`Preparing ZIP with ${attachments.length} attachment${attachments.length === 1 ? "" : "s"}…`, "info"); + const encoder = new TextEncoder(); + const localParts = []; + const centralParts = []; + let localOffset = 0; + let totalPayload = 0; + for (let index = 0; index < attachments.length; index += 1) { + const attachment = attachments[index]; + const blob = await fetchAttachmentBlob(detail.folder, detail.uid, attachment); + totalPayload += blob.size; + if (totalPayload > 120 * 1024 * 1024) throw new Error("Attachments exceed the 120 MB browser ZIP limit"); + const data = new Uint8Array(await blob.arrayBuffer()); + const name = encoder.encode(safeZipFilename(attachment.filename, index)); + const checksum = crc32(data); + const { dosTime, dosDate } = zipDosDateTime(); + const local = zipRecord(30, (view) => { + view.setUint32(0, 0x04034b50, true); view.setUint16(4, 20, true); view.setUint16(6, 0x0800, true); view.setUint16(8, 0, true); + view.setUint16(10, dosTime, true); view.setUint16(12, dosDate, true); view.setUint32(14, checksum, true); + view.setUint32(18, data.length, true); view.setUint32(22, data.length, true); view.setUint16(26, name.length, true); view.setUint16(28, 0, true); + }); + const central = zipRecord(46, (view) => { + view.setUint32(0, 0x02014b50, true); view.setUint16(4, 20, true); view.setUint16(6, 20, true); view.setUint16(8, 0x0800, true); view.setUint16(10, 0, true); + view.setUint16(12, dosTime, true); view.setUint16(14, dosDate, true); view.setUint32(16, checksum, true); + view.setUint32(20, data.length, true); view.setUint32(24, data.length, true); view.setUint16(28, name.length, true); view.setUint16(30, 0, true); + view.setUint16(32, 0, true); view.setUint16(34, 0, true); view.setUint16(36, 0, true); view.setUint32(38, 0, true); view.setUint32(42, localOffset, true); + }); + localParts.push(local, name, data); + centralParts.push(central, name); + localOffset += local.byteLength + name.byteLength + data.byteLength; + } + const centralDirectory = concatBytes(centralParts); + const eocd = zipRecord(22, (view) => { + view.setUint32(0, 0x06054b50, true); view.setUint16(4, 0, true); view.setUint16(6, 0, true); + view.setUint16(8, attachments.length, true); view.setUint16(10, attachments.length, true); + view.setUint32(12, centralDirectory.byteLength, true); view.setUint32(16, localOffset, true); view.setUint16(20, 0, true); + }); + const zip = new Blob([...localParts, centralDirectory, eocd], { type:"application/zip" }); + const url = URL.createObjectURL(zip); + const link = document.createElement("a"); + const stem = String(detail.subject || "attachments").replace(/[\x00-\x1f\x7f\\/:*?"<>|]/g, "_").slice(0, 80) || "attachments"; + link.href = url; link.download = `${stem}-attachments.zip`; document.body.append(link); link.click(); link.remove(); + setTimeout(() => URL.revokeObjectURL(url), 30000); + toast(`Downloaded ${attachments.length} attachment${attachments.length === 1 ? "" : "s"} as ZIP`, "success"); + } catch (error) { toast(error.message, "error"); } + } + + async function importCalendarAttachment(folder, uid, attachment) { + try { + const blob = await fetchAttachmentBlob(folder, uid, attachment); + if (blob.size > 10 * 1024 * 1024) throw new Error("ICS attachment is too large"); + const text = await blob.text(); + const headers = new Headers({ "Content-Type":"text/calendar; charset=utf-8", "X-Requested-With":"XMLHttpRequest" }); + if (state.accountId) headers.set("X-Active-Account", state.accountId); + const response = await fetch(apiPath("/calendar/events/import-ics"), { method:"POST", credentials:"include", headers, body:text }); + const data = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(data?.error?.message || `Calendar import failed (${response.status})`); + toast(`Calendar: ${data.created || 0} event(s) imported`); + } catch (error) { toast(error.message, "error"); } + } + + async function importVcardAttachment(folder, uid, attachment) { + try { + const blob = await fetchAttachmentBlob(folder, uid, attachment); + if (blob.size > 10 * 1024 * 1024) throw new Error("vCard attachment is too large"); + const form = new FormData(); + form.append("file", blob, attachment.filename || "contact.vcf"); + const result = await request("/contacts/import", { method:"POST", body:form }); + toast(`Contacts: ${result.created || 0} created, ${result.updated || 0} updated`); + } catch (error) { toast(error.message, "error"); } + } + + function closeReader() { + clearTimeout(state.markReadTimer); + state.markReadTimer = null; + $("#reader").classList.remove("mobile-open"); + $("#reader-empty").classList.remove("hidden"); + $("#reader-content").classList.add("hidden"); + } + + function parseRecipients(raw) { + return raw.split(",").map((value) => value.trim()).filter(Boolean); + } + + function recipientToken(input) { + const value = input.value || ""; + const comma = value.lastIndexOf(","); + return { prefix: comma >= 0 ? value.slice(0, comma + 1) : "", query: value.slice(comma + 1).trim() }; + } + + function bindRecipientAutocomplete(selector) { + const input = $(selector); + if (!input) return; + const host = input.closest("label") || input.parentElement; + host.classList.add("recipient-field"); + const menu = el("div", "recipient-suggestions hidden"); + menu.setAttribute("role", "listbox"); + host.append(menu); + let timer = null; + let activeIndex = -1; + let suggestions = []; + + const hide = () => { menu.classList.add("hidden"); menu.replaceChildren(); activeIndex = -1; suggestions = []; }; + const select = (item) => { + const { prefix } = recipientToken(input); + const formatted = item.name?.trim() ? `${item.name.trim()} <${item.email}>` : item.email; + input.value = `${prefix}${prefix ? " " : ""}${formatted}, `; + hide(); + input.focus(); + scheduleDraftSave(); + input.dispatchEvent(new Event("input", { bubbles:true })); + }; + const render = () => { + menu.replaceChildren(); + if (!suggestions.length) { hide(); return; } + suggestions.forEach((item, index) => { + const button = el("button", `recipient-suggestion ${index === activeIndex ? "active" : ""}`); + button.type = "button"; + button.setAttribute("role", "option"); + button.setAttribute("aria-selected", String(index === activeIndex)); + const primary = el("strong", "", item.name?.trim() || item.email); + const secondary = el("span", "", item.name?.trim() ? item.email : (item.source || "")); + button.append(primary, secondary); + button.addEventListener("mousedown", (event) => event.preventDefault()); + button.addEventListener("click", () => select(item)); + menu.append(button); + }); + menu.classList.remove("hidden"); + }; + const load = async () => { + const { query } = recipientToken(input); + if (query.length < 2 || query.includes(">")) { hide(); return; } + try { + const data = await request(`/contacts/autocomplete?q=${encodeURIComponent(query)}&limit=8`, { method:"GET" }); + suggestions = data.suggestions || []; + activeIndex = -1; + render(); + } catch (_) { hide(); } + }; + + input.addEventListener("input", () => { clearTimeout(timer); timer = setTimeout(load, 140); }); + input.addEventListener("blur", () => setTimeout(hide, 120)); + input.addEventListener("keydown", (event) => { + if (menu.classList.contains("hidden") || !suggestions.length) return; + if (event.key === "ArrowDown") { event.preventDefault(); activeIndex = (activeIndex + 1) % suggestions.length; render(); } + else if (event.key === "ArrowUp") { event.preventDefault(); activeIndex = (activeIndex - 1 + suggestions.length) % suggestions.length; render(); } + else if (event.key === "Enter" && activeIndex >= 0) { event.preventDefault(); select(suggestions[activeIndex]); } + else if (event.key === "Escape") { event.preventDefault(); hide(); } + }); + } + + function quoteTextAsHtml(text) { + return escapeHtml(cleanPlainMessageText(text || "")).replaceAll("\n", "
    "); + } + + function ownAddressSet() { + return new Set(state.accounts.map((account) => String(account.email || "").trim().toLowerCase()).filter(Boolean)); + } + + function ensureDraftId() { + if (!state.composeDraftId) state.composeDraftId = crypto.randomUUID ? crypto.randomUUID() : `draft-${Date.now()}-${Math.random().toString(16).slice(2)}`; + return state.composeDraftId; + } + + function renderComposeAttachments() { + const box = $("#compose-attachments"); + box.replaceChildren(); + const visibleAttachments = state.composeAttachments.filter((attachment) => !state.inlineAttachmentIds.has(String(attachment.id))); + box.classList.toggle("hidden", !visibleAttachments.length); + for (const attachment of visibleAttachments) { + const item = el("div", "compose-attachment"); + item.innerHTML = `${svgIcon("paperclip")}${escapeHtml(attachment.filename)} · ${Math.max(1, Math.ceil((attachment.size || 0)/1024))} KB`; + const remove = el("button", ""); + remove.innerHTML = svgIcon("x"); + remove.type = "button"; + remove.setAttribute("aria-label", `Remove ${attachment.filename}`); + remove.addEventListener("click", () => deleteComposeAttachment(attachment.id)); + item.append(remove); + box.append(item); + } + if ($("#compose-pgp-mode")) updateComposePgpUi(); + } + + function composeRecoveryKey() { + return `rustbox.composeRecovery.${state.accountId || "default"}`; + } + + function draftHasContent() { + return Boolean( + $("#compose-to")?.value.trim() || $("#compose-cc")?.value.trim() || $("#compose-bcc")?.value.trim() || + $("#compose-subject")?.value.trim() || $("#compose-editor")?.innerText.trim() || state.composeAttachments.length + ); + } + + function composeAttachmentContentUrl(draftId, attachmentId) { + const query = state.accountId ? `?account_id=${encodeURIComponent(state.accountId)}` : ""; + return `${apiPath(`/drafts/${encodeURIComponent(draftId)}/attachments/${encodeURIComponent(attachmentId)}/content`)}${query}`; + } + + function markdownInlineToSafeHtml(value) { + const source = String(value ?? ""); + const code = []; + let html = escapeHtml(source).replace(/`([^`\n]+)`/g, (_, body) => { + const token = `@@RUSTBOX_CODE_${code.length}@@`; + code.push(`${body}`); + return token; + }); + html = html.replace(/\[([^\]]+)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g, (_, label, rawUrl) => { + let url = rawUrl.replaceAll("&", "&"); + try { + const parsed = new URL(url, window.location.origin); + if (!["http:", "https:", "mailto:"].includes(parsed.protocol)) return label; + url = parsed.protocol === "mailto:" ? url : parsed.href; + } catch (_) { return label; } + return `${label}`; + }); + html = html + .replace(/\*\*([^*\n]+)\*\*/g, "$1") + .replace(/__([^_\n]+)__/g, "$1") + .replace(/~~([^~\n]+)~~/g, "$1") + .replace(/(^|[\s(])\*([^*\n]+)\*(?=$|[\s).,!?:;])/g, "$1$2") + .replace(/(^|[\s(])_([^_\n]+)_(?=$|[\s).,!?:;])/g, "$1$2"); + code.forEach((snippet, index) => { html = html.replace(`@@RUSTBOX_CODE_${index}@@`, snippet); }); + return html; + } + + function markdownToSafeHtml(markdown) { + const lines = String(markdown ?? "").replace(/\r\n?/g, "\n").split("\n"); + const out = []; + let paragraph = []; + let listType = null; + let inCode = false; + let codeLines = []; + const flushParagraph = () => { + if (!paragraph.length) return; + out.push(`

    ${paragraph.map(markdownInlineToSafeHtml).join("
    ")}

    `); + paragraph = []; + }; + const closeList = () => { + if (!listType) return; + out.push(``); + listType = null; + }; + for (const line of lines) { + if (/^\s*```/.test(line)) { + flushParagraph(); closeList(); + if (inCode) { out.push(`
    ${escapeHtml(codeLines.join("\n"))}
    `); codeLines = []; inCode = false; } + else { inCode = true; } + continue; + } + if (inCode) { codeLines.push(line); continue; } + if (!line.trim()) { flushParagraph(); closeList(); continue; } + const heading = line.match(/^(#{1,6})\s+(.+)$/); + if (heading) { flushParagraph(); closeList(); const level = heading[1].length; out.push(`${markdownInlineToSafeHtml(heading[2])}`); continue; } + if (/^\s*(?:---+|\*\*\*+)\s*$/.test(line)) { flushParagraph(); closeList(); out.push("
    "); continue; } + const quote = line.match(/^>\s?(.*)$/); + if (quote) { flushParagraph(); closeList(); out.push(`
    ${markdownInlineToSafeHtml(quote[1])}
    `); continue; } + const unordered = line.match(/^\s*[-+*]\s+(.+)$/); + const ordered = line.match(/^\s*\d+[.)]\s+(.+)$/); + if (unordered || ordered) { + flushParagraph(); + const wanted = unordered ? "ul" : "ol"; + if (listType !== wanted) { closeList(); listType = wanted; out.push(`<${wanted}>`); } + out.push(`
  • ${markdownInlineToSafeHtml((unordered || ordered)[1])}
  • `); + continue; + } + paragraph.push(line); + } + if (inCode) out.push(`
    ${escapeHtml(codeLines.join("\n"))}
    `); + flushParagraph(); closeList(); + return out.join("\n"); + } + + function composeFontCss(value = state.prefs.composeFontFamily) { + return ({ + system: '-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif', + humanist: '"Trebuchet MS","Segoe UI",sans-serif', + serif: 'Georgia,"Times New Roman",serif', + mono: 'ui-monospace,SFMono-Regular,Menlo,Consolas,monospace', + })[value] || '-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif'; + } + + function wrapComposeHtmlFont(html) { + if (!html) return null; + const family = composeFontCss().replace(/&/g, "&").replace(/"/g, """); + return `
    ${html}
    `; + } + + function serializeComposeHtml(editor = $("#compose-editor")) { + if (!editor) return null; + const mode = $("#compose-mode").value; + if (mode === "markdown") { + const markdown = editor.innerText.trim(); + return markdown ? wrapComposeHtmlFont(markdownToSafeHtml(markdown)) : null; + } + if (mode !== "html") return null; + const clone = editor.cloneNode(true); + clone.querySelectorAll(".rustbox-compose-font").forEach((wrapper) => wrapper.replaceWith(...wrapper.childNodes)); + clone.querySelectorAll("img[data-rustbox-cid]").forEach((image) => { + const cid = image.dataset.rustboxCid || ""; + if (cid) image.setAttribute("src", `cid:${cid}`); + image.removeAttribute("data-rustbox-cid"); + image.removeAttribute("data-rustbox-align"); + image.classList.remove("compose-inline-image-selected"); + if (!image.className) image.removeAttribute("class"); + }); + const html = clone.innerHTML.trim(); + return html ? wrapComposeHtmlFont(html) : null; + } + + function hydrateComposeHtml(html, attachments = state.composeAttachments, draftId = state.composeDraftId) { + const host = document.createElement("div"); + host.innerHTML = sanitizeMessageHtml(html || ""); + if (host.children.length === 1 && host.firstElementChild?.classList.contains("rustbox-compose-font")) { + host.innerHTML = host.firstElementChild.innerHTML; + } + if (!draftId) return host.innerHTML; + const ids = new Set((attachments || []).map((attachment) => String(attachment.id || "")).filter(Boolean)); + host.querySelectorAll("img[src]").forEach((image) => { + const src = image.getAttribute("src") || ""; + if (!src.toLowerCase().startsWith("cid:")) return; + const cid = src.slice(4).replace(/^<|>$/g, ""); + if (!ids.has(cid)) return; + state.inlineAttachmentIds.add(cid); + image.dataset.rustboxCid = cid; + image.setAttribute("src", composeAttachmentContentUrl(draftId, cid)); + }); + return host.innerHTML; + } + + function composeDraftPayload() { + const editor = $("#compose-editor"); + return { + id: ensureDraftId(), + to: $("#compose-to").value, + cc: $("#compose-cc").value, + bcc: $("#compose-bcc").value, + subject: $("#compose-subject").value, + text_body: editor.innerText, + html_body: serializeComposeHtml(editor), + in_reply_to: state.composeReplyMeta?.inReplyTo || null, + references: state.composeReplyMeta?.references || null, + request_mdn: Boolean($("#compose-mdn")?.checked), + priority: $("#compose-priority")?.value || "normal", + compose_format: $("#compose-mode")?.value || "html", + pgp_mode: $("#compose-pgp-mode")?.value || "none", + pgp_inline: Boolean($("#compose-pgp-inline")?.checked), + pgp_attach_public_key: Boolean($("#compose-pgp-attach-key")?.checked), + pgp_attach_recipient_keys: Boolean($("#compose-pgp-attach-recipient-keys")?.checked), + }; + } + + function readLocalDraftSnapshots() { + try { + const raw = localStorage.getItem(composeRecoveryKey()); + if (!raw) return []; + const parsed = JSON.parse(raw); + const drafts = Array.isArray(parsed) ? parsed : Array.isArray(parsed?.drafts) ? parsed.drafts : parsed?.id ? [parsed] : []; + return drafts.filter((draft) => draft && draft.id).sort((a, b) => String(b.saved_at || "").localeCompare(String(a.saved_at || ""))); + } catch (_) { return []; } + } + + function writeLocalDraftSnapshots(drafts) { + const kept = drafts.filter((draft) => draft?.id).slice(0, 10); + if (!kept.length) localStorage.removeItem(composeRecoveryKey()); + else localStorage.setItem(composeRecoveryKey(), JSON.stringify({ drafts: kept })); + } + + function storeLocalDraftSnapshot() { + if (!draftHasContent()) return; + try { + const payload = composeDraftPayload(); + const snapshot = { + ...payload, + compose_format: $("#compose-mode").value, + attachments: state.composeAttachments, + saved_at: new Date().toISOString(), + }; + const drafts = readLocalDraftSnapshots().filter((draft) => draft.id !== payload.id); + drafts.unshift(snapshot); + writeLocalDraftSnapshots(drafts); + } catch (_) {} + } + + function clearLocalDraftSnapshot(draftId = state.composeDraftId) { + if (!draftId) return; + writeLocalDraftSnapshots(readLocalDraftSnapshots().filter((draft) => draft.id !== draftId)); + } + + async function saveCurrentDraft(silent = false, force = false, syncImap = false) { + if (!force && !$("#compose-dialog").open) return false; + if (!force && !state.composeDirty) return true; + if (!draftHasContent()) { state.composeDirty = false; clearLocalDraftSnapshot(); return true; } + const payload = composeDraftPayload(); + payload.sync_imap = Boolean(syncImap); + try { + await request("/drafts", { method:"POST", body:JSON.stringify(payload) }); + state.composeDirty = false; + if (syncImap) state.composeNeedsRemoteSync = false; + clearLocalDraftSnapshot(); + $("#compose-status").textContent = `Draft saved ${new Date().toLocaleTimeString([], {hour:"2-digit",minute:"2-digit"})}`; + if (!silent) toast("Draft saved", "success"); + return true; + } catch (error) { + storeLocalDraftSnapshot(); + if (!silent) toast(error.message, "error"); + return false; + } + } + + function scheduleDraftSave() { + state.composeDirty = true; + state.composeNeedsRemoteSync = true; + storeLocalDraftSnapshot(); + clearTimeout(state.draftSaveTimer); + state.draftSaveTimer = setTimeout(() => saveCurrentDraft(true), Math.max(1000, Number(state.prefs.composeAutosaveMs || 3000))); + } + + function setComposeUploadProgress(visible, percent = 0, label = "Uploading…") { + const box = $("#compose-upload-progress"); + const bar = $("#compose-upload-bar"); + const text = $("#compose-upload-label"); + if (!box || !bar || !text) return; + const value = Math.max(0, Math.min(100, Number(percent) || 0)); + box.classList.toggle("hidden", !visible); + bar.style.width = `${value}%`; + text.textContent = label; + } + + async function uploadComposeFiles(files, options = {}) { + const list = [...(files || [])]; + const announce = options.announce !== false; + if (!list.length) return []; + if (state.composeUploading) { if (announce) toast("Another upload is already in progress", "warning"); return []; } + state.composeUploading = true; + setComposeUploadProgress(true, 0, `Uploading ${list.length} file${list.length === 1 ? "" : "s"}…`); + await saveCurrentDraft(true, true); + const id = ensureDraftId(); + const form = new FormData(); + list.forEach((file) => form.append("file", file, file.name)); + try { + const data = await uploadRequest(`/drafts/${encodeURIComponent(id)}/attachments`, form, (percent) => { + setComposeUploadProgress(true, percent, `Uploading ${list.length} file${list.length === 1 ? "" : "s"}… ${percent}%`); + }); + const added = data.attachments || []; + state.composeAttachments.push(...added); + renderComposeAttachments(); + state.composeDirty = true; + state.composeNeedsRemoteSync = true; + storeLocalDraftSnapshot(); + setComposeUploadProgress(true, 100, "Upload complete"); + if (announce) toast(`${added.length} attachment${added.length === 1 ? "" : "s"} added`, "success"); + setTimeout(() => setComposeUploadProgress(false), 450); + return added; + } catch (error) { + setComposeUploadProgress(false); + toast(error.message, "error"); + return []; + } finally { + state.composeUploading = false; + } + } + + function previewMarkdown() { + const editor = $("#compose-editor"); + const preview = $("#markdown-preview-content"); + if (!editor || !preview) return; + preview.innerHTML = markdownToSafeHtml(editor.innerText); + $("#markdown-preview-dialog").showModal(); + } + + function setComposeMode(mode, persist = false) { + const value = ["html", "text", "markdown"].includes(mode) ? mode : "html"; + const select = $("#compose-mode"); + if (select) select.value = value; + const editor = $("#compose-editor"); + editor.dataset.mode = value; + editor.classList.toggle("plain-mode", value !== "html"); + $$(".editor-toolbar [data-command], #editor-link, #editor-image, #editor-font, #editor-size, #editor-color").forEach((control) => { control.disabled = value !== "html"; }); + const imageEdit = $("#editor-image-edit"); + if (imageEdit) imageEdit.disabled = value !== "html" || !state.composeSelectedImage; + $("#compose-markdown-preview")?.classList.toggle("hidden", value !== "markdown"); + if (persist) savePreference("composeFormat", value); + } + + function captureEditorRange(editor = $("#compose-editor")) { + const selection = window.getSelection(); + if (!editor || !selection || !selection.rangeCount) return null; + const range = selection.getRangeAt(0); + const container = range.commonAncestorContainer.nodeType === Node.ELEMENT_NODE ? range.commonAncestorContainer : range.commonAncestorContainer.parentElement; + if (container !== editor && !editor.contains(container)) return null; + return range.cloneRange(); + } + + function restoreEditorRange(range, editor = $("#compose-editor")) { + editor.focus(); + if (!range) return; + try { + const selection = window.getSelection(); + selection.removeAllRanges(); + selection.addRange(range); + } catch (_) {} + } + + function setSelectedInlineImage(image) { + if (state.composeSelectedImage && state.composeSelectedImage !== image) { + state.composeSelectedImage.classList?.remove("compose-inline-image-selected"); + } + state.composeSelectedImage = image && image.isConnected ? image : null; + state.composeSelectedImage?.classList.add("compose-inline-image-selected"); + const edit = $("#editor-image-edit"); + if (edit) edit.disabled = $("#compose-mode")?.value !== "html" || !state.composeSelectedImage; + } + + function inlineImageAlignment(image) { + if (!image) return "inline"; + if (image.dataset.rustboxAlign) return image.dataset.rustboxAlign; + const left = image.style.marginLeft; + const right = image.style.marginRight; + if (left === "auto" && right === "auto") return "center"; + if (left === "auto" && right === "0px") return "right"; + if (left === "0px" && right === "auto") return "left"; + return "inline"; + } + + function applyInlineImageDisplay(image, widthPercent, alignment) { + if (!image) return; + const width = Math.max(10, Math.min(100, Number(widthPercent) || 100)); + image.style.width = `${width}%`; + image.style.maxWidth = "100%"; + image.style.height = "auto"; + image.style.float = "none"; + image.style.marginTop = "0"; + image.style.marginBottom = "0"; + image.dataset.rustboxAlign = alignment; + if (alignment === "center") { + image.style.display = "block"; + image.style.marginLeft = "auto"; + image.style.marginRight = "auto"; + } else if (alignment === "right") { + image.style.display = "block"; + image.style.marginLeft = "auto"; + image.style.marginRight = "0px"; + } else if (alignment === "left") { + image.style.display = "block"; + image.style.marginLeft = "0px"; + image.style.marginRight = "auto"; + } else { + image.style.display = "inline-block"; + image.style.marginLeft = "0"; + image.style.marginRight = "0"; + } + } + + function imageCropValues() { + const value = (id) => Math.max(0, Math.min(45, Number($(id)?.value) || 0)); + return { top:value("#image-crop-top"), right:value("#image-crop-right"), bottom:value("#image-crop-bottom"), left:value("#image-crop-left") }; + } + + function updateImageEditOutputs() { + const width = Number($("#image-edit-width")?.value || 100); + const widthOut = $("#image-edit-width-value"); + if (widthOut) widthOut.textContent = `${width}%`; + for (const side of ["top", "right", "bottom", "left"]) { + const input = $(`#image-crop-${side}`); + const output = $(`#image-crop-${side}-value`); + if (input && output) output.textContent = `${input.value}%`; + } + } + + function drawInlineImageCropPreview() { + const bitmap = state.imageEditorBitmap; + const canvas = $("#image-edit-preview"); + if (!bitmap || !canvas) return; + const crop = imageCropValues(); + const sx = Math.round(bitmap.width * crop.left / 100); + const sy = Math.round(bitmap.height * crop.top / 100); + const sw = Math.max(1, Math.round(bitmap.width * (100 - crop.left - crop.right) / 100)); + const sh = Math.max(1, Math.round(bitmap.height * (100 - crop.top - crop.bottom) / 100)); + const scale = Math.min(1, 760 / sw, 560 / sh); + canvas.width = Math.max(1, Math.round(sw * scale)); + canvas.height = Math.max(1, Math.round(sh * scale)); + const ctx = canvas.getContext("2d"); + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.drawImage(bitmap, sx, sy, sw, sh, 0, 0, canvas.width, canvas.height); + updateImageEditOutputs(); + } + + function resetImageCropControls() { + for (const side of ["top", "right", "bottom", "left"]) { + const input = $(`#image-crop-${side}`); + if (input) input.value = "0"; + } + drawInlineImageCropPreview(); + } + + async function decodeInlineImageBlob(blob) { + if (typeof createImageBitmap === "function") return createImageBitmap(blob); + const url = URL.createObjectURL(blob); + try { + const image = new Image(); + image.decoding = "async"; + await new Promise((resolve, reject) => { image.onload = resolve; image.onerror = () => reject(new Error("Could not decode image")); image.src = url; }); + return image; + } finally { + URL.revokeObjectURL(url); + } + } + + async function openInlineImageEditor(image = state.composeSelectedImage) { + if (!image?.dataset?.rustboxCid) return toast("Select an inline image first", "info"); + setSelectedInlineImage(image); + const cid = image.dataset.rustboxCid; + const attachment = state.composeAttachments.find((item) => String(item.id) === String(cid)); + if (!attachment) return toast("The inline image attachment is no longer available", "error"); + try { + state.imageEditorBitmap?.close?.(); + const response = await fetch(composeAttachmentContentUrl(ensureDraftId(), cid), { credentials:"same-origin", cache:"no-store" }); + if (!response.ok) throw new Error(`Could not load image (${response.status})`); + state.imageEditorBlob = await response.blob(); + state.imageEditorBitmap = await decodeInlineImageBlob(state.imageEditorBlob); + const editorWidth = Math.max(1, $("#compose-editor")?.clientWidth || image.parentElement?.clientWidth || image.clientWidth || 1); + const stylePercent = /^\d+(?:\.\d+)?%$/.test(image.style.width || "") ? Number.parseFloat(image.style.width) : null; + const measuredPercent = Math.round((image.getBoundingClientRect().width / editorWidth) * 100 / 5) * 5; + $("#image-edit-width").value = String(Math.max(10, Math.min(100, stylePercent || measuredPercent || 100))); + $("#image-edit-align").value = inlineImageAlignment(image); + resetImageCropControls(); + const cropControls = $("#image-crop-controls"); + const isGif = String(attachment.content_type || state.imageEditorBlob.type || "").toLowerCase() === "image/gif"; + cropControls?.classList.toggle("image-crop-disabled", isGif); + cropControls?.querySelectorAll("input").forEach((input) => { input.disabled = isGif; }); + const help = $("#image-edit-help"); + if (help) help.textContent = isGif + ? "Size and position are available. Cropping animated GIF files is disabled so the animation is not flattened." + : "Resizing changes only the displayed size. Cropping creates a new CID image and replaces the previous inline attachment."; + drawInlineImageCropPreview(); + $("#image-edit-dialog").showModal(); + } catch (error) { + toast(error.message || "Could not open the image editor", "error"); + } + } + + function croppedImageFilename(filename, mime) { + const base = String(filename || "image").replace(/\.[^.]+$/, "") || "image"; + const ext = mime === "image/jpeg" ? "jpg" : mime === "image/webp" ? "webp" : "png"; + return `${base}-crop.${ext}`; + } + + async function cropInlineImageToFile(bitmap, sourceBlob, crop, filename) { + const sx = Math.round(bitmap.width * crop.left / 100); + const sy = Math.round(bitmap.height * crop.top / 100); + const sw = Math.max(1, Math.round(bitmap.width * (100 - crop.left - crop.right) / 100)); + const sh = Math.max(1, Math.round(bitmap.height * (100 - crop.top - crop.bottom) / 100)); + if (sw * sh > 40_000_000) throw new Error("The crop is too large to process safely in the browser"); + const canvas = document.createElement("canvas"); + canvas.width = sw; + canvas.height = sh; + const ctx = canvas.getContext("2d"); + ctx.drawImage(bitmap, sx, sy, sw, sh, 0, 0, sw, sh); + const sourceType = String(sourceBlob?.type || "").toLowerCase(); + const outputType = ["image/jpeg", "image/webp", "image/png"].includes(sourceType) ? sourceType : "image/png"; + const blob = await new Promise((resolve, reject) => canvas.toBlob((value) => value ? resolve(value) : reject(new Error("Could not create cropped image")), outputType, outputType === "image/png" ? undefined : 0.92)); + return new File([blob], croppedImageFilename(filename, outputType), { type:outputType, lastModified:Date.now() }); + } + + async function applyInlineImageEditor(event) { + event.preventDefault(); + const image = state.composeSelectedImage; + const bitmap = state.imageEditorBitmap; + if (!image?.dataset?.rustboxCid || !bitmap) return; + const width = Number($("#image-edit-width").value || 100); + const alignment = $("#image-edit-align").value || "inline"; + const crop = imageCropValues(); + const hasCrop = Object.values(crop).some((value) => value > 0); + const oldCid = image.dataset.rustboxCid; + const oldAttachment = state.composeAttachments.find((item) => String(item.id) === String(oldCid)); + const applyButton = $("#image-edit-apply"); + setBusy(applyButton, true); + try { + if (hasCrop) { + const type = String(oldAttachment?.content_type || state.imageEditorBlob?.type || "").toLowerCase(); + if (type === "image/gif") throw new Error("Cropping animated GIF files is disabled to preserve animation"); + const file = await cropInlineImageToFile(bitmap, state.imageEditorBlob, crop, oldAttachment?.filename || image.alt || "image"); + const uploaded = await uploadComposeFiles([file], { announce:false }); + const replacement = uploaded[0]; + if (!replacement) throw new Error("Could not upload the cropped image"); + state.inlineAttachmentIds.add(String(replacement.id)); + image.dataset.rustboxCid = replacement.id; + image.src = composeAttachmentContentUrl(ensureDraftId(), replacement.id); + image.alt = replacement.filename || image.alt || "image"; + state.inlineAttachmentIds.delete(String(oldCid)); + await deleteComposeAttachment(oldCid); + } + applyInlineImageDisplay(image, width, alignment); + setSelectedInlineImage(image); + scheduleDraftSave(); + $("#image-edit-dialog").close(); + toast(hasCrop ? "Image cropped" : "Image layout updated", "success"); + } catch (error) { + toast(error.message || "Could not update image", "error"); + } finally { + setBusy(applyButton, false); + } + } + + function scheduleUnusedInlineAttachmentCleanup() { + clearTimeout(state.inlineImageCleanupTimer); + state.inlineImageCleanupTimer = setTimeout(cleanupUnusedInlineAttachments, 3500); + } + + async function cleanupUnusedInlineAttachments() { + if (!state.composeDraftId || !state.inlineAttachmentIds.size) return; + const used = new Set($$("#compose-editor img[data-rustbox-cid]").map((image) => String(image.dataset.rustboxCid || "")).filter(Boolean)); + const unused = [...state.inlineAttachmentIds].filter((id) => !used.has(String(id))); + if (!unused.length) return; + let changed = false; + for (const id of unused) { + try { + await request(`/drafts/${encodeURIComponent(state.composeDraftId)}/attachments/${encodeURIComponent(id)}`, { method:"DELETE" }); + state.composeAttachments = state.composeAttachments.filter((attachment) => String(attachment.id) !== String(id)); + state.inlineAttachmentIds.delete(String(id)); + changed = true; + } catch (error) { + console.warn("Could not remove unused inline attachment", id, error); + } + } + if (changed) renderComposeAttachments(); + } + + function editorRangeFromPoint(x, y) { + const editor = $("#compose-editor"); + if (!editor) return null; + if (document.caretRangeFromPoint) { + const range = document.caretRangeFromPoint(x, y); + if (range && editor.contains(range.commonAncestorContainer.nodeType === Node.ELEMENT_NODE ? range.commonAncestorContainer : range.commonAncestorContainer.parentElement)) return range; + } + if (document.caretPositionFromPoint) { + const position = document.caretPositionFromPoint(x, y); + if (position?.offsetNode) { + const range = document.createRange(); + range.setStart(position.offsetNode, position.offset); + range.collapse(true); + if (editor.contains(position.offsetNode.nodeType === Node.ELEMENT_NODE ? position.offsetNode : position.offsetNode.parentElement)) return range; + } + } + return captureEditorRange(editor); + } + + async function uploadInlineImage(files, insertionRange = null) { + const images = [...(files || [])].filter((item) => /^image\/(png|jpeg|gif|webp)$/i.test(item.type)); + if (!images.length) return toast("Choose a PNG, JPEG, GIF or WebP image", "error"); + const uploaded = await uploadComposeFiles(images); + if (!uploaded.length) return; + setComposeMode("html"); + const editor = $("#compose-editor"); + restoreEditorRange(insertionRange, editor); + const draftId = ensureDraftId(); + let lastInsertedId = null; + for (const attachment of uploaded) { + const previewUrl = composeAttachmentContentUrl(draftId, attachment.id); + state.inlineAttachmentIds.add(String(attachment.id)); + lastInsertedId = String(attachment.id); + const html = `${escapeHtml(attachment.filename || `; + document.execCommand("insertHTML", false, html); + } + if (lastInsertedId) { + const inserted = [...editor.querySelectorAll("img[data-rustbox-cid]")].reverse().find((image) => String(image.dataset.rustboxCid) === lastInsertedId); + if (inserted) setSelectedInlineImage(inserted); + } + scheduleDraftSave(); + } + + function applyRecoveredDraft(draft, sourceLabel = "Saved draft") { + state.composeSignatureGeneration += 1; + state.composeDraftId = draft.id || null; + state.composeAttachments = Array.isArray(draft.attachments) ? draft.attachments : []; + state.inlineAttachmentIds = new Set(); + setSelectedInlineImage(null); + state.composeReplyMeta = { + inReplyTo: draft.in_reply_to || null, + references: draft.references || null, + }; + $("#compose-to").value = draft.to || ""; + $("#compose-cc").value = draft.cc || ""; + $("#compose-bcc").value = draft.bcc || ""; + $("#compose-subject").value = draft.subject || ""; + $("#compose-priority").value = ["high", "normal", "low"].includes(draft.priority) ? draft.priority : "normal"; + $("#compose-mdn").checked = Boolean(draft.request_mdn); + $("#compose-pgp-mode").value = ["none","sign","encrypt","sign-encrypt"].includes(draft.pgp_mode) ? draft.pgp_mode : "none"; + $("#compose-pgp-inline").checked = Boolean(draft.pgp_inline); + $("#compose-pgp-attach-key").checked = Boolean(draft.pgp_attach_public_key); + $("#compose-pgp-attach-recipient-keys").checked = Boolean(draft.pgp_attach_recipient_keys); + $("#compose-pgp-passphrase").value = ""; + updateComposePgpUi(); + const mode = draft.compose_format || (draft.html_body ? "html" : "text"); + setComposeMode(mode); + const editor = $("#compose-editor"); + if (mode === "html" && draft.html_body) editor.innerHTML = hydrateComposeHtml(draft.html_body, state.composeAttachments, state.composeDraftId); + else editor.textContent = draft.text_body || ""; + renderComposeAttachments(); + state.composeDirty = sourceLabel === "Browser recovery"; + state.composeNeedsRemoteSync = sourceLabel === "Browser recovery"; + if (state.composeDirty) storeLocalDraftSnapshot(); else clearLocalDraftSnapshot(); + $("#compose-title").textContent = "Recovered draft"; + $("#compose-status").textContent = `${sourceLabel}${draft.updated_at ? ` · ${draft.updated_at}` : ""}`; + setTimeout(() => editor.focus(), 20); + } + + async function recoverDraft() { + if (state.composeDirty && draftHasContent()) { + const saved = await saveCurrentDraft(true, true); + if (!saved) return toast("Save the current draft before recovering another one", "error"); + } + const localDrafts = readLocalDraftSnapshots(); + let serverDrafts = []; + try { + const data = await request("/drafts", { method:"GET" }); + serverDrafts = data.drafts || []; + } catch (error) { + if (!localDrafts.length) return toast(error.message, "error"); + } + const options = []; + for (const local of localDrafts) { + const localSubject = local.subject || "(no subject)"; + options.push({ value:`local:${local.id}`, label:`Browser recovery — ${localSubject}` }); + } + for (const draft of serverDrafts.slice(0, 50)) { + const subject = draft.subject || "(no subject)"; + const recipient = draft.to || "no recipient"; + options.push({ value:`server:${draft.id}`, label:`${subject} — ${recipient}` }); + } + if (!options.length) return toast("No recoverable drafts found", "info"); + const selected = await appSelect("Recover draft", options, { value:options[0].value, confirmLabel:"Recover" }); + if (!selected) return; + if (selected.startsWith("local:")) { + const id = selected.slice("local:".length); + const local = localDrafts.find((draft) => draft.id === id); + if (!local) return toast("Browser recovery snapshot is no longer available", "error"); + applyRecoveredDraft(local, "Browser recovery"); + toast("Recovered unsaved browser draft", "success"); + return; + } + const id = selected.slice("server:".length); + try { + const draft = await request(`/drafts/${encodeURIComponent(id)}`, { method:"GET" }); + applyRecoveredDraft(draft, "Saved draft"); + toast("Draft recovered", "success"); + } catch (error) { toast(error.message, "error"); } + } + + async function closeComposer() { + clearTimeout(state.draftSaveTimer); + clearTimeout(state.inlineImageCleanupTimer); + await cleanupUnusedInlineAttachments(); + if (state.composeUploading) return toast("Wait for the attachment upload to finish", "warning"); + if (state.composeNeedsRemoteSync && draftHasContent()) { + const saved = await saveCurrentDraft(true, true, true); + if (!saved) toast("Draft kept in this browser for recovery", "warning", 5200); + } else if (state.composeDirty && draftHasContent()) { + const saved = await saveCurrentDraft(true, true); + if (!saved) toast("Draft kept in this browser for recovery", "warning", 5200); + } + if ($("#compose-dialog").open) $("#compose-dialog").close(); + state.composeSignatureGeneration += 1; + } + + function attachmentReminderNeeded() { + if (state.prefs.attachmentReminder === false) return false; + if (state.composeAttachments.some((attachment) => !state.inlineAttachmentIds.has(String(attachment.id)))) return false; + const text = `${$("#compose-subject").value} ${$("#compose-editor").innerText}`.toLowerCase(); + return /\b(attach(ed|ment|ments)?|załącz(am|nik|niki|niku|one|ony)?|zalacz(am|nik|niki|niku|one|ony)?)\b/i.test(text); + } + + async function deleteComposeAttachment(id) { + if (!state.composeDraftId) return; + try { + await request(`/drafts/${encodeURIComponent(state.composeDraftId)}/attachments/${encodeURIComponent(id)}`, { method:"DELETE" }); + state.composeAttachments = state.composeAttachments.filter((a) => a.id !== id); + state.inlineAttachmentIds.delete(String(id)); + if (state.composeSelectedImage?.dataset?.rustboxCid === String(id)) setSelectedInlineImage(null); + $("#compose-editor")?.querySelectorAll("img[data-rustbox-cid]").forEach((image) => { + if (image.dataset.rustboxCid === id) image.remove(); + }); + renderComposeAttachments(); + scheduleDraftSave(); + } catch (error) { toast(error.message, "error"); } + } + + function selectIdentityForMessage(detail) { + const select = $("#compose-from"); + if (!select || !detail || !state.identities.length) return; + const addresses = new Set(); + for (const entry of [...(detail.to_addresses || []), ...(detail.cc_addresses || [])]) { + if (entry?.address) addresses.add(String(entry.address).trim().toLowerCase()); + } + const raw = String(detail.raw_headers || ""); + for (const match of raw.matchAll(/^(?:delivered-to|envelope-to):\s*(.+)$/gim)) { + const value = match[1] || ""; + const email = value.match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i)?.[0]; + if (email) addresses.add(email.toLowerCase()); + } + const identity = state.identities.find((candidate) => addresses.has(String(candidate.email || "").trim().toLowerCase())); + if (identity) select.value = String(identity.id); + } + + function setComposeFullscreen(enabled, persist = true) { + const dialog = $("#compose-dialog"); + const button = $("#compose-fullscreen"); + if (!dialog || !button) return; + dialog.classList.toggle("compose-fullscreen", Boolean(enabled)); + button.setAttribute("aria-pressed", enabled ? "true" : "false"); + button.title = enabled ? "Exit full screen" : "Full screen"; + button.textContent = enabled ? "↙" : "⛶"; + if (persist) { + try { localStorage.setItem("rustbox.composeFullscreen", enabled ? "1" : "0"); } catch (_) {} + } + } + + function toggleComposeFullscreen() { + setComposeFullscreen(!$("#compose-dialog").classList.contains("compose-fullscreen")); + } + + function openCompose(mode = "new") { + if (mode === "forward" && state.prefs.forwardMode === "attachment") { + void forwardAsAttachment(); + return; + } + const detail = state.currentDetail; + clearTimeout(state.draftSaveTimer); + $("#compose-form").reset(); + renderComposeIdentities(); + if (detail && ["reply", "reply-all", "forward"].includes(mode)) selectIdentityForMessage(detail); + state.composeDraftId = null; + state.composeAttachments = []; + state.composeSignatureGeneration += 1; + state.inlineAttachmentIds = new Set(); + setSelectedInlineImage(null); + state.composeDirty = false; + state.composeNeedsRemoteSync = false; + state.composeReplyMeta = null; + setComposeUploadProgress(false); + renderComposeAttachments(); + $("#compose-error").classList.add("hidden"); + $("#compose-status").textContent = ""; + $("#compose-title").textContent = mode === "reply" ? "Reply" : mode === "reply-all" ? "Reply all" : mode === "forward" ? "Forward" : "New message"; + const editor = $("#compose-editor"); + editor.replaceChildren(); + editor.spellcheck = state.prefs.spellcheckEnabled !== false; + setComposeMode(state.prefs.composeFormat || "html"); + $("#compose-priority").value = ["high", "normal", "low"].includes(state.prefs.defaultPriority) ? state.prefs.defaultPriority : "normal"; + $("#compose-mdn").checked = Boolean(state.prefs.requestMdnDefault); + state.pgpRecipientStatus = []; + $("#compose-pgp-passphrase").value = ""; + updateComposePgpUi(true); + + if (detail && (mode === "reply" || mode === "reply-all")) { + state.composeReplyMeta = { inReplyTo: detail.message_id || null, references: buildReferences(detail) }; + $("#compose-to").value = detail.from_address || ""; + if (mode === "reply-all") { + const own = ownAddressSet(); + const cc = [...(detail.to_addresses || []), ...(detail.cc_addresses || [])] + .map((entry) => entry.address || "") + .filter((address) => address && address.toLowerCase() !== String(detail.from_address || "").toLowerCase() && !own.has(address.toLowerCase())); + $("#compose-cc").value = [...new Set(cc)].join(", "); + } + $("#compose-subject").value = /^re:/i.test(detail.subject || "") ? detail.subject : `Re: ${detail.subject || ""}`; + const quoteStyle = state.prefs.quoteStyle || "standard"; + const quote = quoteStyle === "none" ? null : el("div", "compose-quote"); + if (quote) { + const compact = quoteStyle === "compact"; + if ($("#compose-mode").value === "html") { + const attribution = compact + ? `${escapeHtml(detail.from_address || "")} · ${escapeHtml(formatDate(detail.date, true))}` + : `On ${escapeHtml(formatDate(detail.date, true))}, ${escapeHtml(detail.from_address || "")} wrote:`; + quote.innerHTML = `
    ${attribution}
    ${quoteTextAsHtml(detail.text || "")}
    `; + } else { + const quoted = cleanPlainMessageText(detail.text || "").split("\n").map((line) => `> ${line}`).join("\n"); + const attribution = compact + ? `${detail.from_address || ""} · ${formatDate(detail.date, true)}` + : `On ${formatDate(detail.date, true)}, ${detail.from_address || ""} wrote:`; + quote.textContent = `\n\n${attribution}\n${quoted}`; + } + } + const entry = el("div", "compose-reply-entry"); + entry.innerHTML = "
    "; + if (state.prefs.replyPosition === "below" && quote) { editor.append(quote, entry); } + else { editor.append(entry); if (quote) editor.append(quote); } + } + if (detail && mode === "forward") { + $("#compose-subject").value = /^fwd:/i.test(detail.subject || "") ? detail.subject : `Fwd: ${detail.subject || ""}`; + const quote = el("div", "compose-quote"); + if ($("#compose-mode").value === "html") { + quote.innerHTML = `
    Forwarded message
    From: ${escapeHtml(detail.from_address || "")}
    Date: ${escapeHtml(formatDate(detail.date, true))}
    Subject: ${escapeHtml(detail.subject || "")}

    ${quoteTextAsHtml(detail.text || "")}
    `; + } else { + quote.textContent = `\n\nForwarded message\nFrom: ${detail.from_address || ""}\nDate: ${formatDate(detail.date, true)}\nSubject: ${detail.subject || ""}\n\n${cleanPlainMessageText(detail.text || "")}`; + } + editor.append(quote); + } + insertIdentitySignature(editor, mode); + let fullScreen = $("#compose-dialog").classList.contains("compose-fullscreen"); + try { fullScreen = localStorage.getItem("rustbox.composeFullscreen") === "1"; } catch (_) {} + setComposeFullscreen(fullScreen, false); + $("#compose-dialog").showModal(); + setTimeout(() => (mode === "new" ? $("#compose-to") : editor).focus(), 30); + } + + function buildReferences(detail) { + if (!detail) return null; + const values = [detail.references, detail.message_id].filter(Boolean); + return values.length ? values.join(" ") : null; + } + + async function sendMessage(event) { + event.preventDefault(); + if (state.composeUploading) return toast("Wait for the attachment upload to finish", "warning"); + clearTimeout(state.inlineImageCleanupTimer); + await cleanupUnusedInlineAttachments(); + const send = $("#send-btn"); + const errorBox = $("#compose-error"); + errorBox.classList.add("hidden"); + if (attachmentReminderNeeded() && !await appConfirm("The message mentions an attachment but no file is attached. Send anyway?", { title:"Missing attachment", confirmLabel:"Send anyway" })) return; + const pgpMode = $("#compose-pgp-mode").value || "none"; + const pgpInline = Boolean($("#compose-pgp-inline").checked); + if (pgpInline && state.composeAttachments.length) return toast("Inline OpenPGP cannot carry attachments. Disable inline mode to use PGP/MIME.", "warning"); + if (pgpInline && $("#compose-mode").value !== "text" && !await appConfirm("Inline OpenPGP sends the plain-text representation and drops HTML/Markdown formatting. Continue?", { title:"Inline OpenPGP", confirmLabel:"Continue" })) return; + if (pgpMode === "encrypt" || pgpMode === "sign-encrypt") { + const statuses = await checkComposePgpRecipients(false); + const missing = statuses.filter((item) => !item.hasKey); + const untrusted = statuses.filter((item) => item.hasKey && item.trust === "untrusted"); + const notTrusted = state.pgpPrefs.requireTrustedEncryption + ? statuses.filter((item) => item.hasKey && !["trusted", "verified"].includes(item.trust)) + : []; + if (missing.length) return toast(`Missing OpenPGP key for ${missing.map((item) => item.email).join(", ")}`, "warning"); + if (untrusted.length) return toast(`OpenPGP key marked untrusted for ${untrusted.map((item) => item.email).join(", ")}`, "warning"); + if (notTrusted.length) return toast(`OpenPGP policy requires trusted keys: ${notTrusted.map((item) => item.email).join(", ")}`, "warning"); + } + if ($("#compose-pgp-attach-recipient-keys").checked) { + const visibleRecipients = composePgpVisibleRecipients(); + if (!visibleRecipients.length) return toast("Add at least one To/Cc recipient before attaching recipient OpenPGP keys", "warning"); + const statuses = await checkComposePgpRecipients(false, visibleRecipients); + if (!statuses.some((item) => item.hasKey)) return toast("No OpenPGP public key is available for any To/Cc recipient", "warning"); + } + setBusy(send, true); + const editor = $("#compose-editor"); + try { + await request("/messages/send", { + method: "POST", + body: JSON.stringify({ + to: parseRecipients($("#compose-to").value), + cc: parseRecipients($("#compose-cc").value), + bcc: parseRecipients($("#compose-bcc").value), + subject: $("#compose-subject").value, + text_body: editor.innerText, + html_body: serializeComposeHtml(editor), + in_reply_to: state.composeReplyMeta?.inReplyTo || null, + references: state.composeReplyMeta?.references || null, + draft_id: state.composeDraftId, + from_identity_id: $("#compose-from").value ? Number($("#compose-from").value) : null, + request_mdn: Boolean($("#compose-mdn").checked), + priority: $("#compose-priority").value || "normal", + pgp_mode: $("#compose-pgp-mode").value || "none", + pgp_inline: Boolean($("#compose-pgp-inline").checked), + pgp_passphrase: $("#compose-pgp-passphrase").value || "", + pgp_attach_public_key: Boolean($("#compose-pgp-attach-key").checked), + pgp_attach_recipient_keys: Boolean($("#compose-pgp-attach-recipient-keys").checked), + }), + }); + $("#compose-dialog").close(); + clearLocalDraftSnapshot(state.composeDraftId); + state.composeDraftId = null; + state.composeAttachments = []; + state.inlineAttachmentIds = new Set(); + setSelectedInlineImage(null); + state.composeReplyMeta = null; + state.composeDirty = false; + state.composeNeedsRemoteSync = false; + state.pgpRecipientStatus = []; + $("#compose-pgp-passphrase").value = ""; + toast("Message sent", "success"); + await loadFolders(); + } catch (error) { + state.composeDirty = true; + state.composeNeedsRemoteSync = true; + storeLocalDraftSnapshot(); + await saveCurrentDraft(true, true, true); + errorBox.textContent = error.message; + errorBox.classList.remove("hidden"); + } finally { setBusy(send, false); } + } + + async function updateFlag(flag, add) { + if (!state.currentDetail) return; + const detail = state.currentDetail; + await request(`/messages/${encodeURIComponent(detail.folder)}/${detail.uid}/flags`, { + method: "PATCH", + body: JSON.stringify({ flags: [flag], add }), + }); + const flags = new Set(detail.flags || []); + add ? flags.add(flag) : flags.delete(flag); + detail.flags = [...flags]; + const item = state.messages.find((msg) => msg.uid === detail.uid && (msg.folder || state.folder) === detail.folder); + if (item) item.flags = detail.flags.join(" "); + renderMessages(); + renderMessageDetail(detail); + } + + async function toggleUnread() { + if (!state.currentDetail) return; + const seen = (state.currentDetail.flags || []).includes("\\Seen"); + try { await updateFlag("\\Seen", !seen); toast(seen ? "Marked unread" : "Marked read", "success"); } catch (error) { toast(error.message, "error"); } + } + + async function toggleFlagged() { + if (!state.currentDetail) return; + const flagged = (state.currentDetail.flags || []).includes("\\Flagged"); + try { await updateFlag("\\Flagged", !flagged); toast(flagged ? "Flag removed" : "Message flagged", "success"); } catch (error) { toast(error.message, "error"); } + } + + function moveUndoAction(result, label = "Undo") { + const undo = result?.undo_move; + if (!undo?.from_folder || !undo?.to_folder || !Number.isFinite(Number(undo.uid))) return null; + return { + label, + onClick: async () => { + await request("/messages/move", { + method: "POST", + body: JSON.stringify({ from_folder: undo.from_folder, to_folder: undo.to_folder, uid: Number(undo.uid) }), + }); + state.selected = null; + state.currentDetail = null; + state.selectedMessages.clear(); + closeReader(); + await refreshFolderListOnly(); + await refreshCurrentMessageView(); + toast("Move undone", "success"); + }, + }; + } + + function bulkMoveUndoAction(undoMoves) { + if (!Array.isArray(undoMoves) || !undoMoves.length) return null; + return { + label: "Undo", + onClick: async () => { + let restored = 0; + for (const undo of [...undoMoves].reverse()) { + await request("/messages/move", { + method: "POST", + body: JSON.stringify({ from_folder: undo.from_folder, to_folder: undo.to_folder, uid: Number(undo.uid) }), + }); + restored += 1; + } + state.selected = null; + state.currentDetail = null; + state.selectedMessages.clear(); + closeReader(); + await refreshFolderListOnly(); + await refreshCurrentMessageView(); + toast(`Undo completed: ${restored} message${restored === 1 ? "" : "s"}`, "success"); + }, + }; + } + + async function moveMessageByDrag(fromFolder, uid, targetFolder) { + if (!targetFolder || targetFolder === fromFolder) return; + const result = await request("/messages/move", { method:"POST", body:JSON.stringify({ from_folder:fromFolder, to_folder:targetFolder, uid }) }); + const wasCurrentFolder = state.folder === fromFolder; + if (wasCurrentFolder) { + state.messages = state.messages.filter((message) => !(message.uid === uid && (message.folder || state.folder) === fromFolder)); + state.selectedMessages.delete(`${fromFolder}\u0000${uid}`); + state.total = Math.max(0, state.total - 1); + if (state.currentDetail?.uid === uid && state.currentDetail?.folder === fromFolder) { + state.selected = null; + state.currentDetail = null; + closeReader(); + } + renderMessages(); + } + await refreshFolderListOnly(); + toast(`Moved to ${targetFolder}`, "success", 9000, moveUndoAction(result)); + } + + async function moveCurrentTo(targetFolder, successMessage = null) { + if (!state.currentDetail || !targetFolder || targetFolder === state.currentDetail.folder) return; + const detail = state.currentDetail; + try { + const result = await request("/messages/move", { + method: "POST", + body: JSON.stringify({ from_folder: detail.folder, to_folder: targetFolder, uid: detail.uid }), + }); + state.messages = state.messages.filter((msg) => !(msg.uid === detail.uid && (msg.folder || state.folder) === detail.folder)); + state.selectedMessages.delete(`${detail.folder}\u0000${detail.uid}`); + state.total = Math.max(0, state.total - 1); + state.selected = null; + state.currentDetail = null; + closeReader(); + renderMessages(); + await refreshFolderListOnly(); + toast(successMessage || `Moved to ${targetFolder}`, "success", 9000, moveUndoAction(result)); + } catch (error) { toast(error.message, "error"); } + } + + async function copyCurrentTo(targetFolder) { + if (!state.currentDetail || !targetFolder || targetFolder === state.currentDetail.folder) return; + const detail = state.currentDetail; + try { + await request("/messages/copy", { method:"POST", body:JSON.stringify({ from_folder:detail.folder, to_folder:targetFolder, uid:detail.uid }) }); + await refreshFolderListOnly(); + toast(`Copied to ${targetFolder}`); + } catch (error) { toast(error.message, "error"); } + } + + const SPAM_DEFAULT_FOLDER_KEY = "rustbox.defaultSpamFolder"; + + function configuredBrowserSpamFolder() { + const preferred = (localStorage.getItem(SPAM_DEFAULT_FOLDER_KEY) || "").trim(); + if (!preferred) return null; + const match = state.folders.find((folder) => String(folder.name || "").toLowerCase() === preferred.toLowerCase()); + return match?.name || null; + } + + function autoDetectSpecialFolder(role) { + const attr = { sent:"\\Sent", drafts:"\\Drafts", trash:"\\Trash", junk:"\\Junk", archive:"\\Archive" }[role]; + if (attr) { + const byAttr = state.folders.find((folder) => (folder.attributes || []).some((value) => String(value).toLowerCase() === attr.toLowerCase())); + if (byAttr) return byAttr.name; + } + const names = { sent:["sent"], drafts:["draft"], trash:["trash","deleted"], junk:["junk","spam"], archive:["archive"] }[role] || []; + return state.folders.find((folder) => names.some((name) => String(folder.name || "").toLowerCase().includes(name)))?.name || null; + } + + function detectSpecialFolder(role) { + const configured = state.specialFolders?.[role]; + if (configured && state.folders.some((folder) => folder.name === configured)) return configured; + if (role === "junk") { + const browserDefault = configuredBrowserSpamFolder(); + if (browserDefault) return browserDefault; + } + return autoDetectSpecialFolder(role); + } + + function isJunkFolder(folder) { + const junk = detectSpecialFolder("junk"); + return Boolean(junk && String(folder || "").toLowerCase() === junk.toLowerCase()); + } + + function inboxFolder() { + return state.folders.find((folder) => String(folder.name || "").toUpperCase() === "INBOX")?.name || "INBOX"; + } + + async function archiveCurrent() { + const archive = detectSpecialFolder("archive"); + if (!archive) return toast("Configure an Archive folder in Settings → Folders", "error"); + await moveCurrentTo(archive, "Message archived"); + } + + async function junkCurrent() { + if (!state.currentDetail) return; + const junk = detectSpecialFolder("junk"); + if (!junk) return toast("Configure a Junk folder in Settings → Folders", "error"); + if (isJunkFolder(state.currentDetail.folder)) { + await moveCurrentTo(inboxFolder(), tr("marked_not_spam", "Marked as not spam")); + } else { + await moveCurrentTo(junk, tr("marked_spam", "Marked as spam")); + } + } + + async function deleteCurrent() { + if (!state.selected) return; + const { folder, uid } = state.selected; + const trash = detectSpecialFolder("trash"); + if (trash && folder !== trash) { + await moveCurrentTo(trash); + return; + } + if (!await appConfirm("Permanently delete this message?", { title:"Delete message permanently", confirmLabel:"Delete permanently", danger:true })) return; + try { + await request(`/messages/${encodeURIComponent(folder)}/${uid}`, { method: "DELETE" }); + state.messages = state.messages.filter((msg) => !(msg.uid === uid && (msg.folder || state.folder) === folder)); + state.selectedMessages.delete(`${folder}\u0000${uid}`); + state.total = Math.max(0, state.total - 1); + state.selected = null; + state.currentDetail = null; + closeReader(); + renderMessages(); + await refreshFolderListOnly(); + toast("Message deleted", "success"); + } catch (error) { toast(error.message, "error"); } + } + + async function performBulk(action, targetFolder = null) { + const items = selectedMessages(); + if (!items.length) return; + const bulkJunkToInbox = action === "junk" && items.every((message) => isJunkFolder(message.folder || state.folder)); + if (action === "archive" && !detectSpecialFolder("archive")) { + toast("Configure an Archive folder in Settings → Folders", "error"); + return; + } + if (action === "junk" && !bulkJunkToInbox && !detectSpecialFolder("junk")) { + toast("Configure a Junk folder in Settings → Folders", "error"); + return; + } + const permanentDelete = action === "delete" && items.every((message) => (message.folder || state.folder) === detectSpecialFolder("trash")); + if (permanentDelete && !await appConfirm(`Permanently delete ${items.length} selected messages?`, { title:"Delete selected messages", confirmLabel:"Delete permanently", danger:true })) return; + let completed = 0; + const undoMoves = []; + let movedOperations = 0; + try { + for (const message of items) { + const folder = message.folder || state.folder; + if (action === "read" || action === "unread" || action === "flag" || action === "unflag") { + const flag = action === "read" || action === "unread" ? "\\Seen" : "\\Flagged"; + const add = action === "read" || action === "flag"; + await request(`/messages/${encodeURIComponent(folder)}/${message.uid}/flags`, { method:"PATCH", body:JSON.stringify({ flags:[flag], add }) }); + } else if (action === "move" || action === "archive" || action === "junk" || action === "delete") { + let destination = targetFolder; + if (action === "archive") destination = detectSpecialFolder("archive"); + if (action === "junk") destination = bulkJunkToInbox ? inboxFolder() : detectSpecialFolder("junk"); + if (action === "delete") destination = detectSpecialFolder("trash"); + if (destination && destination !== folder) { + movedOperations += 1; + const result = await request("/messages/move", { method:"POST", body:JSON.stringify({ from_folder:folder, to_folder:destination, uid:message.uid }) }); + if (result?.undo_move) undoMoves.push(result.undo_move); + } else if (action === "delete") { + await request(`/messages/${encodeURIComponent(folder)}/${message.uid}`, { method:"DELETE" }); + } + } else if (action === "copy" && targetFolder && targetFolder !== folder) { + await request("/messages/copy", { method:"POST", body:JSON.stringify({ from_folder:folder, to_folder:targetFolder, uid:message.uid }) }); + } + completed += 1; + $("#bulk-count").textContent = `${completed}/${items.length}`; + } + state.selectedMessages.clear(); + state.searchAllResults = null; + await refreshFolderListOnly(); + await refreshCurrentMessageView(); + const bulkMessages = { junk: bulkJunkToInbox ? tr("marked_not_spam", "Marked as not spam") : tr("marked_spam", "Marked as spam"), archive: "Archived", read: "Marked read", unread: "Marked unread", flag: "Flagged", unflag: "Unflagged", delete: "Deleted", move: "Moved", copy: "Copied" }; + const undoAction = movedOperations > 0 && undoMoves.length === movedOperations ? bulkMoveUndoAction(undoMoves) : null; + toast(`${bulkMessages[action] || "Updated"}: ${completed} message${completed === 1 ? "" : "s"}`, "success", undoAction ? 10000 : null, undoAction); + } catch (error) { + toast(`${completed}/${items.length} completed: ${error.message}`, "error"); + state.searchAllResults = null; + await refreshCurrentMessageView(); + } + } + + async function refreshFolderListOnly() { + try { + const data = await request("/folders", { method: "GET" }); + state.folders = data.folders || []; + updateUnreadAppBadge(); + renderFolders(); + } catch (_) {} + } + + async function refreshCurrentMessageView() { + if (state.searchContext) { + await runSearch(new URLSearchParams(state.searchContext.params), state.searchContext.label, { restoreScrollTop:state.searchContext.scrollTop }); + } else { + await loadMessages(true); + } + } + + async function runSearch(params, label = "Search", options = {}) { + const cleanParams = new URLSearchParams(params.toString()); + cleanParams.delete("all"); + const queryString = cleanParams.toString(); + if (!queryString) return; + $("#folder-title").textContent = label; + $("#message-list").innerHTML = `
    Searching…
    `; + state.selectedMessages.clear(); + state.searchAllResults = null; + state.searchContext = { params:queryString, label, scrollTop:Number(options.restoreScrollTop) || 0 }; + saveActiveSearchContext(); + renderBulkToolbar(); + try { + const data = await request(`/search?${queryString}`, { method: "GET" }); + state.messages = data.results || []; + state.total = data.total_count || state.messages.length; + renderMessages(); + $("#folder-title").textContent = label; + $("#message-count").textContent = `${state.total} result${state.total === 1 ? "" : "s"}`; + const restoreScrollTop = Number(options.restoreScrollTop) || 0; + if (restoreScrollTop > 0) requestAnimationFrame(() => { $("#message-list").scrollTop = restoreScrollTop; }); + } catch (error) { toast(error.message, "error"); } + } + + async function searchMail(event) { + event.preventDefault(); + const query = $("#search-input").value.trim(); + if (!query) { + clearActiveSearchContext(); + return loadMessages(true); + } + const params = new URLSearchParams({ q: query, limit: "100" }); + await runSearch(params, `Search: ${query}`); + } + + function populateAdvancedSearchFolders() { + const select = $("#search-folder"); + const current = select.value; + select.replaceChildren(new Option("All folders", "")); + for (const folder of orderedFolders()) select.add(new Option(folder.name, folder.name)); + if ([...select.options].some((option) => option.value === current)) select.value = current; + } + + function buildAdvancedSearchParams() { + const params = new URLSearchParams(); + const set = (name, value) => { if (String(value || "").trim()) params.set(name, String(value).trim()); }; + set("q", $("#search-text").value); + set("folder", $("#search-folder").value); + set("from", $("#search-from").value); + set("to", $("#search-to").value); + set("subject", $("#search-subject").value); + const dateFrom = $("#search-date-from").value; + const dateTo = $("#search-date-to").value; + if (dateFrom) params.set("date_from", String(Math.floor(new Date(`${dateFrom}T00:00:00`).getTime() / 1000))); + if (dateTo) params.set("date_to", String(Math.floor(new Date(`${dateTo}T23:59:59`).getTime() / 1000))); + const read = $("#search-read").value; + if (read) params.set("unread", read === "unread" ? "true" : "false"); + set("flagged", $("#search-flagged").value); + set("has_attachment", $("#search-attachment").value); + params.set("sort", $("#search-sort").value || "date_desc"); + params.set("limit", "250"); + return params; + } + + function resetAdvancedSearch() { + $("#advanced-search-form").reset(); + $("#search-sort").value = "date_desc"; + populateAdvancedSearchFolders(); + } + + async function openAdvancedSearch() { + populateAdvancedSearchFolders(); + $("#advanced-search-dialog").showModal(); + await loadSavedSearches(); + } + + async function submitAdvancedSearch(event) { + event.preventDefault(); + const params = buildAdvancedSearchParams(); + const meaningful = [...params.keys()].some((key) => !["sort", "limit"].includes(key)); + if (!meaningful) return toast("Choose at least one search criterion", "error"); + $("#advanced-search-dialog").close(); + await runSearch(params, "Advanced search"); + } + + async function loadSavedSearches() { + try { + const data = await request("/search/saved", { method:"GET" }); + state.savedSearches = data.searches || []; + renderSavedSearches(); + } catch (error) { toast(error.message, "error"); } + } + + function renderSavedSearches() { + const list = $("#saved-search-list"); + list.replaceChildren(); + if (!state.savedSearches.length) { list.append(el("div", "empty-list", "No saved searches")); return; } + for (const saved of state.savedSearches) { + const row = el("div", "saved-search-item"); + const name = el("strong", "", saved.name); + const run = el("button", "button quiet", "Run"); run.type = "button"; + const remove = el("button", "button quiet danger", "Delete"); remove.type = "button"; + run.addEventListener("click", async () => { $("#advanced-search-dialog").close(); await runSearch(new URLSearchParams(saved.query_string), `Search: ${saved.name}`); }); + remove.addEventListener("click", async () => { + if (!await appConfirm(`Delete saved search “${saved.name}”?`, { title:"Delete saved search", confirmLabel:"Delete", danger:true })) return; + try { + await request(`/search/saved/${saved.id}`, { method:"DELETE" }); + await loadSavedSearches(); + toast("Saved search deleted", "success"); + } catch (error) { toast(error.message, "error"); } + }); + row.append(name, run, remove); list.append(row); + } + } + + async function saveAdvancedSearch() { + const params = buildAdvancedSearchParams(); + const meaningful = [...params.keys()].some((key) => !["sort", "limit"].includes(key)); + if (!meaningful) return toast("Choose at least one search criterion", "error"); + const name = await appPrompt("Save search", { message:"Name this search so you can run it again later.", placeholder:"Search name", confirmLabel:"Save" }); + if (!name?.trim()) return; + try { + await request("/search/saved", { method:"POST", body:JSON.stringify({ name:name.trim(), query_string:params.toString() }) }); + await loadSavedSearches(); + toast("Search saved"); + } catch (error) { toast(error.message, "error"); } + } + + function openFolderDialog() { + $("#current-folder-name").value = state.folder; + $("#new-folder-name").value = ""; + const current = state.folders.find((folder) => folder.name === state.folder); + $("#subscribe-folder-btn").textContent = current?.is_subscribed ? "Unsubscribe" : "Subscribe"; + $("#folder-dialog").showModal(); + } + + async function createFolder() { + const name = $("#new-folder-name").value.trim(); + if (!name) return; + try { + await request("/folders", { method: "POST", body: JSON.stringify({ name }) }); + $("#new-folder-name").value = ""; + await refreshFolderListOnly(); + toast("Folder created"); + } catch (error) { toast(error.message, "error"); } + } + + async function renameFolder() { + const current = state.folder; + const next = await appPrompt("Rename folder", { value:current, confirmLabel:"Rename", required:true }); + if (!next || next === current) return; + try { + await request(`/folders/${encodeURIComponent(current)}`, { method: "PATCH", body: JSON.stringify({ new_name: next }) }); + state.folder = next; + $("#current-folder-name").value = next; + await loadFolders(); + toast("Folder renamed"); + } catch (error) { toast(error.message, "error"); } + } + + async function deleteFolder() { + const current = state.folder; + if (!await appConfirm(`Delete folder “${current}”?`, { title:"Delete folder", confirmLabel:"Delete", danger:true })) return; + try { + await request(`/folders/${encodeURIComponent(current)}`, { method: "DELETE" }); + $("#folder-dialog").close(); + state.folder = "INBOX"; + await loadFolders(); + toast("Folder deleted"); + } catch (error) { toast(error.message, "error"); } + } + + function hideContextMenu() { + const menu = $("#context-menu"); + menu.classList.add("hidden"); + menu.replaceChildren(); + } + + function showContextMenu(x, y, items) { + const menu = $("#context-menu"); + menu.replaceChildren(); + for (const item of items) { + if (item === "separator") { menu.append(el("div", "separator")); continue; } + const button = el("button", item.danger ? "danger" : "", item.label); + button.type = "button"; + button.addEventListener("click", async () => { hideContextMenu(); await item.action(); }); + menu.append(button); + } + menu.classList.remove("hidden"); + const rect = menu.getBoundingClientRect(); + menu.style.left = `${Math.max(6, Math.min(x, innerWidth - rect.width - 6))}px`; + menu.style.top = `${Math.max(6, Math.min(y, innerHeight - rect.height - 6))}px`; + } + + async function ensureMessageOpen(message) { + const folder = message.folder || state.folder; + if (!state.currentDetail || state.currentDetail.uid !== message.uid || state.currentDetail.folder !== folder) { + await openMessage(folder, message.uid); + } + } + + async function promptFolder(label, exclude = null) { + const options = orderedFolders().map((folder) => folder.name).filter((name) => name !== exclude); + if (!options.length) return null; + return appSelect(label, options.map((name) => ({ value:name, label:name })), { value:options[0], confirmLabel:"Choose folder" }); + } + + function openMessageContextMenu(x, y, message) { + const flags = Array.isArray(message.flags) ? message.flags.join(" ") : String(message.flags || ""); + const seen = flags.includes("\\Seen"); + const flagged = flags.includes("\\Flagged"); + showContextMenu(x, y, [ + { label: "Open", action: () => openMessage(message.folder || state.folder, message.uid) }, + { label: "Reply", action: async () => { await ensureMessageOpen(message); openCompose("reply"); } }, + { label: "Reply all", action: async () => { await ensureMessageOpen(message); openCompose("reply-all"); } }, + { label: "Forward", action: async () => { await ensureMessageOpen(message); openCompose("forward"); } }, + { label: "Forward as attachment", action: async () => { await ensureMessageOpen(message); await forwardAsAttachment(); } }, + "separator", + { label: seen ? "Mark unread" : "Mark read", action: async () => { await ensureMessageOpen(message); await updateFlag("\\Seen", !seen); } }, + { label: flagged ? "Unflag" : "Flag", action: async () => { await ensureMessageOpen(message); await updateFlag("\\Flagged", !flagged); } }, + "separator", + { label: "Archive", action: async () => { await ensureMessageOpen(message); await archiveCurrent(); } }, + { label: "Move to…", action: async () => { await ensureMessageOpen(message); const target = await promptFolder("Move to folder", message.folder || state.folder); if (target) await moveCurrentTo(target); } }, + { label: "Copy to…", action: async () => { await ensureMessageOpen(message); const target = await promptFolder("Copy to folder", message.folder || state.folder); if (target) await copyCurrentTo(target); } }, + { label: "Add label…", action: async () => { const tag = await promptTag("Add label"); if (tag) await addTagToMessage(message, tag); } }, + { label: "Remove label…", action: async () => { const attached = state.tags.filter((tag) => (message.tags || []).some((item) => item.id === tag.id)); const tag = await promptTag("Remove label", attached); if (tag) await removeTagFromMessage(message, tag); } }, + { label: isJunkFolder(message.folder || state.folder) ? tr("mark_not_spam", "Mark as not spam") : tr("mark_spam", "Mark as spam"), action: async () => { await ensureMessageOpen(message); await junkCurrent(); } }, + { label: "Create filter from sender", action: async () => { await ensureMessageOpen(message); await createFilterFromCurrentMessage("sender"); } }, + { label: "Create filter from domain", action: async () => { await ensureMessageOpen(message); await createFilterFromCurrentMessage("domain"); } }, + { label: "Create filter from subject", action: async () => { await ensureMessageOpen(message); await createFilterFromCurrentMessage("subject"); } }, + { label: "Add sender to contacts", action: async () => { await ensureMessageOpen(message); await addSenderToContacts(); } }, + { label: "Search messages from sender", action: async () => { await ensureMessageOpen(message); await searchFromCurrentSender(); } }, + { label: "Unsubscribe from mailing list", action: async () => { await ensureMessageOpen(message); await openListUnsubscribe(); } }, + "separator", + { label: "Show headers", action: async () => { await ensureMessageOpen(message); showHeaders(); } }, + { label: "Show source", action: async () => { await ensureMessageOpen(message); await showMessageSource(); } }, + { label: "Copy Message-ID", action: async () => { await ensureMessageOpen(message); await copyMessageId(); } }, + { label: "Copy sender address", action: async () => { await ensureMessageOpen(message); await copySenderAddress(); } }, + { label: "Download .eml", action: async () => { await ensureMessageOpen(message); downloadEml(); } }, + { label: "Print", action: async () => { await ensureMessageOpen(message); printCurrentMessage(); } }, + { label: "Delete", danger: true, action: async () => { await ensureMessageOpen(message); await deleteCurrent(); } }, + ]); + } + + async function configureFolderFlaggedOnTop(folder) { + try { + const current = await request(`/folders/${encodeURIComponent(folder.name)}/preferences`, { method:"GET" }); + const value = current.flagged_on_top === true ? "on" : current.flagged_on_top === false ? "off" : "inherit"; + const choice = await appSelect("Flagged messages on top", [ + { value:"inherit", label:`Use mailbox default (${current.effective_flagged_on_top ? "on" : "off"})` }, + { value:"on", label:"Always on for this folder" }, + { value:"off", label:"Always off for this folder" }, + ], { message:`Folder: ${folder.name}`, value, confirmLabel:"Save" }); + if (choice === null) return; + const flagged_on_top = choice === "inherit" ? null : choice === "on"; + await request(`/folders/${encodeURIComponent(folder.name)}/preferences`, { method:"PUT", body:JSON.stringify({ flagged_on_top }) }); + if (state.folder === folder.name && !state.searchContext) await loadMessages(true); + toast("Folder list preference saved", "success"); + } catch (error) { toast(error.message, "error"); } + } + + function openFolderContextMenu(x, y, folder) { + const subscribed = Boolean(folder.is_subscribed); + showContextMenu(x, y, [ + { label: "Open", action: async () => { state.folder = folder.name; renderFolders(); await loadMessages(true); } }, + { label: "Create subfolder", action: async () => { + const delimiter = folder.delimiter || "/"; + const child = await appPrompt("Create subfolder", { message:`Parent folder: ${folder.name}`, placeholder:"Subfolder name", confirmLabel:"Create", required:true }); + if (!child) return; + try { + await request("/folders", { method:"POST", body:JSON.stringify({ name:`${folder.name}${delimiter}${child}` }) }); + await refreshFolderListOnly(); + toast("Subfolder created", "success"); + } catch (error) { toast(error.message, "error"); } + } }, + { label: subscribed ? "Unsubscribe" : "Subscribe", action: () => setFolderSubscription(folder.name, !subscribed) }, + { label: "Flagged on top…", action: () => configureFolderFlaggedOnTop(folder) }, + { label: "Mark all read", action: () => markFolderAllRead(folder.name) }, + { label: "Import .eml…", action: () => importEmlToFolder(folder.name) }, + { label: "Empty folder", danger: true, action: () => emptyFolder(folder.name) }, + "separator", + { label: "Properties", action: () => showFolderProperties(folder) }, + { label: "Rename", action: async () => { state.folder = folder.name; await renameFolder(); } }, + { label: "Delete", danger: true, action: async () => { state.folder = folder.name; await deleteFolder(); } }, + ]); + } + + function importEmlToFolder(folderName) { + const input = document.createElement("input"); + input.type = "file"; + input.accept = ".eml,message/rfc822"; + input.multiple = false; + input.addEventListener("change", async () => { + const file = input.files?.[0]; + if (!file) return; + if (file.size > 50 * 1024 * 1024) return toast(".eml exceeds the 50 MB import limit", "error"); + const form = new FormData(); + form.append("file", file, file.name || "message.eml"); + try { + await request(`/folders/${encodeURIComponent(folderName)}/import-eml`, { method:"POST", body:form }); + if (state.folder === folderName) await loadMessages(true); + await refreshFolderListOnly(); + toast(`Imported ${file.name || "message.eml"}`); + } catch (error) { toast(error.message, "error"); } + }, { once:true }); + input.click(); + } + + function showFolderProperties(folder) { + const roles = Object.entries(state.specialFolders || {}).filter(([, value]) => value === folder.name).map(([key]) => key); + const dialog = document.createElement("dialog"); + dialog.className = "dialog"; + const card = el("div", "dialog-card folder-properties-card"); + const header = el("header", "dialog-header"); + header.append(el("strong", "", "Folder properties")); + const close = el("button", "button quiet", "Close"); close.type="button"; close.addEventListener("click", () => dialog.close()); + header.append(close); + const body = el("div", "folder-properties-list"); + const rows = [ + ["Name", folder.name || ""], + ["Subscribed", folder.is_subscribed ? "Yes" : "No"], + ["Messages", String(folder.total_count ?? folder.exists ?? 0)], + ["Unread", String(folder.unread_count ?? 0)], + ["Delimiter", folder.delimiter || "(none)"], + ["Special role", roles.length ? roles.join(", ") : "None"], + ["Attributes", Array.isArray(folder.attributes) ? folder.attributes.join(" ") : String(folder.attributes || "None")], + ]; + for (const [label,value] of rows) { const row=el("div","folder-property-row"); row.append(el("span","muted",label),el("strong","",value)); body.append(row); } + card.append(header, body); dialog.append(card); document.body.append(dialog); + dialog.addEventListener("close", () => dialog.remove(), { once:true }); + dialog.showModal(); + } + + async function markFolderAllRead(name) { + try { + await request(`/folders/${encodeURIComponent(name)}/mark-all-read`, { method:"POST", body:"{}" }); + if (state.folder === name) await loadMessages(true); + await refreshFolderListOnly(); + toast("All messages marked read"); + } catch (error) { toast(error.message, "error"); } + } + + async function emptyFolder(name) { + if (!await appConfirm(`Permanently remove every message from “${name}”?`, { title:"Empty folder", confirmLabel:"Empty folder", danger:true })) return; + try { + await request(`/folders/${encodeURIComponent(name)}/empty`, { method:"POST", body:"{}" }); + if (state.folder === name) { state.selected=null; state.currentDetail=null; closeReader(); await loadMessages(true); } + await refreshFolderListOnly(); + toast("Folder emptied"); + } catch (error) { toast(error.message, "error"); } + } + + async function setFolderSubscription(name, subscribed) { + try { + await request(`/folders/${encodeURIComponent(name)}/subscribe`, { method:"PATCH", body:JSON.stringify({ subscribed }) }); + await refreshFolderListOnly(); + toast(subscribed ? "Folder subscribed" : "Folder unsubscribed"); + } catch (error) { toast(error.message, "error"); } + } + + async function toggleCurrentFolderSubscription() { + const folder = state.folders.find((item) => item.name === state.folder); + if (!folder) return; + await setFolderSubscription(folder.name, !folder.is_subscribed); + } + + function rawHeaderValue(name, raw = state.currentDetail?.raw_headers || "") { + if (!raw) return ""; + const unfolded = raw.replace(/\r?\n[ \t]+/g, " "); + const prefix = `${String(name).toLowerCase()}:`; + const line = unfolded.split(/\r?\n/).find((item) => item.toLowerCase().startsWith(prefix)); + return line ? line.slice(line.indexOf(":") + 1).trim() : ""; + } + + async function openListUnsubscribe() { + const detail = state.currentDetail; + if (!detail) return; + const value = rawHeaderValue("List-Unsubscribe"); + if (!value) return toast("This message does not provide List-Unsubscribe", "error"); + const targets = [...value.matchAll(/<([^>]+)>/g)].map((match) => match[1].trim()).filter(Boolean); + const httpsTarget = targets.find((item) => /^https:\/\//i.test(item)); + const mailtoTarget = targets.find((item) => /^mailto:/i.test(item)); + const oneClick = rawHeaderValue("List-Unsubscribe-Post") + .split(",") + .some((item) => item.trim().toLowerCase() === "list-unsubscribe=one-click"); + + if (oneClick && httpsTarget) { + const confirmed = await appConfirm("Unsubscribe from this mailing list?", { + title: "One-click unsubscribe", + confirmLabel: "Unsubscribe", + danger: true, + }); + if (!confirmed) return; + try { + await request(`/messages/${encodeURIComponent(detail.folder)}/${detail.uid}/unsubscribe`, { method:"POST" }); + toast("Unsubscribe request sent", "success"); + } catch (error) { + toast(error.message, "error"); + } + return; + } + + const target = httpsTarget || mailtoTarget; + if (!target) return toast("No supported HTTPS or mailto unsubscribe target", "error"); + if (/^https:\/\//i.test(target)) { + const confirmed = await appConfirm("This mailing list does not support one-click unsubscribe. Open its external unsubscribe page?", { + title: "Open unsubscribe page", + confirmLabel: "Open page", + }); + if (confirmed) window.open(target, "_blank", "noopener,noreferrer"); + } else { + window.location.href = target; + } + } + + function showHeaders() { + const detail = state.currentDetail; + if (!detail) return; + const summary = $("#headers-summary"); + summary.replaceChildren(); + const to = (detail.to_addresses || []).map((entry) => entry.name ? `${entry.name} <${entry.address}>` : entry.address).join(", "); + const cc = (detail.cc_addresses || []).map((entry) => entry.name ? `${entry.name} <${entry.address}>` : entry.address).join(", "); + const fields = [ + ["From", detail.from_name ? `${detail.from_name} <${detail.from_address}>` : detail.from_address], + ["To", to], ["Cc", cc], ["Date", detail.date ? formatDate(detail.date, true) : rawHeaderValue("Date")], + ["Message-ID", detail.message_id || rawHeaderValue("Message-ID")], + ["In-Reply-To", detail.in_reply_to || rawHeaderValue("In-Reply-To")], + ["References", detail.references || rawHeaderValue("References")], + ["Return-Path", rawHeaderValue("Return-Path")], ["Delivered-To", rawHeaderValue("Delivered-To")], + ["List-ID", rawHeaderValue("List-ID")], ["List-Unsubscribe", rawHeaderValue("List-Unsubscribe")], + ["List-Unsubscribe-Post", rawHeaderValue("List-Unsubscribe-Post")], + ["User-Agent", rawHeaderValue("User-Agent") || rawHeaderValue("X-Mailer")], + ["Authentication-Results", rawHeaderValue("Authentication-Results")], ["Content-Type", rawHeaderValue("Content-Type")], + ]; + for (const [label, value] of fields) { + if (!String(value || "").trim()) continue; + const row = el("div", "header-summary-row"); + row.append(el("span", "muted", label), el("code", "", String(value))); + summary.append(row); + } + $("#headers-content").textContent = detail.raw_headers || "No raw headers available."; + $("#headers-dialog").showModal(); + } + + async function fetchEmlBlob(detail = state.currentDetail) { + if (!detail) throw new Error("No message selected"); + const headers = new Headers(); + if (state.accountId) headers.set("X-Active-Account", state.accountId); + const response = await fetch(apiPath(`/messages/${encodeURIComponent(detail.folder)}/${detail.uid}/source`), { credentials:"include", headers }); + if (!response.ok) throw new Error(`Message source failed (${response.status})`); + return response.blob(); + } + + async function showMessageSource() { + const detail = state.currentDetail; + if (!detail) return; + try { + const blob = await fetchEmlBlob(detail); + const source = await blob.text(); + const dialog = document.createElement("dialog"); + dialog.className = "dialog"; + const card = el("div", "dialog-card source-card"); + const header = el("header", "dialog-header"); + header.append(el("strong", "", "Message source")); + const close = el("button", "button quiet", "Close"); close.type="button"; close.addEventListener("click", () => dialog.close()); + header.append(close); + const pre = el("pre", "message-source-content"); pre.textContent = source; + const actions = el("footer", "dialog-actions"); + const copy = el("button", "button quiet", "Copy source"); copy.type="button"; + copy.addEventListener("click", async () => { try { await navigator.clipboard.writeText(source); toast("Message source copied"); } catch (_) { toast("Could not copy source", "error"); } }); + const download = el("button", "button primary", "Download .eml"); download.type="button"; download.addEventListener("click", downloadEml); + actions.append(copy, download); + card.append(header, pre, actions); dialog.append(card); document.body.append(dialog); + dialog.addEventListener("close", () => dialog.remove(), { once:true }); + dialog.showModal(); + } catch (error) { toast(error.message, "error"); } + } + + function printCurrentMessage() { + const detail = state.currentDetail; + if (!detail) return toast("Select a message before printing", "warning"); + const to = (detail.to_addresses || []).map((entry) => entry.name ? `${entry.name} <${entry.address}>` : entry.address).join(", "); + const body = detail.html ? sanitizeMessageHtml(detail.html) : `
    ${escapeHtml(cleanPlainMessageText(detail.text || ""))}
    `; + const frame = document.createElement("iframe"); + frame.className = "print-frame"; + frame.setAttribute("aria-hidden", "true"); + const html = `${escapeHtml(detail.subject || "Message")}

    ${escapeHtml(detail.subject || "(no subject)")}

    From${escapeHtml(detail.from_name ? `${detail.from_name} <${detail.from_address}>` : detail.from_address || "")}
    To${escapeHtml(to)}
    Date${escapeHtml(formatDate(detail.date, true))}
    ${body}`; + frame.addEventListener("load", () => { + const cleanup = () => frame.remove(); + try { + frame.contentWindow?.addEventListener("afterprint", cleanup, { once:true }); + frame.contentWindow?.focus(); + frame.contentWindow?.print(); + toast("Print dialog opened", "info"); + setTimeout(cleanup, 60000); + } catch (_) { + cleanup(); + toast("Could not open the print dialog", "error"); + } + }, { once:true }); + frame.srcdoc = html; + document.body.append(frame); + } + + async function copyMessageId() { + const value = state.currentDetail?.message_id; + if (!value) return toast("Message-ID is not available", "error"); + try { await navigator.clipboard.writeText(value); toast("Message-ID copied"); } + catch (_) { toast("Could not copy Message-ID", "error"); } + } + + async function copySenderAddress() { + const value = state.currentDetail?.from_address; + if (!value) return; + try { await navigator.clipboard.writeText(value); toast("Sender address copied"); } + catch (_) { toast("Could not copy address", "error"); } + } + + async function downloadEml() { + const detail = state.currentDetail; + if (!detail) return; + try { + const blob = await fetchEmlBlob(detail); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = `message-${detail.uid}.eml`; + link.click(); + setTimeout(() => URL.revokeObjectURL(url), 30000); + toast("Downloading .eml", "info"); + } catch (error) { toast(error.message, "error"); } + } + + async function forwardAsAttachment() { + const detail = state.currentDetail; + if (!detail) return; + try { + const blob = await fetchEmlBlob(detail); + openCompose("new"); + $("#compose-title").textContent = "Forward as attachment"; + $("#compose-subject").value = /^fwd:/i.test(detail.subject || "") ? detail.subject : `Fwd: ${detail.subject || ""}`; + const safe = String(detail.subject || `message-${detail.uid}`).replace(/[\\/:*?"<>|]+/g, "_").slice(0, 120) || `message-${detail.uid}`; + const file = new File([blob], `${safe}.eml`, { type:"message/rfc822" }); + await uploadComposeFiles([file]); + } catch (error) { toast(error.message, "error"); } + } + + + function openCurrentSenderMenu(x, y) { + const detail = state.currentDetail; + if (!detail?.from_address) return; + showContextMenu(x, y, [ + { label: "Compose to sender", action: () => { openCompose("new"); $("#compose-to").value = detail.from_name ? `${detail.from_name} <${detail.from_address}>` : detail.from_address; $("#compose-subject").value = ""; } }, + { label: "Add to contacts", action: addSenderToContacts }, + { label: "Copy address", action: copySenderAddress }, + { label: "Search messages", action: searchFromCurrentSender }, + "separator", + { label: "Create filter", action: async () => createFilterFromCurrentMessage("sender") }, + ]); + } + + async function addSenderToContacts() { + const detail = state.currentDetail; + if (!detail?.from_address) return; + try { + await request("/contacts", { method:"POST", body:JSON.stringify({ id:null, email:detail.from_address, name:detail.from_name || "", company:"", notes:"", is_favorite:false, source:"message" }) }); + toast("Sender added to contacts"); + } catch (error) { toast(error.message, "error"); } + } + + async function searchFromCurrentSender() { + const detail = state.currentDetail; + if (!detail?.from_address) return; + const query = `from:${detail.from_address}`; + $("#search-input").value = query; + await runSearch(new URLSearchParams({ q:query, limit:"100" }), `From: ${detail.from_address}`); + } + + async function copyHeaders() { + if (!state.currentDetail?.raw_headers) return; + try { await navigator.clipboard.writeText(state.currentDetail.raw_headers); toast("Headers copied"); } + catch (_) { toast("Could not copy headers", "error"); } + } + + function quickFilterSource(detail, kind) { + const sender = String(detail?.from_address || "").trim(); + const domain = sender.includes("@") ? sender.split("@").pop() : sender; + const subject = String(detail?.subject || "").trim(); + if (kind === "domain") return { kind:"domain", value:domain, match:"is" }; + if (kind === "subject") return { kind:"subject", value:subject, match:"contains" }; + return { kind:"sender", value:sender, match:"is" }; + } + + function buildQuickFilterRule({ kind, value, match, action, target, stop, markRead = false }) { + if (!value.trim()) throw new Error("Filter value is required"); + const matchToken = match === "contains" ? ":contains" : ":is"; + let condition; + if (kind === "domain") condition = `address :domain ${matchToken} "from" "${sieveQuote(value)}"`; + else if (kind === "subject") condition = `header ${matchToken} "subject" "${sieveQuote(value)}"`; + else condition = `address ${matchToken} "from" "${sieveQuote(value)}"`; + + const requirements = new Set(); + let command; + if (action === "move") { + if (!target) throw new Error("Choose a destination folder"); + requirements.add("fileinto"); + command = `fileinto "${sieveQuote(target)}";`; + } else if (action === "copy") { + if (!target) throw new Error("Choose a destination folder"); + requirements.add("fileinto"); + requirements.add("copy"); + command = `fileinto :copy "${sieveQuote(target)}";`; + } else if (action === "mark-read") { + requirements.add("imap4flags"); + command = 'addflag "\\Seen";'; + } else if (action === "flag") { + requirements.add("imap4flags"); + command = 'addflag "\\Flagged";'; + } else if (action === "discard") command = "discard;"; + else command = "keep;"; + + if (markRead && action !== "mark-read") requirements.add("imap4flags"); + const ruleId = newSieveVisualRuleId(); + const commands = [command, markRead && action !== "mark-read" ? 'addflag "\\Seen";' : "", stop ? "stop;" : ""].filter(Boolean).join("\n "); + return { + requirements:[...requirements], + rule:`# RUSTBOX-RULE-BEGIN ${ruleId}\n# Created from message\nif ${condition} {\n ${commands}\n}\n# RUSTBOX-RULE-END`, + }; + } + + async function prepareQuickFilterAdvanced(config) { + if ($("#settings-dialog").open) $("#settings-dialog").close(); + selectSettingsTab("sieve"); + $("#settings-dialog").showModal(); + await loadSieve(); + const scripts = state.sieveScripts || []; + const targetScript = config.script || scripts.find((script) => script.active)?.name || scripts.find((script) => script.name === "rustbox")?.name || "rustbox"; + if (scripts.some((script) => script.name === targetScript)) await openSieveScript(targetScript); + else { + $("#sieve-name").value = targetScript; + $("#sieve-editor").value = ""; + renderManagedSieveRules(); + } + state.sieveEditingRuleId = null; + const suggestedName = config.kind === "subject" ? "subject_filter" : config.kind === "domain" ? "sender_domain" : "sender_filter"; + $("#sieve-builder-name").value = suggestedName; + $("#sieve-builder-enabled").checked = true; + $("#sieve-builder-stop").checked = Boolean(config.stop); + $("#sieve-builder-condition").value = config.kind === "domain" ? "from-domain" : config.kind === "subject" ? "subject" : "from"; + $("#sieve-builder-match").value = config.match; + $("#sieve-builder-value").value = config.value; + $("#sieve-builder-action").value = config.action === "flag" ? "add-flag" : config.action; + $("#sieve-builder-target").value = config.action === "flag" ? "\\Flagged" : config.target || ""; + $("#sieve-builder-condition-2").value = ""; + $("#sieve-builder-condition-3").value = ""; + $("#sieve-builder-extra-conditions").replaceChildren(); + $("#sieve-builder-action-2").value = config.markRead && config.action !== "mark-read" ? "mark-read" : ""; + $("#sieve-builder-extra-actions").replaceChildren(); + updateSieveBuilder(); + $("#sieve-result").textContent = "Quick-filter values loaded. Review the rule, add it, validate, then save."; + showSieveRuleDialog("new"); + } + + async function createFilterFromCurrentMessage(kind = "sender") { + const detail = state.currentDetail; + if (!detail) return; + + let sieveInfo; + let scripts; + try { + [sieveInfo, scripts] = await Promise.all([ + request("/sieve/status", { method:"GET" }), + request("/sieve/scripts", { method:"GET" }), + ]); + state.sieveInfo = sieveInfo; + state.sieveScripts = scripts.scripts || []; + } catch (error) { + toast(`ManageSieve unavailable: ${error.message}`, "error"); + return; + } + + const source = quickFilterSource(detail, kind); + const activeScript = state.sieveScripts.find((script) => script.active); + const defaultScript = activeScript?.name || state.sieveScripts.find((script) => script.name === "rustbox")?.name || "rustbox"; + const defaultTarget = detectSpecialFolder("archive") || orderedFolders().find((folder) => folder.name !== (detail.folder || state.folder))?.name || ""; + const extensions = new Set((sieveInfo.extensions || []).map((value) => String(value).toLowerCase())); + + const dialog = el("dialog", "dialog quick-filter-dialog"); + const form = el("form", "dialog-card quick-filter-card"); + form.method = "dialog"; + const header = el("header", "dialog-header"); + header.append(el("strong", "", "Create filter from message")); + const close = el("button", "icon-button"); close.type = "button"; close.innerHTML = svgIcon("x"); close.setAttribute("aria-label", "Close"); + header.append(close); + const body = el("div", "quick-filter-body"); + const grid = el("div", "quick-filter-grid"); + + const makeField = (labelText, control) => { const label = el("label"); label.append(el("span", "", labelText), control); return label; }; + const condition = document.createElement("select"); + condition.add(new Option("Sender address", "sender")); + condition.add(new Option("Sender domain", "domain")); + condition.add(new Option("Subject", "subject")); + condition.value = source.kind; + const match = document.createElement("select"); + match.add(new Option("is exactly", "is")); + match.add(new Option("contains", "contains")); + match.value = source.match; + const value = document.createElement("input"); value.value = source.value; value.required = true; + const action = document.createElement("select"); + [ + ["Move to folder", "move", ["fileinto"]], + ["Copy to folder", "copy", ["fileinto", "copy"]], + ["Mark as read", "mark-read", ["imap4flags"]], + ["Flag message", "flag", ["imap4flags"]], + ["Discard", "discard", []], + ["Keep", "keep", []], + ].forEach(([label, actionValue, requires]) => { + const option = new Option(label, actionValue); + option.disabled = requires.some((requirement) => !extensions.has(requirement)); + action.add(option); + }); + if (action.options[0]?.disabled) action.value = [...action.options].find((option) => !option.disabled)?.value || "keep"; + const target = document.createElement("select"); + target.replaceChildren(...orderedFolders().map((folder) => new Option(folder.name, folder.name))); + target.value = defaultTarget; + const script = document.createElement("select"); + const scriptNames = new Set(state.sieveScripts.map((item) => item.name)); + for (const item of state.sieveScripts) script.add(new Option(`${item.name}${item.active ? " · active" : ""}`, item.name)); + if (!scriptNames.has("rustbox")) script.add(new Option("rustbox · new", "rustbox")); + script.value = defaultScript; + const stop = document.createElement("input"); stop.type = "checkbox"; stop.checked = true; + const markRead = document.createElement("input"); markRead.type = "checkbox"; markRead.disabled = !extensions.has("imap4flags"); + const activate = document.createElement("input"); activate.type = "checkbox"; activate.checked = !activeScript; + + const targetField = makeField("Destination folder", target); + grid.append( + makeField("Match", condition), makeField("Comparison", match), + makeField("Value", value), makeField("Action", action), + targetField, makeField("Sieve script", script) + ); + body.append(grid); + const toggles = el("div", "appearance-toggles quick-filter-toggles"); + const stopLabel = el("label", "checkbox-row"); stopLabel.append(stop, el("span", "", "Stop processing after this rule")); + const readLabel = el("label", "checkbox-row"); readLabel.append(markRead, el("span", "", "Also mark matching mail as read")); + if (markRead.disabled) readLabel.title = "Server does not advertise imap4flags"; + const activateLabel = el("label", "checkbox-row"); activateLabel.append(activate, el("span", "", "Activate selected script after saving")); + toggles.append(stopLabel, readLabel, activateLabel); + body.append(toggles); + const preview = el("div", "quick-filter-preview"); body.append(preview); + const hint = el("div", "muted quick-filter-hint"); body.append(hint); + + const actions = el("footer", "dialog-actions"); + const cancel = el("button", "button quiet", "Cancel"); cancel.type = "button"; + const advanced = el("button", "button quiet", "Advanced editor"); advanced.type = "button"; + const save = el("button", "button primary", "Create filter"); save.type = "submit"; + actions.append(cancel, advanced, save); + form.append(header, body, actions); dialog.append(form); document.body.append(dialog); + + const sourceValues = { + sender: quickFilterSource(detail, "sender"), + domain: quickFilterSource(detail, "domain"), + subject: quickFilterSource(detail, "subject"), + }; + const update = () => { + const needsFolder = ["move", "copy"].includes(action.value); + targetField.classList.toggle("hidden", !needsFolder); + const requires = action.value === "copy" ? ["fileinto", "copy"] : action.value === "move" ? ["fileinto"] : ["mark-read", "flag"].includes(action.value) ? ["imap4flags"] : []; + const missing = requires.filter((item) => !extensions.has(item)); + const conditionLabel = condition.value === "domain" ? "sender domain" : condition.value === "subject" ? "subject" : "sender"; + const actionLabel = action.selectedOptions[0]?.textContent || action.value; + const displayValue = value.value.trim() || "…"; + preview.textContent = `If ${conditionLabel} ${match.value === "contains" ? "contains" : "is"} “${displayValue}” → ${actionLabel}${needsFolder && target.value ? ` “${target.value}”` : ""}${markRead.checked && action.value !== "mark-read" ? " + mark as read" : ""}.`; + hint.textContent = missing.length + ? `Server does not advertise: ${missing.join(", ")}` + : `Will add this rule to “${script.value}”${activate.checked ? " and activate the script" : ""}. It applies to new incoming mail; existing messages are not changed.`; + save.disabled = missing.length > 0; + }; + condition.addEventListener("change", () => { const next = sourceValues[condition.value]; value.value = next.value; match.value = next.match; update(); }); + match.addEventListener("change", update); + value.addEventListener("input", update); + action.addEventListener("change", update); + target.addEventListener("change", update); + script.addEventListener("change", update); + activate.addEventListener("change", update); + markRead.addEventListener("change", update); + + let settled = false; + const cleanup = () => { if (settled) return; settled = true; if (dialog.open) dialog.close(); else dialog.remove(); }; + close.addEventListener("click", cleanup); + cancel.addEventListener("click", cleanup); + dialog.addEventListener("cancel", (event) => { event.preventDefault(); cleanup(); }); + dialog.addEventListener("close", () => dialog.remove(), { once:true }); + advanced.addEventListener("click", async () => { + const config = { kind:condition.value, match:match.value, value:value.value.trim(), action:action.value, target:target.value, script:script.value, markRead:markRead.checked }; + cleanup(); + await prepareQuickFilterAdvanced(config); + }); + form.addEventListener("submit", async (event) => { + event.preventDefault(); + if (!form.reportValidity()) return; + setBusy(save, true); + try { + const config = { kind:condition.value, match:match.value, value:value.value.trim(), action:action.value, target:target.value, stop:stop.checked, markRead:markRead.checked }; + const built = buildQuickFilterRule(config); + const existing = state.sieveScripts.find((item) => item.name === script.value); + let current = ""; + if (existing) current = (await request(`/sieve/scripts/${encodeURIComponent(script.value)}`, { method:"GET" })).content || ""; + const content = mergeSieveRequirements([current.trim(), built.rule].filter(Boolean).join("\n\n"), built.requirements); + const validation = await request("/sieve/check", { method:"POST", body:JSON.stringify({ content }) }); + if (!validation.valid) throw new Error(validation.error || "Filter validation failed"); + await request(`/sieve/scripts/${encodeURIComponent(script.value)}`, { method:"PUT", body:JSON.stringify({ content }) }); + if (activate.checked || existing?.active) await request(`/sieve/scripts/${encodeURIComponent(script.value)}/activate`, { method:"POST", body:"{}" }); + cleanup(); + toast(`Filter added to ${script.value}`, "success"); + } catch (error) { + hint.textContent = error.message; + toast(error.message, "error"); + } finally { setBusy(save, false); } + }); + + update(); + dialog.showModal(); + setTimeout(() => value.focus(), 20); + } + + async function openContacts() { + $("#contacts-dialog").showModal(); + await loadContactGroups(); + await loadContacts(); + } + + async function loadContactGroups() { + try { + const data = await request("/contact-groups", { method:"GET" }); + state.contactGroups = data.groups || []; + renderContactGroupFilter(); + renderContactGroupMembership($("#contact-id").value || null, []); + } catch (error) { toast(error.message, "error"); } + } + + function renderContactGroupFilter() { + const select = $("#contact-group-filter"); + const current = select.value; + select.replaceChildren(new Option("All contacts", "")); + for (const group of state.contactGroups) select.add(new Option(`${group.name} (${group.member_count || 0})`, group.id)); + if ([...select.options].some((option) => option.value === current)) select.value = current; + const hasGroup = Boolean(select.value); + $("#contact-group-rename").disabled = !hasGroup; + $("#contact-group-delete").disabled = !hasGroup; + } + + async function downloadContactExport() { + try { + const headers = new Headers(); + if (state.accountId) headers.set("X-Active-Account", state.accountId); + const response = await fetch(apiPath("/contacts/export"), { credentials: "include", headers }); + if (!response.ok) throw new Error(`Export failed (${response.status})`); + const blob = await response.blob(); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = "contacts.vcf"; + document.body.append(link); + link.click(); + link.remove(); + setTimeout(() => URL.revokeObjectURL(url), 1000); + toast("Calendar exported", "success"); + } catch (error) { toast(error.message, "error"); } + } + + async function importContactVcard(files) { + const file = files?.[0]; + if (!file) return; + if (file.size > 10 * 1024 * 1024) return toast("vCard file is too large", "error"); + const form = new FormData(); + form.append("file", file, file.name || "contacts.vcf"); + try { + const data = await request("/contacts/import", { method: "POST", body: form }); + toast(`vCard import: ${data.created || 0} created, ${data.updated || 0} updated, ${data.skipped || 0} skipped`); + await loadContacts($("#contact-search").value.trim()); + } catch (error) { toast(error.message, "error"); } + finally { $("#contact-import-file").value = ""; } + } + + async function showContactDuplicates() { + try { + const data = await request("/contacts/duplicates", { method:"GET" }); + const groups = data.duplicates || []; + const list = $("#contact-duplicate-list"); + list.replaceChildren(); + if (!groups.length) { + list.append(el("div", "empty-list", "No duplicate contacts found")); + } + for (const group of groups) { + const card = el("section", "duplicate-contact-group"); + card.append(el("strong", "", group.email)); + const rows = el("div", "duplicate-contact-options"); + for (const contact of group.contacts || []) { + const row = el("div", "duplicate-contact-row"); + const summary = el("div", "duplicate-contact-summary"); + summary.append(el("strong", "", contact.name || contact.email)); + summary.append(el("small", "muted", [contact.email, contact.company, contact.phone].filter(Boolean).join(" · "))); + const keep = el("button", "button quiet", "Keep this"); + keep.type = "button"; + keep.addEventListener("click", async () => { + const sources = (group.contacts || []).filter((item) => item.id !== contact.id); + if (!sources.length) return; + if (!await appConfirm(`Merge ${sources.length} duplicate contact(s) into ${contact.name || contact.email}?`, { title:"Merge duplicate contacts", confirmLabel:"Merge" })) return; + try { + let target = contact; + for (const source of sources) { + target = await request("/contacts/merge", { method:"POST", body:JSON.stringify({ target_id:target.id, source_id:source.id }) }); + } + toast("Contacts merged"); + await loadContacts($("#contact-search").value.trim()); + await showContactDuplicates(); + editContact(target); + } catch (error) { toast(error.message, "error"); } + }); + row.append(summary, keep); rows.append(row); + } + card.append(rows); list.append(card); + } + if (!$("#contact-duplicates-dialog").open) $("#contact-duplicates-dialog").showModal(); + } catch (error) { toast(error.message, "error"); } + } + + async function loadContacts(query = "") { + try { + const groupId = $("#contact-group-filter")?.value || ""; + let contacts; + if (groupId) { + const data = await request(`/contact-groups/${encodeURIComponent(groupId)}/members`, { method:"GET" }); + contacts = data.members || []; + if (query) { const needle=query.toLowerCase(); contacts = contacts.filter((contact) => `${contact.name} ${contact.email} ${contact.company}`.toLowerCase().includes(needle)); } + } else { + const data = await request(`/contacts?limit=500&offset=0${query ? `&q=${encodeURIComponent(query)}` : ""}`, { method:"GET" }); + contacts = data.contacts || []; + } + state.contacts = contacts; + renderContacts(); + } catch (error) { toast(error.message, "error"); } + } + + async function createContactGroup() { + const name = await appPrompt("New contact group", { placeholder:"Group name", confirmLabel:"Create", required:true }); + if (!name?.trim()) return; + try { await request("/contact-groups", { method:"POST", body:JSON.stringify({ name:name.trim() }) }); await loadContactGroups(); toast("Group created"); } + catch (error) { toast(error.message, "error"); } + } + + async function renameContactGroup() { + const id = $("#contact-group-filter").value; + const group = state.contactGroups.find((item) => item.id === id); + if (!group) return; + const name = await appPrompt("Rename contact group", { value:group.name, confirmLabel:"Rename", required:true }); + if (!name?.trim() || name.trim() === group.name) return; + try { await request(`/contact-groups/${encodeURIComponent(id)}`, { method:"PUT", body:JSON.stringify({ name:name.trim() }) }); await loadContactGroups(); toast("Group renamed"); } + catch (error) { toast(error.message, "error"); } + } + + async function deleteContactGroup() { + const id = $("#contact-group-filter").value; + const group = state.contactGroups.find((item) => item.id === id); + if (!group || !await appConfirm(`Delete group “${group.name}”? Contacts will be kept.`, { title:"Delete contact group", confirmLabel:"Delete", danger:true })) return; + try { await request(`/contact-groups/${encodeURIComponent(id)}`, { method:"DELETE" }); $("#contact-group-filter").value=""; await loadContactGroups(); await loadContacts($("#contact-search").value.trim()); toast("Group deleted"); } + catch (error) { toast(error.message, "error"); } + } + + async function loadContactMembership(contactId) { + if (!contactId) return renderContactGroupMembership(null, []); + try { + const data = await request(`/contacts/${encodeURIComponent(contactId)}/groups`, { method:"GET" }); + renderContactGroupMembership(contactId, (data.groups || []).map((group) => group.id)); + } catch (error) { toast(error.message, "error"); } + } + + function renderContactGroupMembership(contactId, selectedIds) { + const list = $("#contact-group-membership"); + if (!list) return; + const selected = new Set(selectedIds || []); + list.replaceChildren(); + if (!state.contactGroups.length) { list.append(el("span", "muted", "No groups")); return; } + for (const group of state.contactGroups) { + const label = el("label", "checkbox-row"); + const input = document.createElement("input"); input.type="checkbox"; input.checked=selected.has(group.id); input.disabled=!contactId; + input.addEventListener("change", async () => { + try { + if (input.checked) await request(`/contact-groups/${encodeURIComponent(group.id)}/members`, { method:"POST", body:JSON.stringify({ contact_id:contactId }) }); + else await request(`/contact-groups/${encodeURIComponent(group.id)}/members/${encodeURIComponent(contactId)}`, { method:"DELETE" }); + await loadContactGroups(); + } catch (error) { input.checked=!input.checked; toast(error.message, "error"); } + }); + label.append(input, el("span", "", group.name)); list.append(label); + } + } + + function renderContacts() { + const list = $("#contact-list"); + list.replaceChildren(); + if (!state.contacts.length) list.append(el("div", "empty-list", "No contacts")); + for (const contact of state.contacts) { + const button = el("button", "contact-item"); + button.type = "button"; + button.innerHTML = `${escapeHtml(contact.name || contact.email)}${escapeHtml(contact.email)}${contact.company ? ` · ${escapeHtml(contact.company)}` : ""}${contact.phone ? ` · ${escapeHtml(contact.phone)}` : ""}`; + button.addEventListener("click", () => editContact(contact)); + list.append(button); + } + } + + function editContact(contact = null) { + $("#contact-id").value = contact?.id || ""; + $("#contact-name").value = contact?.name || ""; + $("#contact-email").value = contact?.email || ""; + $("#contact-emails").value = (contact?.emails || []).join("\n"); + $("#contact-company").value = contact?.company || ""; + $("#contact-phone").value = contact?.phone || ""; + $("#contact-phones").value = (contact?.phones || []).join("\n"); + $("#contact-job-title").value = contact?.job_title || ""; + $("#contact-website").value = contact?.website || ""; + $("#contact-birthday").value = contact?.birthday || ""; + $("#contact-address").value = contact?.address || ""; + $("#contact-addresses").value = (contact?.addresses || []).join("\n"); + $("#contact-notes").value = contact?.notes || ""; + $("#contact-favorite").checked = Boolean(contact?.is_favorite); + $("#contact-delete").disabled = !contact; + $("#contact-compose").disabled = !contact?.email; + $("#contact-photo-upload").disabled = !contact?.id; + $("#contact-photo-delete").disabled = !contact?.id || !contact?.has_photo; + renderContactPhoto(contact); + loadContactMembership(contact?.id || null); + } + + function clearContactPhotoObjectUrl() { + if (state.contactPhotoUrl) URL.revokeObjectURL(state.contactPhotoUrl); + state.contactPhotoUrl = null; + } + + async function renderContactPhoto(contact) { + clearContactPhotoObjectUrl(); + const image = $("#contact-photo-preview"); + const placeholder = $("#contact-photo-placeholder"); + image.classList.add("hidden"); + image.removeAttribute("src"); + placeholder.classList.remove("hidden"); + if (!contact?.id || !contact?.has_photo) return; + try { + const headers = new Headers(); + if (state.accountId) headers.set("X-Active-Account", state.accountId); + const response = await fetch(apiPath(`/contacts/${encodeURIComponent(contact.id)}/photo`), { credentials:"include", headers }); + if (!response.ok) return; + const blob = await response.blob(); + state.contactPhotoUrl = URL.createObjectURL(blob); + image.src = state.contactPhotoUrl; + image.classList.remove("hidden"); + placeholder.classList.add("hidden"); + } catch (_) {} + } + + async function uploadContactPhoto(files) { + const id = $("#contact-id").value; + const file = files?.[0]; + if (!id || !file) return; + if (file.size > 2 * 1024 * 1024) { toast("Contact photo exceeds the 2 MB limit", "error"); $("#contact-photo-file").value=""; return; } + const form = new FormData(); + form.append("file", file, file.name || "photo"); + try { + await request(`/contacts/${encodeURIComponent(id)}/photo`, { method:"POST", body:form }); + const contact = state.contacts.find((item) => item.id === id); + if (contact) contact.has_photo = true; + $("#contact-photo-delete").disabled = false; + await renderContactPhoto({ id, has_photo:true }); + toast("Contact photo saved"); + } catch (error) { toast(error.message, "error"); } + finally { $("#contact-photo-file").value=""; } + } + + async function deleteContactPhoto() { + const id = $("#contact-id").value; + if (!id) return; + try { + await request(`/contacts/${encodeURIComponent(id)}/photo`, { method:"DELETE" }); + const contact = state.contacts.find((item) => item.id === id); + if (contact) contact.has_photo = false; + $("#contact-photo-delete").disabled = true; + await renderContactPhoto({ id, has_photo:false }); + toast("Contact photo removed"); + } catch (error) { toast(error.message, "error"); } + } + + async function saveContact(event) { + event.preventDefault(); + const payload = { + id: $("#contact-id").value || null, + name: $("#contact-name").value.trim(), email: $("#contact-email").value.trim(), emails: $("#contact-emails").value.split(/\r?\n/).map((value) => value.trim()).filter(Boolean), company: $("#contact-company").value.trim(), + phone: $("#contact-phone").value.trim(), phones: $("#contact-phones").value.split(/\r?\n/).map((value) => value.trim()).filter(Boolean), job_title: $("#contact-job-title").value.trim(), website: $("#contact-website").value.trim(), + birthday: $("#contact-birthday").value, address: $("#contact-address").value.trim(), addresses: $("#contact-addresses").value.split(/\r?\n/).map((value) => value.trim()).filter(Boolean), notes: $("#contact-notes").value, + is_favorite: $("#contact-favorite").checked, source:"manual", + }; + try { const saved = await request("/contacts", { method:"POST", body:JSON.stringify(payload) }); await loadContacts($("#contact-search").value.trim()); editContact(saved); toast("Contact saved"); } + catch (error) { toast(error.message, "error"); } + } + + async function deleteContact() { + const id = $("#contact-id").value; + if (!id || !await appConfirm("Delete this contact?", { title:"Delete contact", confirmLabel:"Delete", danger:true })) return; + try { await request(`/contacts/${encodeURIComponent(id)}`, { method:"DELETE" }); editContact(null); await loadContacts($("#contact-search").value.trim()); toast("Contact deleted"); } + catch (error) { toast(error.message, "error"); } + } + + function composeToContact() { + const email = $("#contact-email").value.trim(); + if (!email) return; + $("#contacts-dialog").close(); + openCompose("new"); + $("#compose-to").value = email; + } + + function startOfCalendarDay(date) { + return new Date(date.getFullYear(), date.getMonth(), date.getDate()); + } + + function addCalendarDays(date, count) { + const next = new Date(date); + next.setDate(next.getDate() + count); + return next; + } + + function startOfCalendarWeek(date) { + const start = startOfCalendarDay(date); + const day = start.getDay(); + const mondayOffset = day === 0 ? -6 : 1 - day; + return addCalendarDays(start, mondayOffset); + } + + function calendarRange(date, view = state.calendarView) { + if (view === "week") { + const start = startOfCalendarWeek(date); + return { start, end: addCalendarDays(start, 7) }; + } + if (view === "day") { + const start = startOfCalendarDay(date); + return { start, end: addCalendarDays(start, 1) }; + } + if (view === "agenda") { + const start = startOfCalendarDay(date); + return { start, end: addCalendarDays(start, 30) }; + } + const first = new Date(date.getFullYear(), date.getMonth(), 1); + const start = startOfCalendarWeek(first); + return { start, end: addCalendarDays(start, 42) }; + } + + function calendarDayKey(value) { + const date = value instanceof Date ? value : new Date(value); + if (Number.isNaN(date.getTime())) return ""; + return `${date.getFullYear()}-${String(date.getMonth()+1).padStart(2,"0")}-${String(date.getDate()).padStart(2,"0")}`; + } + + function calendarRangeLabel() { + const cursor = state.calendarCursor; + if (state.calendarView === "month") return cursor.toLocaleDateString([], { month:"long", year:"numeric" }); + if (state.calendarView === "day") return cursor.toLocaleDateString([], { weekday:"long", year:"numeric", month:"long", day:"numeric" }); + const { start, end } = calendarRange(cursor); + const last = addCalendarDays(end, -1); + if (state.calendarView === "agenda") return `${start.toLocaleDateString()} – ${last.toLocaleDateString()}`; + return `${start.toLocaleDateString([], { month:"short", day:"numeric" })} – ${last.toLocaleDateString([], { month:"short", day:"numeric", year:"numeric" })}`; + } + + function shiftCalendarCursor(direction) { + const cursor = new Date(state.calendarCursor); + if (state.calendarView === "month") cursor.setMonth(cursor.getMonth() + direction); + else if (state.calendarView === "week") cursor.setDate(cursor.getDate() + (7 * direction)); + else if (state.calendarView === "agenda") cursor.setDate(cursor.getDate() + (30 * direction)); + else cursor.setDate(cursor.getDate() + direction); + state.calendarCursor = cursor; + } + + function toLocalInput(value) { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return ""; + const local = new Date(date.getTime() - date.getTimezoneOffset() * 60000); + return local.toISOString().slice(0,16); + } + + async function importCalendarIcs(files) { + const file = files?.[0]; + if (!file) return; + if (file.size > 10 * 1024 * 1024) return toast("ICS file is too large", "error"); + try { + const text = await file.text(); + const headers = new Headers({ "Content-Type": "text/calendar; charset=utf-8", "X-Requested-With": "XMLHttpRequest" }); + if (state.accountId) headers.set("X-Active-Account", state.accountId); + const response = await fetch(apiPath("/calendar/events/import-ics"), { method:"POST", credentials:"include", headers, body:text }); + const data = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(data?.error?.message || `ICS import failed (${response.status})`); + toast(`ICS import: ${data.created || 0} created, ${data.skipped || 0} skipped`); + await loadCalendar(); + } catch (error) { toast(error.message, "error"); } + finally { $("#calendar-import-file").value = ""; } + } + + async function exportCalendarIcs() { + try { + const headers = new Headers(); + if (state.accountId) headers.set("X-Active-Account", state.accountId); + const response = await fetch(apiPath("/calendar/events/export-ics"), { credentials:"include", headers }); + if (!response.ok) throw new Error(`Calendar export failed (${response.status})`); + const blob = await response.blob(); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = "rustbox-calendar.ics"; + document.body.append(link); + link.click(); + link.remove(); + setTimeout(() => URL.revokeObjectURL(url), 1000); + } catch (error) { toast(error.message, "error"); } + } + + async function openCalendar() { + $("#calendar-dialog").showModal(); + await loadCalendar(); + } + + async function loadCalendar() { + const { start, end } = calendarRange(state.calendarCursor); + $("#calendar-view").value = state.calendarView; + $("#calendar-range").textContent = calendarRangeLabel(); + try { + const data = await request(`/calendar/events?start=${encodeURIComponent(start.toISOString())}&end=${encodeURIComponent(end.toISOString())}`, { method:"GET" }); + state.calendarEvents = data.events || []; + renderCalendarEvents(); + } catch (error) { toast(error.message, "error"); } + } + + function createCalendarEventButton(event, compact = false) { + const button = el("button", compact ? "calendar-event-chip" : "calendar-event"); + button.type = "button"; + const color = /^#[0-9a-f]{6}$/i.test(String(event.color || "")) ? event.color : "var(--accent)"; + button.style.setProperty("--event-color", color); + if (compact) { + const time = event.all_day ? "All day" : new Date(event.start_time).toLocaleTimeString([], { hour:"2-digit", minute:"2-digit" }); + button.innerHTML = `${escapeHtml(time)}${escapeHtml(event.title)}`; + } else { + button.innerHTML = `
    ${escapeHtml(event.title)}${escapeHtml(event.location || "")}
    ${escapeHtml(event.status || "")}`; + } + button.addEventListener("click", () => editCalendarEvent(event)); + return button; + } + + function renderCalendarMonthOrWeek(view) { + const host = $("#calendar-events"); + const { start, end } = calendarRange(state.calendarCursor, view); + const days = view === "week" ? 7 : 42; + const grid = el("div", `calendar-grid calendar-${view}-grid`); + for (const label of ["Mon","Tue","Wed","Thu","Fri","Sat","Sun"]) grid.append(el("div", "calendar-weekday", label)); + const byDay = new Map(); + for (const event of state.calendarEvents) { + const key = calendarDayKey(event.start_time); + if (!byDay.has(key)) byDay.set(key, []); + byDay.get(key).push(event); + } + for (let i=0; i { + const startDate = new Date(date); startDate.setHours(9,0,0,0); + const endDate = new Date(startDate.getTime()+3600000); + editCalendarEvent(null); + $("#calendar-start").value = toLocalInput(startDate); + $("#calendar-end").value = toLocalInput(endDate); + }); + cell.append(heading); + const dayEvents = (byDay.get(calendarDayKey(date)) || []).sort((a,b) => new Date(a.start_time)-new Date(b.start_time)); + for (const event of dayEvents.slice(0, view === "month" ? 4 : 12)) cell.append(createCalendarEventButton(event, true)); + if (view === "month" && dayEvents.length > 4) cell.append(el("span", "calendar-more", `+${dayEvents.length-4} more`)); + grid.append(cell); + } + host.append(grid); + } + + function renderCalendarAgenda() { + const host = $("#calendar-events"); + const groups = new Map(); + for (const event of state.calendarEvents) { + const key = calendarDayKey(event.start_time); + if (!groups.has(key)) groups.set(key, []); + groups.get(key).push(event); + } + if (!groups.size) { host.append(el("div", "empty-list", "No events in this range")); return; } + for (const [key, events] of [...groups.entries()].sort(([a],[b]) => a.localeCompare(b))) { + const date = new Date(`${key}T12:00:00`); + const section = el("section", "calendar-agenda-day"); + section.append(el("h4", "", date.toLocaleDateString([], { weekday:"long", month:"long", day:"numeric", year:"numeric" }))); + for (const event of events.sort((a,b) => new Date(a.start_time)-new Date(b.start_time))) section.append(createCalendarEventButton(event)); + host.append(section); + } + } + + function renderCalendarEvents() { + const list = $("#calendar-events"); + list.replaceChildren(); + if (state.calendarView === "month" || state.calendarView === "week") { + renderCalendarMonthOrWeek(state.calendarView); + return; + } + if (!state.calendarEvents.length) { + list.append(el("div", "empty-list", state.calendarView === "day" ? "No events today" : "No events in this range")); + return; + } + renderCalendarAgenda(); + } + + function updateCalendarRecurrenceUi() { + $("#calendar-rrule-wrap").classList.toggle("hidden", $("#calendar-recurrence").value !== "custom"); + } + + function editCalendarEvent(event = null) { + $("#calendar-form").classList.remove("hidden"); + $("#calendar-event-id").value = event?.id || ""; + $("#calendar-title").value = event?.title || ""; + $("#calendar-start").value = event ? toLocalInput(event.start_time) : toLocalInput(new Date()); + $("#calendar-end").value = event ? toLocalInput(event.end_time) : toLocalInput(new Date(Date.now()+3600000)); + $("#calendar-all-day").checked = Boolean(event?.all_day); + const rule = event?.recurrence_rule || ""; + const commonRules = new Set(["", "FREQ=DAILY", "FREQ=WEEKLY", "FREQ=MONTHLY", "FREQ=YEARLY"]); + $("#calendar-recurrence").value = commonRules.has(rule) ? rule : "custom"; + $("#calendar-rrule").value = commonRules.has(rule) ? "" : rule; + updateCalendarRecurrenceUi(); + let attendees = []; + try { attendees = JSON.parse(event?.attendees || "[]"); } catch (_) {} + $("#calendar-attendees").value = Array.isArray(attendees) ? attendees.join(", ") : ""; + $("#calendar-organizer").value = event?.organizer || state.accounts.find((item) => item.id === state.accountId)?.email || ""; + $("#calendar-status").value = String(event?.status || "confirmed").toLowerCase(); + $("#calendar-location").value = event?.location || ""; + $("#calendar-meeting-url").value = event?.meeting_url || ""; + $("#calendar-description").value = event?.description || ""; + const color = String(event?.color || ""); + $("#calendar-color").value = /^#[0-9a-f]{6}$/i.test(color) ? color : "#6b7280"; + const reminder = event?.reminder_minutes == null ? "" : String(event.reminder_minutes); + $("#calendar-reminder").value = Array.from($("#calendar-reminder").options).some((option) => option.value === reminder) ? reminder : ""; + $("#calendar-delete").disabled = !event; + } + + async function saveCalendarEvent(event) { + event.preventDefault(); + const id = $("#calendar-event-id").value; + const recurrenceChoice = $("#calendar-recurrence").value; + const recurrenceRule = recurrenceChoice === "custom" ? $("#calendar-rrule").value.trim() : recurrenceChoice; + const attendees = $("#calendar-attendees").value.split(",").map((value) => value.trim()).filter(Boolean); + const startValue = $("#calendar-start").value; + const endValue = $("#calendar-end").value; + if (!startValue || !endValue) return toast("Start and end are required", "error"); + const base = { + title:$("#calendar-title").value.trim(), + description:$("#calendar-description").value, + location:$("#calendar-location").value.trim(), + start_time:new Date(startValue).toISOString(), + end_time:new Date(endValue).toISOString(), + all_day:$("#calendar-all-day").checked, + recurrence_rule:recurrenceRule || null, + attendees:JSON.stringify(attendees), + organizer:$("#calendar-organizer").value.trim(), + status:$("#calendar-status").value || "confirmed", + meeting_url:$("#calendar-meeting-url").value.trim() || null, + color:$("#calendar-color").value || null, + reminder_minutes:$("#calendar-reminder").value === "" ? null : Number($("#calendar-reminder").value), + }; + const payload = id ? base : { ...base, source:"manual", source_uid:null, source_message_uid:null, source_message_folder:null }; + try { await request(id ? `/calendar/events/${encodeURIComponent(id)}` : "/calendar/events", { method:id ? "PUT" : "POST", body:JSON.stringify(payload) }); $("#calendar-form").classList.add("hidden"); await loadCalendar(); toast("Event saved"); } + catch (error) { toast(error.message, "error"); } + } + + async function deleteCalendarEvent() { + const id = $("#calendar-event-id").value; + if (!id || !await appConfirm("Delete this event?", { title:"Delete event", confirmLabel:"Delete", danger:true })) return; + try { await request(`/calendar/events/${encodeURIComponent(id)}`, { method:"DELETE" }); $("#calendar-form").classList.add("hidden"); await loadCalendar(); toast("Event deleted"); } + catch (error) { toast(error.message, "error"); } + } + + async function loadOpenPgp() { + try { + const [keys, prefs] = await Promise.all([ + request("/pgp/keys", { method:"GET" }), + request("/pgp/preferences", { method:"GET" }), + ]); + state.pgpKeys = Array.isArray(keys) ? keys : []; + state.pgpPrefs = { ...state.pgpPrefs, ...(prefs || {}) }; + renderOpenPgp(); + renderIdentityPgpOptions(); + updateComposePgpUi(); + if (state.currentDetail) renderPgpReaderState(state.currentDetail); + } catch (error) { + state.pgpKeys = []; + updateComposePgpUi(); + if (state.currentDetail) renderPgpReaderState(state.currentDetail); + toast(error.message, "error"); + } + } + + function pgpFingerprintLabel(value) { + const fp = String(value || "").replace(/\s+/g, "").toUpperCase(); + return fp ? fp.replace(/(.{4})/g, "$1 ").trim() : ""; + } + + function renderOpenPgp() { + if (!$("#pgp-key-list")) return; + $("#pgp-pref-wkd").checked = Boolean(state.pgpPrefs.wkdEnabled); + $("#pgp-pref-hkp").checked = Boolean(state.pgpPrefs.hkpEnabled); + $("#pgp-pref-trusted").checked = Boolean(state.pgpPrefs.requireTrustedEncryption); + const discoveryEnabled = Boolean(state.pgpPrefs.wkdEnabled || state.pgpPrefs.hkpEnabled); + if ($("#pgp-discover")) { + $("#pgp-discover").disabled = !discoveryEnabled; + $("#pgp-discover").title = discoveryEnabled ? "" : "Enable WKD or the HTTPS key server first"; + } + $("#pgp-key-summary").textContent = `${state.pgpKeys.length} key${state.pgpKeys.length === 1 ? "" : "s"} · ${state.pgpKeys.filter((key) => key.hasSecret).length} private`; + const list = $("#pgp-key-list"); + list.replaceChildren(); + if (!state.pgpKeys.length) list.append(el("div", "empty-list", "No OpenPGP keys yet")); + for (const key of state.pgpKeys) { + const card = el("article", "pgp-key-card"); + const head = el("div", "pgp-key-head"); + const title = el("div"); + title.innerHTML = `${escapeHtml(key.userId || key.email || key.keyId)}${escapeHtml(key.email || "No email")} · ${key.hasSecret ? "private + public" : "public"} · ${escapeHtml(key.source || "import")}`; + const trust = document.createElement("select"); + trust.setAttribute("aria-label", `Trust for ${key.email || key.keyId}`); + for (const value of ["unknown","verified","trusted","untrusted"]) trust.add(new Option(value[0].toUpperCase() + value.slice(1), value)); + trust.value = key.trust || "unknown"; + trust.addEventListener("change", async () => { + try { await request(`/pgp/keys/${encodeURIComponent(key.fingerprint)}/trust`, { method:"PATCH", body:JSON.stringify({ trust:trust.value }) }); await loadOpenPgp(); toast("OpenPGP trust updated", "success"); } + catch (error) { toast(error.message, "error"); } + }); + head.append(title, trust); + const fp = el("code", "pgp-fingerprint", pgpFingerprintLabel(key.fingerprint)); + fp.title = "Fingerprint"; + const actions = el("div", "inline-actions"); + const pub = el("button", "button quiet small", "Export public"); pub.type="button"; pub.addEventListener("click", () => downloadPgpKey(key, false)); actions.append(pub); + if (key.hasSecret) { + const test = el("button", "button quiet small", "Test passphrase"); test.type="button"; test.addEventListener("click", async () => { + const passphrase = await appPrompt("Test OpenPGP passphrase", { message:"Enter the passphrase for this private key. It is used only for this test and is not stored.", type:"password", confirmLabel:"Test" }); + if (passphrase === null) return; + setBusy(test, true); + try { await request(`/pgp/keys/${encodeURIComponent(key.fingerprint)}/test-passphrase`, { method:"POST", body:JSON.stringify({ passphrase }) }); toast("OpenPGP private key unlocked successfully", "success"); } + catch (error) { toast(error.message, "error"); } finally { setBusy(test, false); } + }); actions.append(test); + const change = el("button", "button quiet small", "Change passphrase"); change.type="button"; change.addEventListener("click", async () => { + const currentPassphrase = await appPrompt("Current OpenPGP passphrase", { message:"Enter the current passphrase. Leave it empty if the key is currently unprotected.", type:"password", confirmLabel:"Continue" }); + if (currentPassphrase === null) return; + const newPassphrase = await appPrompt("New OpenPGP passphrase", { message:"Enter the new passphrase. Leave it empty only if you intentionally want to remove passphrase protection.", type:"password", confirmLabel:"Change" }); + if (newPassphrase === null) return; + if (newPassphrase === "" && !await appConfirm("Remove passphrase protection from this private key? The key file will still be protected by server filesystem permissions, but anyone who obtains it could use it immediately.", { title:"Remove OpenPGP passphrase", confirmLabel:"Remove passphrase", danger:true })) return; + setBusy(change, true); + try { await request(`/pgp/keys/${encodeURIComponent(key.fingerprint)}/change-passphrase`, { method:"POST", body:JSON.stringify({ currentPassphrase, newPassphrase }) }); toast("OpenPGP passphrase changed", "success"); } + catch (error) { toast(error.message, "error"); } finally { setBusy(change, false); } + }); actions.append(change); + const sec = el("button", "button quiet small", "Export private"); sec.type="button"; sec.addEventListener("click", async () => { if (await appConfirm("Export the private OpenPGP key? Anyone with this file and its passphrase may use your identity.", { title:"Export private key", confirmLabel:"Export" })) downloadPgpKey(key, true); }); actions.append(sec); + } + const del = el("button", "button quiet small danger", "Delete"); del.type="button"; del.addEventListener("click", async () => { if (!await appConfirm(`Delete OpenPGP key ${key.keyId}?`, { title:"Delete OpenPGP key", confirmLabel:"Delete", danger:true })) return; try { await request(`/pgp/keys/${encodeURIComponent(key.fingerprint)}`, { method:"DELETE" }); await loadOpenPgp(); await loadIdentities(); toast("OpenPGP key deleted", "success"); } catch (error) { toast(error.message,"error"); } }); actions.append(del); + card.append(head, fp, actions); + list.append(card); + } + } + + async function downloadPgpKey(key, secret) { + try { + const headers = new Headers(); + if (state.accountId) headers.set("X-Active-Account", state.accountId); + const response = await fetch(apiPath(`/pgp/keys/${encodeURIComponent(key.fingerprint)}/export?secret=${secret ? "true" : "false"}`), { credentials:"include", headers }); + if (!response.ok) throw new Error(await response.text() || `Export failed (${response.status})`); + const blob = await response.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); a.href=url; a.download=`openpgp-${key.keyId}-${secret ? "private" : "public"}.asc`; a.click(); + setTimeout(() => URL.revokeObjectURL(url), 30000); + } catch (error) { toast(error.message, "error"); } + } + + async function savePgpPreferences() { + try { + state.pgpPrefs = await request("/pgp/preferences", { method:"PUT", body:JSON.stringify({ wkdEnabled:$("#pgp-pref-wkd").checked, hkpEnabled:$("#pgp-pref-hkp").checked, requireTrustedEncryption:$("#pgp-pref-trusted").checked }) }); + renderOpenPgp(); + updateComposePgpUi(); + if (state.currentDetail) renderPgpReaderState(state.currentDetail); + toast("OpenPGP policy saved", "success"); + } catch (error) { toast(error.message, "error"); } + } + + async function generatePgpKey() { + const button = $("#pgp-generate"); setBusy(button, true); + try { + const key = await request("/pgp/keys/generate", { method:"POST", body:JSON.stringify({ name:$("#pgp-generate-name").value.trim(), email:$("#pgp-generate-email").value.trim(), passphrase:$("#pgp-generate-passphrase").value }) }); + $("#pgp-generate-passphrase").value=""; await loadOpenPgp(); toast(`Generated OpenPGP key ${key.keyId}`, "success"); + } catch (error) { toast(error.message,"error"); } finally { setBusy(button,false); } + } + + async function importPgpText(text = $("#pgp-import-data").value) { + if (!String(text || "").trim()) return toast("Paste or choose an OpenPGP key first", "warning"); + try { const key = await request("/pgp/keys", { method:"POST", body:JSON.stringify({ keyData:String(text), trust:$("#pgp-import-trust").value || "unknown" }) }); $("#pgp-import-data").value=""; await loadOpenPgp(); toast(`Imported OpenPGP key ${key.keyId}`, "success"); } + catch (error) { toast(error.message,"error"); } + } + + function bytesToBase64(bytes) { + let binary = ""; + const chunkSize = 0x8000; + for (let offset = 0; offset < bytes.length; offset += chunkSize) { + binary += String.fromCharCode(...bytes.subarray(offset, Math.min(offset + chunkSize, bytes.length))); + } + return btoa(binary); + } + + async function importPgpFile(file) { + if (!file) return; + try { + const bytes = new Uint8Array(await file.arrayBuffer()); + if (!bytes.length) return toast("The selected OpenPGP key file is empty", "warning"); + const key = await request("/pgp/keys", { + method:"POST", + body:JSON.stringify({ keyDataBase64:bytesToBase64(bytes), trust:$("#pgp-import-trust").value || "unknown" }), + }); + $("#pgp-import-data").value=""; + await loadOpenPgp(); + toast(`Imported OpenPGP key ${key.keyId}`, "success"); + } catch (error) { toast(error.message,"error"); } + } + + async function discoverPgpEmail(email = $("#pgp-discover-email").value.trim(), quiet = false) { + if (!email) return null; + try { + const result = await request("/pgp/discover", { method:"POST", body:JSON.stringify({ email }) }); + if (result.found) { await loadOpenPgp(); if (!quiet) toast(`OpenPGP key discovered via ${(result.source || "discovery").toUpperCase()}`, "success"); return result.key; } + if (!quiet) toast(`No OpenPGP key found for ${email}`, "warning"); + return null; + } catch (error) { if (!quiet) toast(error.message,"error"); return null; } + } + + function renderIdentityPgpOptions(selectedFingerprint = null) { + const select = $("#identity-pgp-key"); + if (!select) return; + const value = selectedFingerprint ?? select.value; + select.replaceChildren(new Option("Automatic / none", "")); + for (const key of state.pgpKeys.filter((item) => item.hasSecret)) { + select.add(new Option(`${key.email || key.userId || key.keyId} · ${key.keyId}`, key.fingerprint)); + } + select.value = value || ""; + } + + function pgpModeForIdentity(identity) { + if (!identity) return "none"; + if (identity.pgp_sign_default && identity.pgp_encrypt_default) return "sign-encrypt"; + if (identity.pgp_encrypt_default) return "encrypt"; + if (identity.pgp_sign_default) return "sign"; + return "none"; + } + + function updateComposePgpUi(applyDefaults = false) { + const modeSelect = $("#compose-pgp-mode"); + const panel = $("#compose-pgp-panel"); + const option = $("#compose-pgp-option"); + if (!modeSelect || !panel) return; + const configured = state.pgpKeys.length > 0; + option?.classList.toggle("hidden", !configured); + panel.classList.toggle("hidden", !configured); + if (!configured) { + panel.open = false; + modeSelect.value = "none"; + $("#compose-pgp-inline").checked = false; + $("#compose-pgp-attach-key").checked = false; + $("#compose-pgp-attach-recipient-keys").checked = false; + state.pgpRecipientStatus = []; + return; + } + + const identity = selectedIdentity(); + const accountEmail = state.accounts.find((item) => item.id === state.accountId)?.email || ""; + const senderEmail = identity?.email || accountEmail; + const selectedKey = identity?.pgp_fingerprint ? state.pgpKeys.find((key) => key.fingerprint === identity.pgp_fingerprint) : null; + const senderPublicKey = selectedKey || state.pgpKeys.find((key) => key.email?.toLowerCase() === senderEmail.toLowerCase()); + const signingKey = selectedKey?.hasSecret ? selectedKey : state.pgpKeys.find((key) => key.hasSecret && key.email?.toLowerCase() === senderEmail.toLowerCase()); + if (applyDefaults) { + modeSelect.value = pgpModeForIdentity(identity); + $("#compose-pgp-inline").checked = Boolean(identity?.pgp_inline_default); + panel.open = false; + } + + const hasSigningKey = Boolean(signingKey?.hasSecret); + for (const value of ["sign", "sign-encrypt"]) { + const modeOption = [...modeSelect.options].find((item) => item.value === value); + if (modeOption) modeOption.disabled = !hasSigningKey; + } + if (!hasSigningKey && ["sign", "sign-encrypt"].includes(modeSelect.value)) modeSelect.value = modeSelect.value === "sign-encrypt" ? "encrypt" : "none"; + + const activeMode = modeSelect.value || "none"; + const needsSigning = activeMode === "sign" || activeMode === "sign-encrypt"; + $("#compose-pgp-passphrase-row").classList.toggle("hidden", !needsSigning); + const inline = $("#compose-pgp-inline"); + if (inline) inline.disabled = activeMode === "none" || Boolean(state.composeAttachments.length); + if (inline?.disabled) inline.checked = false; + + const senderRow = $("#compose-pgp-attach-sender-row"); + senderRow?.classList.toggle("hidden", !senderPublicKey); + if (!senderPublicKey) $("#compose-pgp-attach-key").checked = false; + + const discoveryEnabled = Boolean(state.pgpPrefs.wkdEnabled || state.pgpPrefs.hkpEnabled); + const discoverButton = $("#compose-pgp-discover"); + if (discoverButton) { + discoverButton.disabled = !discoveryEnabled; + discoverButton.title = discoveryEnabled ? "" : "Enable WKD or the HTTPS key server in OpenPGP settings first"; + } + + const recipientAttach = $("#compose-pgp-attach-recipient-keys"); + const parts = []; + if (needsSigning) parts.push(`Signing key ${signingKey.keyId}`); + if (activeMode.includes("encrypt")) parts.push(state.pgpRecipientStatus.length ? recipientPgpSummary(state.pgpRecipientStatus) : "Recipient keys not checked yet"); + if (recipientAttach?.checked) { + const available = state.pgpRecipientStatus.filter((item) => item.hasKey).length; + parts.push(state.pgpRecipientStatus.length ? `${available} visible recipient key${available === 1 ? "" : "s"} will be attached` : "Recipient keys to attach not checked yet"); + } + if (!parts.length) parts.push(senderPublicKey ? `OpenPGP ready · ${senderPublicKey.keyId}` : "OpenPGP public keys available"); + $("#compose-pgp-status").textContent = parts.join(" · "); + } + + function recipientPgpSummary(statuses) { + const missing = statuses.filter((item) => !item.hasKey); + const untrusted = statuses.filter((item) => item.hasKey && item.trust === "untrusted"); + const notTrusted = state.pgpPrefs.requireTrustedEncryption + ? statuses.filter((item) => item.hasKey && !["trusted", "verified"].includes(item.trust)) + : []; + if (missing.length) return `${missing.length} recipient key${missing.length === 1 ? "" : "s"} missing`; + if (untrusted.length) return `${untrusted.length} recipient key${untrusted.length === 1 ? "" : "s"} marked untrusted`; + if (notTrusted.length) return `${notTrusted.length} recipient key${notTrusted.length === 1 ? " is" : "s are"} not trusted by policy`; + return `Keys available for ${statuses.length} recipient${statuses.length === 1 ? "" : "s"}`; + } + + function pgpRecipientStatusHasWarning(item) { + if (!item.hasKey || item.trust === "untrusted") return true; + return Boolean(state.pgpPrefs.requireTrustedEncryption && !["trusted", "verified"].includes(item.trust)); + } + + function composePgpVisibleRecipients() { + return [...parseRecipients($("#compose-to").value), ...parseRecipients($("#compose-cc").value)]; + } + + function composePgpRecipients() { + return [...composePgpVisibleRecipients(), ...parseRecipients($("#compose-bcc").value)]; + } + + async function checkComposePgpRecipients(showToast = true, emails = composePgpRecipients()) { + if (!emails.length) { state.pgpRecipientStatus=[]; updateComposePgpUi(); return []; } + try { + const statuses = await request("/pgp/recipients", { method:"POST", body:JSON.stringify({ emails }) }); + state.pgpRecipientStatus = statuses || []; + updateComposePgpUi(); + if (showToast) toast(recipientPgpSummary(state.pgpRecipientStatus), state.pgpRecipientStatus.some(pgpRecipientStatusHasWarning) ? "warning" : "success"); + return state.pgpRecipientStatus; + } catch (error) { if (showToast) toast(error.message,"error"); return []; } + } + + async function discoverComposePgpRecipients() { + const statuses = await checkComposePgpRecipients(false); + const missing = statuses.filter((item) => !item.hasKey).map((item) => item.email); + if (!missing.length) return toast("All recipient OpenPGP keys are already available", "success"); + let found = 0; + for (const email of missing) if (await discoverPgpEmail(email, true)) found += 1; + await checkComposePgpRecipients(false); + toast(found ? `Discovered ${found} OpenPGP key${found === 1 ? "" : "s"}` : "No missing OpenPGP keys were discovered", found ? "success" : "warning"); + } + + async function loadIdentities() { + try { + const data = await request("/identities", { method:"GET" }); + state.identities = data.identities || []; + renderIdentities(); + renderComposeIdentities(); + renderIdentityPgpOptions(); + updateComposePgpUi(); + } catch (error) { toast(error.message, "error"); } + } + + function renderComposeIdentities() { + const select = $("#compose-from"); + if (!select) return; + select.replaceChildren(new Option("Account default", "")); + for (const identity of state.identities) { + const label = identity.display_name ? `${identity.display_name} <${identity.email}>` : identity.email; + const option = new Option(label, String(identity.id)); + if (identity.is_default) option.selected = true; + select.add(option); + } + } + + function renderIdentities() { + const list = $("#identity-list"); + if (!list) return; + list.replaceChildren(); + if (!state.identities.length) list.append(el("div", "empty-list", "No identities yet")); + for (const identity of state.identities) { + const button = el("button", "identity-item"); + button.type = "button"; + button.innerHTML = `${escapeHtml(identity.display_name || identity.email)}${escapeHtml(identity.email)}${identity.is_default ? " · default" : ""}`; + button.addEventListener("click", () => editIdentity(identity)); + list.append(button); + } + } + + function editIdentity(identity = null) { + $("#identity-id").value = identity?.id || ""; + $("#identity-name").value = identity?.display_name || ""; + $("#identity-email").value = identity?.email || ""; + $("#identity-reply-to").value = identity?.reply_to || ""; + $("#identity-bcc").value = identity?.bcc || ""; + $("#identity-organization").value = identity?.organization || ""; + $("#identity-signature-text").value = identity?.signature_text || ""; + $("#identity-signature").innerHTML = sanitizeMessageHtml(identity?.signature_html || ""); + $("#identity-signature-position").value = identity?.signature_position || "above"; + renderIdentityPgpOptions(identity?.pgp_fingerprint || ""); + $("#identity-pgp-sign").checked = Boolean(identity?.pgp_sign_default); + $("#identity-pgp-encrypt").checked = Boolean(identity?.pgp_encrypt_default); + $("#identity-pgp-inline").checked = Boolean(identity?.pgp_inline_default); + $("#identity-signature-new").checked = identity?.signature_new !== false; + $("#identity-signature-reply").checked = identity?.signature_reply !== false; + $("#identity-signature-forward").checked = identity?.signature_forward !== false; + $("#identity-default").checked = Boolean(identity?.is_default); + $("#identity-delete").disabled = !identity; + $("#identity-dialog-title").textContent = identity ? `Edit identity · ${identity.email}` : "New identity & signature"; + const pgpConfigured = state.pgpKeys.length > 0; + $("#identity-pgp-key-row")?.classList.toggle("hidden", !pgpConfigured); + $$(".identity-pgp-default").forEach((row) => row.classList.toggle("hidden", !pgpConfigured)); + const dialog = $("#identity-dialog"); + if (!dialog.open) dialog.showModal(); + setTimeout(() => (identity ? $("#identity-name") : $("#identity-email"))?.focus(), 20); + } + + async function saveIdentity(event) { + event.preventDefault(); + const id = $("#identity-id").value; + const signatureHtml = sanitizeMessageHtml($("#identity-signature").innerHTML); + if (new TextEncoder().encode(signatureHtml).length > 3 * 1024 * 1024) return toast("HTML signature is too large. Reduce imported image sizes.", "warning"); + const payload = { + display_name: $("#identity-name").value.trim(), + email: $("#identity-email").value.trim(), + reply_to: $("#identity-reply-to").value.trim(), + bcc: $("#identity-bcc").value.trim(), + organization: $("#identity-organization").value.trim(), + signature_text: $("#identity-signature-text").value, + signature_html: signatureHtml, + signature_position: $("#identity-signature-position").value, + pgp_fingerprint: $("#identity-pgp-key").value, + pgp_sign_default: $("#identity-pgp-sign").checked, + pgp_encrypt_default: $("#identity-pgp-encrypt").checked, + pgp_inline_default: $("#identity-pgp-inline").checked, + signature_new: $("#identity-signature-new").checked, + signature_reply: $("#identity-signature-reply").checked, + signature_forward: $("#identity-signature-forward").checked, + is_default: $("#identity-default").checked, + }; + try { + await request(id ? `/identities/${id}` : "/identities", { method:id ? "PUT" : "POST", body:JSON.stringify(payload) }); + await loadIdentities(); + const saved = state.identities.find((item) => item.email === payload.email); + if (saved) { + $("#identity-id").value = saved.id || ""; + } + if ($("#identity-dialog").open) $("#identity-dialog").close(); + toast("Identity saved"); + } catch (error) { toast(error.message, "error"); } + } + + async function deleteIdentity() { + const id = $("#identity-id").value; + if (!id || !await appConfirm("Delete this identity?", { title:"Delete identity", confirmLabel:"Delete", danger:true })) return; + try { + await request(`/identities/${id}`, { method:"DELETE" }); + await loadIdentities(); + if ($("#identity-dialog").open) $("#identity-dialog").close(); + toast("Identity deleted"); + } catch (error) { toast(error.message, "error"); } + } + + function selectedIdentity() { + const id = Number($("#compose-from")?.value || 0); + return state.identities.find((identity) => identity.id === id) || state.identities.find((identity) => identity.is_default) || null; + } + + function composeActionFromTitle() { + const title = $("#compose-title").textContent; + if (title === "Reply" || title === "Reply all") return "reply"; + if (title === "Forward") return "forward"; + return "new"; + } + + function dataUrlToSignatureFile(src, index = 0) { + const match = /^data:(image\/(?:png|jpeg|gif|webp));base64,([A-Za-z0-9+/=\r\n]+)$/i.exec(String(src || "")); + if (!match) return null; + try { + const binary = atob(match[2].replace(/\s+/g, "")); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i); + const type = match[1].toLowerCase(); + const ext = type === "image/jpeg" ? "jpg" : type.slice("image/".length); + return new File([bytes], `signature-${index + 1}.${ext}`, { type }); + } catch (_) { + return null; + } + } + + async function materializeSignatureImages(signature, generation) { + const images = [...signature.querySelectorAll('img[src^="data:image/"]')]; + if (!images.length) return; + const pairs = images.map((image, index) => ({ image, file:dataUrlToSignatureFile(image.getAttribute("src"), index) })).filter((item) => item.file); + if (!pairs.length) return; + const uploaded = await uploadComposeFiles(pairs.map((item) => item.file), { announce:false }); + if (!uploaded.length) return; + if (generation !== state.composeSignatureGeneration || !signature.isConnected) { + for (const attachment of uploaded) state.inlineAttachmentIds.add(String(attachment.id)); + renderComposeAttachments(); + scheduleUnusedInlineAttachmentCleanup(); + return; + } + const draftId = ensureDraftId(); + for (let index = 0; index < Math.min(pairs.length, uploaded.length); index += 1) { + const { image } = pairs[index]; + const attachment = uploaded[index]; + state.inlineAttachmentIds.add(String(attachment.id)); + image.dataset.rustboxCid = String(attachment.id); + image.dataset.rustboxSignatureImage = "true"; + image.setAttribute("src", composeAttachmentContentUrl(draftId, attachment.id)); + } + renderComposeAttachments(); + scheduleDraftSave(); + } + + function insertIdentitySignature(editor, action = composeActionFromTitle()) { + state.composeSignatureGeneration += 1; + const generation = state.composeSignatureGeneration; + editor.querySelectorAll(".compose-signature").forEach((node) => node.remove()); + scheduleUnusedInlineAttachmentCleanup(); + const identity = selectedIdentity(); + if (!identity) return; + const enabled = action === "reply" ? identity.signature_reply !== false : action === "forward" ? identity.signature_forward !== false : identity.signature_new !== false; + if (!enabled) return; + const format = $("#compose-mode").value; + const signature = el("div", "compose-signature"); + if (format === "html") { + if (!identity.signature_html) return; + signature.innerHTML = sanitizeMessageHtml(identity.signature_html); + } else { + const text = identity.signature_text || ""; + if (!text) return; + signature.textContent = `\n-- \n${text}\n`; + } + const quote = editor.querySelector(".compose-quote"); + if (quote && identity.signature_position !== "below") editor.insertBefore(signature, quote); + else editor.append(signature); + if (format === "html") void materializeSignatureImages(signature, generation); + } + + function readFileAsDataUrl(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(String(reader.result || "")); + reader.onerror = () => reject(reader.error || new Error("Could not read image")); + reader.readAsDataURL(file); + }); + } + + async function insertIdentitySignatureImage(file, range = state.identitySignatureRange) { + state.identitySignatureRange = null; + if (!file) return; + if (!/^image\/(png|jpeg|gif|webp)$/i.test(file.type || "")) return toast("Choose a PNG, JPEG, GIF or WebP image", "warning"); + if (file.size > 2 * 1024 * 1024) return toast("Signature image must be 2 MB or smaller", "warning"); + const editor = $("#identity-signature"); + try { + const src = await readFileAsDataUrl(file); + editor.focus(); + restoreEditorRange(range, editor); + const alt = escapeHtml((file.name || "signature image").replace(/\.[^.]+$/, "")); + document.execCommand("insertHTML", false, `${alt}`); + } catch (error) { + toast(error.message || "Could not import signature image", "error"); + } + } + + + function populateSpecialFolderSelects() { + const roles = ["sent", "drafts", "trash", "junk", "archive"]; + for (const role of roles) { + const select = $(`#special-${role}`); + if (!select) continue; + const automaticLabel = role === "junk" ? "Use default / auto-detect" : "Automatic / not set"; + select.replaceChildren(new Option(automaticLabel, "")); + for (const folder of orderedFolders()) select.add(new Option(folder.name, folder.name)); + select.value = state.specialFolders?.[role] || ""; + } + populateSpamDefaultFolderSelect(); + } + + function populateSpamDefaultFolderSelect() { + const select = $("#spam-default-folder"); + if (!select) return; + const stored = (localStorage.getItem(SPAM_DEFAULT_FOLDER_KEY) || "").trim(); + select.replaceChildren(new Option("Automatic IMAP Junk/Spam", "")); + for (const folder of orderedFolders()) select.add(new Option(folder.name, folder.name)); + if (stored && !state.folders.some((folder) => folder.name === stored)) { + const stale = new Option(`${stored} (not available in this account)`, stored); + stale.disabled = true; + select.add(stale); + } + select.value = stored; + } + + async function loadSpecialFolders() { + try { state.specialFolders = { ...state.specialFolders, ...(await request("/settings/special-folders", { method:"GET" })) }; } + catch (error) { console.warn("Could not load special folders", error); } + populateSpecialFolderSelects(); + } + + async function saveSpecialFolders() { + const value = {}; + for (const role of ["sent", "drafts", "trash", "junk", "archive"]) value[role] = $(`#special-${role}`).value || null; + const browserSpamDefault = $("#spam-default-folder")?.value || ""; + try { + state.specialFolders = await request("/settings/special-folders", { method:"PUT", body:JSON.stringify(value) }); + if (browserSpamDefault) localStorage.setItem(SPAM_DEFAULT_FOLDER_KEY, browserSpamDefault); + else localStorage.removeItem(SPAM_DEFAULT_FOLDER_KEY); + populateSpecialFolderSelects(); + toast("Folder settings saved"); + } catch (error) { toast(error.message, "error"); } + } + + async function autoDetectSpecialFolders() { + for (const role of ["sent", "drafts", "trash", "junk", "archive"]) { + const select = $(`#special-${role}`); + if (select) select.value = autoDetectSpecialFolder(role) || ""; + } + await saveSpecialFolders(); + } + + async function loadQuota() { + const summary = $("#quota-summary span"); + const bar = $("#quota-bar"); + if (!summary || !bar) return; + summary.textContent = "Loading quota…"; + bar.style.width = "0%"; + try { + const quota = await request("/quota", { method:"GET" }); + const used = Number(quota.usage_bytes || 0); + const limit = Number(quota.limit_bytes || 0); + if (limit > 0) { + const percent = Math.max(0, Math.min(100, (used / limit) * 100)); + summary.textContent = `${formatBytes(used)} of ${formatBytes(limit)} (${percent.toFixed(1)}%)`; + bar.style.width = `${percent}%`; + const quotaBox = $("#quota-summary"); + const level = percent >= 95 ? "danger" : percent >= 80 ? "warning" : "normal"; + quotaBox?.classList.toggle("quota-warning", level === "warning"); + quotaBox?.classList.toggle("quota-danger", level === "danger"); + if (level !== "normal" && state.quotaWarningLevel !== `${state.accountId}:${level}`) { + state.quotaWarningLevel = `${state.accountId}:${level}`; + toast(level === "danger" ? `Mailbox storage is ${percent.toFixed(0)}% full` : `Mailbox storage is ${percent.toFixed(0)}% used`, level === "danger" ? "error" : "warning"); + } + } else { + summary.textContent = `${formatBytes(used)} used · server limit unavailable`; + } + } catch (error) { summary.textContent = `Quota unavailable: ${error.message}`; } + } + + function openSettings(tab = "appearance") { + selectSettingsTab(tab); + $("#settings-dialog").showModal(); + if (tab === "sieve") loadSieve(); + if (tab === "identities") { loadIdentities(); loadOpenPgp(); } + if (tab === "openpgp") loadOpenPgp(); + if (tab === "notifications") loadNotificationPreferences(); + if (tab === "mailbox") loadMailPreferences(); + if (tab === "diagnostics") loadDiagnostics(); + if (tab === "account") { renderAccountSettings(); loadActiveSessions(); } + if (tab === "folders") { loadSpecialFolders(); loadQuota(); } + } + + function selectSettingsTab(name) { + $$(".settings-tab").forEach((button) => button.classList.toggle("active", button.dataset.tab === name)); + $$(".settings-panel").forEach((panel) => panel.classList.toggle("hidden", panel.dataset.panel !== name)); + } + + async function loadNotificationPreferences() { + try { + const prefs = await request("/settings/notifications", { method:"GET" }); + state.notificationPrefs = { ...state.notificationPrefs, ...prefs }; + $("#notify-enabled").checked = Boolean(prefs.enabled); + $("#notify-sound").checked = Boolean(prefs.sound); + $("#notify-dnd-enabled").checked = Boolean(prefs.dnd_enabled); + $("#notify-dnd-start").value = prefs.dnd_start || "22:00"; + $("#notify-dnd-end").value = prefs.dnd_end || "07:00"; + renderNotificationFolders(prefs.folders || ["INBOX"]); + } catch (error) { toast(error.message, "error"); } + } + + function renderNotificationFolders(selected) { + const set = new Set(selected || []); + const list = $("#notify-folders"); + list.replaceChildren(); + for (const folder of orderedFolders()) { + const label = el("label", "checkbox-row"); + const input = document.createElement("input"); + input.type = "checkbox"; + input.value = folder.name; + input.checked = set.has(folder.name); + label.append(input, el("span", "", folder.name)); + list.append(label); + } + } + + async function saveNotificationPreferences() { + const folders = $$('#notify-folders input[type="checkbox"]:checked').map((input) => input.value); + try { + const prefs = await request("/settings/notifications", { method:"PUT", body:JSON.stringify({ enabled:$("#notify-enabled").checked, sound:$("#notify-sound").checked, folders, dnd_enabled:$("#notify-dnd-enabled").checked, dnd_start:$("#notify-dnd-start").value || "22:00", dnd_end:$("#notify-dnd-end").value || "07:00" }) }); + state.notificationPrefs = { ...state.notificationPrefs, ...prefs }; + toast("Notification settings saved"); + } catch (error) { toast(error.message, "error"); } + } + + async function requestNotificationPermission() { + if (!("Notification" in window)) return toast("Notifications are not supported by this browser", "error"); + const result = await Notification.requestPermission(); + toast(`Notification permission: ${result}`); + } + + function dndActive(prefs = state.notificationPrefs) { + if (!prefs?.dnd_enabled) return false; + const toMinutes = (value) => { + const [h, m] = String(value || "00:00").split(":").map(Number); + return h * 60 + m; + }; + const now = new Date(); + const current = now.getHours() * 60 + now.getMinutes(); + const start = toMinutes(prefs.dnd_start); + const end = toMinutes(prefs.dnd_end); + if (start === end) return true; + return start < end ? current >= start && current < end : current >= start || current < end; + } + + function playNotificationSound() { + try { + const AudioContext = window.AudioContext || window.webkitAudioContext; + if (!AudioContext) return; + const context = new AudioContext(); + const oscillator = context.createOscillator(); + const gain = context.createGain(); + oscillator.frequency.value = 620; + gain.gain.setValueAtTime(0.0001, context.currentTime); + gain.gain.exponentialRampToValueAtTime(0.035, context.currentTime + 0.015); + gain.gain.exponentialRampToValueAtTime(0.0001, context.currentTime + 0.16); + oscillator.connect(gain); gain.connect(context.destination); + oscillator.start(); oscillator.stop(context.currentTime + 0.17); + oscillator.onended = () => context.close().catch(() => {}); + } catch (_) {} + } + + function notifyNewMail(data, accountId) { + const prefs = state.notificationPrefs || {}; + if (!prefs.enabled || dndActive(prefs)) return; + const folders = prefs.folders || []; + if (folders.length && !folders.includes(data.folder)) return; + if ("Notification" in window && Notification.permission === "granted") { + const account = state.accounts.find((item) => item.id === accountId); + const title = data.latest_sender || account?.email || "New mail"; + const body = data.latest_subject || `${data.count || 1} new message(s) in ${data.folder}`; + const notification = new Notification(title, { body, tag:`rustbox-${accountId}-${data.folder}`, renotify:true }); + notification.onclick = () => { window.focus(); notification.close(); }; + } + if (prefs.sound) playNotificationSound(); + } + + function scheduleRealtimeRefresh(event, accountId) { + if (accountId !== state.accountId) return; + clearTimeout(state.realtimeRefreshTimer); + state.realtimeRefreshTimer = setTimeout(async () => { + await refreshFolderListOnly(); + if (["NewMessages", "FlagsChanged"].includes(event.type) && event.data?.folder === state.folder) { + if (state.searchContext) await runSearch(new URLSearchParams(state.searchContext.params), state.searchContext.label, { restoreScrollTop:state.searchContext.scrollTop }); + else await loadMessages(true); + } + }, 250); + } + + function connectRealtime() { + if (!state.accounts.length) return; + if (state.ws && [WebSocket.OPEN, WebSocket.CONNECTING].includes(state.ws.readyState)) return; + clearTimeout(state.wsRetryTimer); + const protocol = location.protocol === "https:" ? "wss:" : "ws:"; + const socket = new WebSocket(`${protocol}//${location.host}${BASE}/api/ws`); + state.ws = socket; + socket.onmessage = (message) => { + try { + const payload = JSON.parse(message.data); + const event = payload.event || {}; + if (event.type === "NewMessages") notifyNewMail(event.data || {}, payload.accountId); + if (["NewMessages", "FlagsChanged", "FolderUpdated"].includes(event.type)) scheduleRealtimeRefresh(event, payload.accountId); + } catch (error) { console.warn("Invalid realtime event", error); } + }; + socket.onclose = () => { + if (state.ws === socket) state.ws = null; + if (state.accounts.length) state.wsRetryTimer = setTimeout(connectRealtime, 2500); + }; + socket.onerror = () => socket.close(); + } + + function disconnectRealtime() { + clearTimeout(state.wsRetryTimer); + clearTimeout(state.realtimeRefreshTimer); + state.wsRetryTimer = null; + state.realtimeRefreshTimer = null; + const socket = state.ws; + state.ws = null; + if (socket && socket.readyState < WebSocket.CLOSING) socket.close(); + } + + function capabilityBadge(label, supported) { + return `${escapeHtml(label)}${supported ? "yes" : "no"}`; + } + + async function loadDiagnostics() { + const imapStatus = $("#diagnostic-imap-status"); + const smtpStatus = $("#diagnostic-smtp-status"); + const sieveStatus = $("#diagnostic-sieve-status"); + const wsStatus = $("#diagnostic-ws-status"); + const syncStatus = $("#diagnostic-sync-status"); + const searchStatus = $("#diagnostic-search-status"); + imapStatus.textContent = "Checking"; + smtpStatus.textContent = "Checking"; + sieveStatus.textContent = "Checking"; + syncStatus.textContent = "Checking"; + searchStatus.textContent = "Checking"; + wsStatus.textContent = state.ws?.readyState === WebSocket.OPEN ? "Connected" : "Disconnected"; + try { + const info = await request("/diagnostics/imap", { method:"GET" }); + imapStatus.textContent = info.connected ? "Connected" : "Unavailable"; + $("#diagnostic-imap-summary").innerHTML = [ + ["IDLE", info.idle], ["MOVE", info.moveExtension], ["UIDPLUS", info.uidplus], ["CONDSTORE", info.condstore], + ["QRESYNC", info.qresync], ["SORT", info.sort], ["THREAD", info.thread], ["NAMESPACE", info.namespace], + ["SPECIAL-USE", info.specialUse], ["QUOTA", info.quota], ["ACL", info.acl], ["LIST-EXTENDED", info.listExtended], ["UTF8=ACCEPT", info.utf8Accept], ["CUSTOM KEYWORDS", info.customKeywords], + ].map(([label, supported]) => capabilityBadge(label, supported)).join(""); + $("#diagnostic-imap-caps").textContent = (info.capabilities || []).join(" · ") || "No capabilities reported"; + } catch (error) { + imapStatus.textContent = "Unavailable"; + $("#diagnostic-imap-summary").textContent = error.message; + $("#diagnostic-imap-caps").textContent = ""; + } + try { + const info = await request("/diagnostics/smtp", { method:"GET" }); + smtpStatus.textContent = info.connected ? "Connected" : "Unavailable"; + $("#diagnostic-smtp-summary").textContent = info.connected ? (info.secureTransport ? "Authenticated connection · TLS enabled" : "Authenticated connection · plaintext transport") : "SMTP unavailable"; + } catch (error) { + smtpStatus.textContent = "Unavailable"; + $("#diagnostic-smtp-summary").textContent = error.message; + } + try { + const info = await request("/sieve/status", { method:"GET" }); + sieveStatus.textContent = info.connected ? "Connected" : "Unavailable"; + $("#diagnostic-sieve-summary").textContent = `${info.tlsMode || ""}${info.implementation ? ` · ${info.implementation}` : ""}${info.extensions?.length ? ` · ${info.extensions.join(", ")}` : ""}`; + } catch (error) { + sieveStatus.textContent = "Unavailable"; + $("#diagnostic-sieve-summary").textContent = error.message; + } + try { + const info = await request("/diagnostics/sync", { method:"GET" }); + syncStatus.textContent = info.unsyncedFolders ? "Partial" : "Ready"; + const folderSync = info.latestFolderListSync ? formatDate(info.latestFolderListSync, true) : "never"; + const messageSync = info.latestMessageSync ? formatDate(info.latestMessageSync, true) : "never"; + $("#diagnostic-sync-summary").textContent = `${info.cachedFolders} cached folders · ${info.cachedMessages} cached messages · folder list ${folderSync} · messages ${messageSync}${info.unsyncedFolders ? ` · ${info.unsyncedFolders} folders awaiting message sync` : ""} · ${info.idleTasks || 0} IDLE task(s) / ${info.idleSubscribers || 0} subscriber(s)`; + } catch (error) { + syncStatus.textContent = "Unavailable"; + $("#diagnostic-sync-summary").textContent = error.message; + } + try { + const info = await request("/diagnostics/search", { method:"GET" }); + searchStatus.textContent = "Ready"; + $("#diagnostic-search-summary").textContent = `${info.indexedDocuments} indexed · ${info.cachedMessages} cached messages · ${info.cachedBodies} cached bodies${info.excludedMessages ? ` · ${info.excludedMessages} excluded` : ""}`; + } catch (error) { + searchStatus.textContent = "Unavailable"; + $("#diagnostic-search-summary").textContent = error.message; + } + } + + async function reconnectImap() { + const button = $("#diagnostic-imap-reconnect"); + if (button) button.disabled = true; + try { + const result = await request("/diagnostics/imap/reconnect", { method:"POST", body:"{}" }); + toast(`IMAP reconnected · ${result.capabilityCount || 0} capabilities`); + await loadDiagnostics(); + await loadFolders(false); + } catch (error) { + toast(error.message, "error"); + } finally { + if (button) button.disabled = false; + } + } + + async function rebuildSearchIndex() { + const button = $("#diagnostic-search-rebuild"); + if (!await appConfirm("Rebuild the search index from the local message cache?", { title:"Rebuild search index", confirmLabel:"Rebuild" })) return; + button.disabled = true; + try { + const result = await request("/diagnostics/search/rebuild", { method:"POST", body:"{}" }); + toast(`Search index rebuilt: ${result.indexedDocuments} messages`); + await loadDiagnostics(); + } catch (error) { + toast(error.message, "error"); + } finally { + button.disabled = false; + } + } + + async function loadSieve() { + const status = $("#sieve-status"); + status.textContent = "Connecting…"; + try { + const [info, scripts] = await Promise.all([request("/sieve/status", { method: "GET" }), request("/sieve/scripts", { method: "GET" })]); + status.textContent = `${info.host}:${info.port} · ${info.tlsMode} · ${info.implementation || "ManageSieve"}`; + state.sieveInfo = info; + state.sieveScripts = scripts.scripts || []; + renderSieveScripts(); + const selectedName = $("#sieve-name")?.value.trim(); + const target = state.sieveScripts.find((script) => script.name === selectedName) + || state.sieveScripts.find((script) => script.active) + || state.sieveScripts[0]; + if (target) await openSieveScript(target.name); + else { + $("#sieve-editor").value = ""; + clearSieveRuleBuilder(); + updateSieveBuilder(); + } + } catch (error) { + status.textContent = error.message; + toast(error.message, "error"); + } + } + + function sieveQuote(value) { + return String(value ?? "").replaceAll("\\", "\\\\").replaceAll('"', '\\"').replace(/[\r\n]+/g, " "); + } + + function sieveBuilderRequirements(condition, action) { + const requirements = new Set(); + if (["move", "copy"].includes(action)) requirements.add("fileinto"); + if (["copy", "redirect-copy"].includes(action)) requirements.add("copy"); + if (["mark-read", "add-flag", "set-flags"].includes(action)) requirements.add("imap4flags"); + return [...requirements]; + } + + function mergeSieveRequirements(script, requirements) { + const found = new Set(requirements); + const without = String(script || "").replace(/^\s*require\s+(?:\[([^\]]*)\]|"([^"]+)")\s*;\s*$/gim, (_line, list, single) => { + const raw = single ? [`"${single}"`] : (list || "").split(","); + for (const item of raw) { + const match = item.match(/"([^"]+)"/); + if (match) found.add(match[1]); + } + return ""; + }).trim(); + const header = found.size ? `require [${[...found].sort().map((item) => `"${sieveQuote(item)}"`).join(",")}];` : ""; + return [header, without].filter(Boolean).join("\n\n").trim() + "\n"; + } + + function sieveMatchLabel(match) { + return match === "is" ? "is exactly" : match === "matches" ? "matches pattern" : "contains"; + } + + function sieveConditionLabel(condition, header = "") { + if (condition === "from") return "From address"; + if (condition === "from-domain") return "From domain"; + if (condition === "to") return "To / Cc address"; + if (condition === "header-from") return "From header"; + if (condition === "header-to") return "To header"; + if (condition === "subject") return "Subject"; + if (condition === "list-id") return "List-ID"; + return header || "Header"; + } + + function buildSieveCondition(suffix = "", required = true) { + const condition = $(`#sieve-builder-condition${suffix}`).value; + const value = $(`#sieve-builder-value${suffix}`).value.trim(); + if (!condition || !value) { + if (required) throw new Error("Rule value is required"); + return null; + } + const match = $(`#sieve-builder-match${suffix}`).value; + const matchToken = match === "is" ? ":is" : match === "matches" ? ":matches" : ":contains"; + let test; + let header = ""; + if (condition === "from") test = `address ${matchToken} "from" "${sieveQuote(value)}"`; + else if (condition === "from-domain") test = `address :domain ${matchToken} "from" "${sieveQuote(value)}"`; + else if (condition === "to") test = `address ${matchToken} ["to", "cc"] "${sieveQuote(value)}"`; + else if (condition === "header-from") test = `header ${matchToken} "from" "${sieveQuote(value)}"`; + else if (condition === "header-to") test = `header ${matchToken} "to" "${sieveQuote(value)}"`; + else if (condition === "subject") test = `header ${matchToken} "subject" "${sieveQuote(value)}"`; + else if (condition === "list-id") test = `header ${matchToken} "list-id" "${sieveQuote(value)}"`; + else { + header = $(`#sieve-builder-header${suffix}`).value.trim(); + if (!/^[A-Za-z0-9-]+$/.test(header)) { + const label = suffix === "-3" ? "Third custom" : suffix === "-2" ? "Second custom" : "Custom"; + throw new Error(`${label} header name is invalid`); + } + test = `header ${matchToken} "${sieveQuote(header)}" "${sieveQuote(value)}"`; + } + const negate = $(`#sieve-builder-negate${suffix}`).checked; + if (negate) test = `not ${test}`; + const summary = `${negate ? "NOT " : ""}${sieveConditionLabel(condition, header)} ${sieveMatchLabel(match)} “${value}”`; + return { test, condition, match, value, header, negate, summary }; + } + + function sieveFlagValues(target) { + return String(target || "").split(",").map((value) => value.trim()).filter(Boolean); + } + + function buildSieveActionFromValues(action, target, label = "Action") { + let command; + let summary; + if (action === "move") { + if (!target) throw new Error(`${label} target folder is required`); + command = `fileinto "${sieveQuote(target)}";`; + summary = `Move to “${target}”`; + } else if (action === "copy") { + if (!target) throw new Error(`${label} target folder is required`); + command = `fileinto :copy "${sieveQuote(target)}";`; + summary = `Copy to “${target}”`; + } else if (action === "redirect" || action === "redirect-copy") { + if (!target || !target.includes("@")) throw new Error(`${label} redirect address is required`); + command = `redirect${action === "redirect-copy" ? " :copy" : ""} "${sieveQuote(target)}";`; + summary = action === "redirect-copy" ? `Redirect a copy to ${target}` : `Redirect to ${target}`; + } else if (action === "discard") { + command = "discard;"; + summary = "Discard the message"; + } else if (action === "mark-read") { + command = 'addflag "\\Seen";'; + summary = "Mark as read"; + } else if (action === "add-flag") { + if (!target) throw new Error(`${label} IMAP flag is required`); + command = `addflag "${sieveQuote(target)}";`; + summary = `Add IMAP flag ${target}`; + } else if (action === "set-flags") { + const flags = sieveFlagValues(target); + if (!flags.length) throw new Error(`${label} IMAP flags are required`); + command = `setflag [${flags.map((flag) => `"${sieveQuote(flag)}"`).join(",")}];`; + summary = `Set IMAP flags ${flags.join(", ")}`; + } else { + command = "keep;"; + summary = "Keep in the inbox"; + } + return { action, target, command, summary, requirements: sieveBuilderRequirements("", action) }; + } + + function buildSieveAction(suffix = "", required = true) { + const action = $(`#sieve-builder-action${suffix}`).value; + const target = $(`#sieve-builder-target${suffix}`)?.value.trim() || ""; + if (!action) { + if (required) throw new Error("Action is required"); + return null; + } + return buildSieveActionFromValues(action, target, suffix ? "Second action" : "Action"); + } + + function newSieveVisualRuleId() { + const random = globalThis.crypto?.randomUUID?.() || `${Date.now()}-${Math.random().toString(16).slice(2)}`; + return `rule-${random.replace(/[^A-Za-z0-9_-]/g, "")}`; + } + + function sieveUnescape(value) { + return String(value || "").replace(/\\([\\"])/g, "$1"); + } + + function sieveQuotedValues(value) { + const values = []; + const pattern = /"((?:\\.|[^"\\])*)"/g; + let match; + while ((match = pattern.exec(String(value || "")))) values.push(sieveUnescape(match[1])); + return values; + } + + function splitSieveTopLevel(value) { + const result = []; + let start = 0; + let depth = 0; + let bracket = 0; + let quoted = false; + let escaped = false; + const text = String(value || ""); + for (let index = 0; index < text.length; index += 1) { + const char = text[index]; + if (quoted) { + if (escaped) escaped = false; + else if (char === "\\") escaped = true; + else if (char === '"') quoted = false; + continue; + } + if (char === '"') { quoted = true; continue; } + if (char === "(") depth += 1; + else if (char === ")") depth = Math.max(0, depth - 1); + else if (char === "[") bracket += 1; + else if (char === "]") bracket = Math.max(0, bracket - 1); + else if (char === "," && depth === 0 && bracket === 0) { + result.push(text.slice(start, index).trim()); + start = index + 1; + } + } + result.push(text.slice(start).trim()); + return result.filter(Boolean); + } + + function splitSieveStatements(value) { + const result = []; + let start = 0; + let quoted = false; + let escaped = false; + const text = String(value || ""); + for (let index = 0; index < text.length; index += 1) { + const char = text[index]; + if (quoted) { + if (escaped) escaped = false; + else if (char === "\\") escaped = true; + else if (char === '"') quoted = false; + continue; + } + if (char === '"') { quoted = true; continue; } + if (char === "#") { + while (index < text.length && text[index] !== "\n") index += 1; + continue; + } + if (char === ";") { + const statement = text.slice(start, index + 1).trim(); + if (statement) result.push(statement); + start = index + 1; + } + } + return result; + } + + function findSieveClosingBrace(source, openIndex) { + let depth = 0; + let quoted = false; + let escaped = false; + for (let index = openIndex; index < source.length; index += 1) { + const char = source[index]; + if (quoted) { + if (escaped) escaped = false; + else if (char === "\\") escaped = true; + else if (char === '"') quoted = false; + continue; + } + if (char === '"') { quoted = true; continue; } + if (char === "#") { + while (index < source.length && source[index] !== "\n") index += 1; + continue; + } + if (char === "{") depth += 1; + else if (char === "}") { + depth -= 1; + if (depth === 0) return index; + } + } + return -1; + } + + function parseSieveConditionToken(value) { + let raw = String(value || "").trim(); + const negate = /^not\s+/i.test(raw); + if (negate) raw = raw.replace(/^not\s+/i, "").trim(); + const match = raw.match(/^(header|address)\s+(?:(:domain)\s+)?:(contains|is|matches)\s+([\s\S]+)$/i); + if (!match) return { raw:value, supported:false, summary:String(value || "").trim() || "Unsupported condition" }; + const values = sieveQuotedValues(match[4]); + if (values.length < 2) return { raw:value, supported:false, summary:String(value || "").trim() }; + const targetValue = values.at(-1); + const fields = values.slice(0, -1).map((item) => item.toLowerCase()); + const primaryField = fields[0] || ""; + let kind = "header"; + let header = primaryField; + const testType = match[1].toLowerCase(); + if (testType === "address" && primaryField === "from") kind = match[2] ? "from-domain" : "from"; + else if (testType === "address" && (fields.includes("to") || fields.includes("cc"))) kind = "to"; + else if (testType === "header" && primaryField === "from") kind = "header-from"; + else if (testType === "header" && primaryField === "to" && fields.length === 1) kind = "header-to"; + else if (primaryField === "subject") kind = "subject"; + else if (primaryField === "list-id") kind = "list-id"; + const comparator = match[3].toLowerCase(); + const summary = `${negate ? "NOT " : ""}${sieveConditionLabel(kind, header)} ${sieveMatchLabel(comparator)} “${targetValue}”`; + return { raw:value, supported:true, kind, header, match:comparator, value:targetValue, negate, summary }; + } + + function parseSieveConditionExpression(value) { + const raw = String(value || "").trim(); + const group = raw.match(/^(allof|anyof)\s*\(([\s\S]*)\)$/i); + if (!group) { + const condition = parseSieveConditionToken(raw); + return { logic:"allof", conditions:[condition], supported:condition.supported, mixed:false, summary:condition.summary }; + } + const logic = group[1].toLowerCase(); + const parts = splitSieveTopLevel(group[2]); + const conditions = []; + let supported = true; + let mixed = false; + const summaries = []; + for (const part of parts) { + const parsed = parseSieveConditionExpression(part); + if (parsed.conditions.length > 1 && parsed.logic !== logic) mixed = true; + conditions.push(...parsed.conditions); + supported = supported && parsed.supported; + mixed = mixed || parsed.mixed; + summaries.push(parsed.summary); + } + return { logic, conditions, supported, mixed, summary:summaries.join(logic === "anyof" ? " OR " : " AND ") }; + } + + function parseSieveActionStatement(statement) { + const raw = String(statement || "").trim().replace(/;\s*$/, ""); + let match = raw.match(/^fileinto\s+(:copy\s+)?"((?:\\.|[^"\\])*)"$/i); + if (match) { + const target = sieveUnescape(match[2]); + const action = match[1] ? "copy" : "move"; + return { action, target, supported:true, summary:action === "copy" ? `Copy to “${target}”` : `Move to “${target}”` }; + } + match = raw.match(/^redirect\s+(:copy\s+)?"((?:\\.|[^"\\])*)"$/i); + if (match) { + const target = sieveUnescape(match[2]); + const action = match[1] ? "redirect-copy" : "redirect"; + return { action, target, supported:true, summary:action === "redirect-copy" ? `Redirect a copy to ${target}` : `Redirect to ${target}` }; + } + match = raw.match(/^addflag\s+([\s\S]+)$/i); + if (match) { + const flags = sieveQuotedValues(match[1]); + const target = flags.join(", "); + const markRead = flags.length === 1 && flags[0] === "\\Seen"; + return { action:markRead ? "mark-read" : "add-flag", target:markRead ? "" : target, supported:Boolean(flags.length), summary:markRead ? "Mark as read" : `Add IMAP flag ${target}` }; + } + match = raw.match(/^setflag\s+([\s\S]+)$/i); + if (match) { + const flags = sieveQuotedValues(match[1]); + const target = flags.join(", "); + return { action:"set-flags", target, supported:Boolean(flags.length), summary:`Set IMAP flags ${target}` }; + } + if (/^discard$/i.test(raw)) return { action:"discard", target:"", supported:true, summary:"Discard the message" }; + if (/^keep$/i.test(raw)) return { action:"keep", target:"", supported:true, summary:"Keep in the inbox" }; + if (/^stop$/i.test(raw)) return { action:"stop", target:"", supported:true, stop:true, summary:"Stop processing more rules" }; + return { action:"unsupported", target:"", supported:false, summary:raw || "Unsupported action", raw }; + } + + function parseSieveRules(script = $("#sieve-editor")?.value || "") { + const source = String(script || ""); + const markerPattern = /^\s*#\s*(?:rule:\[([^\]\r\n]+)\]|RUSTBOX-RULE-BEGIN\s+([A-Za-z0-9_-]+))\s*$/gim; + const markers = []; + let marker; + while ((marker = markerPattern.exec(source))) markers.push({ index:marker.index, end:markerPattern.lastIndex, name:marker[1]?.trim() || "", rustboxId:marker[2] || "" }); + const rules = []; + const nameCounts = new Map(); + for (let markerIndex = 0; markerIndex < markers.length; markerIndex += 1) { + const info = markers[markerIndex]; + const boundary = markers[markerIndex + 1]?.index ?? source.length; + const segment = source.slice(info.end, boundary); + const ifMatch = /\bif\s+([\s\S]*?)\{/i.exec(segment); + if (!ifMatch) continue; + const openBrace = info.end + ifMatch.index + ifMatch[0].lastIndexOf("{"); + const closeBrace = findSieveClosingBrace(source, openBrace); + if (closeBrace < 0 || closeBrace >= boundary) continue; + const conditionOffset = ifMatch[0].indexOf(ifMatch[1]); + const conditionStart = info.end + ifMatch.index + conditionOffset; + const conditionEnd = conditionStart + ifMatch[1].length; + const rawCondition = ifMatch[1].trim(); + const disabledMatch = rawCondition.match(/^false\s*#\s*([\s\S]+)$/i); + const enabled = !disabledMatch; + const actualCondition = (disabledMatch ? disabledMatch[1] : rawCondition).trim(); + const condition = parseSieveConditionExpression(actualCondition); + const body = source.slice(openBrace + 1, closeBrace); + const parsedActions = splitSieveStatements(body).map(parseSieveActionStatement); + const stop = parsedActions.some((action) => action.stop); + const actions = parsedActions.filter((action) => !action.stop); + let end = closeBrace + 1; + if (info.rustboxId) { + const tail = source.slice(end, boundary); + const endMarker = new RegExp(`^\\s*#\\s*RUSTBOX-RULE-END\\s*$`, "im").exec(tail); + if (endMarker) end += endMarker.index + endMarker[0].length; + } + const fallbackName = info.rustboxId ? `RustBox ${info.rustboxId.replace(/^rule-/, "").slice(0, 12)}` : `Rule ${rules.length + 1}`; + const name = info.name || fallbackName; + const count = (nameCounts.get(name) || 0) + 1; + nameCounts.set(name, count); + const id = `${info.rustboxId ? "rustbox" : "named"}:${name}:${count}`; + const editable = condition.supported && !condition.mixed && actions.every((action) => action.supported); + rules.push({ + id, name, enabled, start:info.index, end, full:source.slice(info.index, end), rawCondition, actualCondition, + conditionStart, conditionEnd, condition, actions, stop, editable, rustboxId:info.rustboxId, + }); + } + return rules; + } + + function setSieveConditionControls(suffix, condition) { + const kind = condition?.kind || ""; + $(`#sieve-builder-condition${suffix}`).value = kind; + $(`#sieve-builder-match${suffix}`).value = condition?.match || "contains"; + $(`#sieve-builder-value${suffix}`).value = condition?.value || ""; + $(`#sieve-builder-negate${suffix}`).checked = Boolean(condition?.negate); + const header = $(`#sieve-builder-header${suffix}`); + if (header) header.value = condition?.header || ""; + } + + function setSieveDynamicCondition(row, condition, logic) { + row.querySelector(".sieve-dynamic-logic").value = logic === "anyof" ? "anyof" : "allof"; + row.querySelector(".sieve-dynamic-kind").value = condition.kind || "from"; + row.querySelector(".sieve-dynamic-match").value = condition.match || "contains"; + row.querySelector(".sieve-dynamic-header").value = condition.header || ""; + row.querySelector(".sieve-dynamic-value").value = condition.value || ""; + row.querySelector(".sieve-dynamic-negate").checked = Boolean(condition.negate); + } + + function setSieveActionControls(suffix, action) { + $(`#sieve-builder-action${suffix}`).value = action?.action || (suffix ? "" : "move"); + const target = $(`#sieve-builder-target${suffix}`); + if (target) target.value = action?.target || ""; + } + + function setSieveDynamicAction(row, action) { + row.querySelector(".sieve-dynamic-action").value = action.action || "keep"; + row.querySelector(".sieve-dynamic-target").value = action.target || ""; + configureSieveDynamicAction(row); + } + + function showSieveRuleDialog(mode = "new", ruleName = "") { + const dialog = $("#sieve-rule-dialog"); + if (!dialog) return; + $("#sieve-rule-dialog-title").textContent = mode === "edit" ? `Edit filter · ${ruleName}` : "Create filter"; + $("#sieve-rule-dialog-subtitle").textContent = mode === "edit" + ? "Change the rule visually; the live preview shows exactly what will be written to Sieve." + : "Build the rule visually and verify the preview before applying it."; + if (!dialog.open) dialog.showModal(); + } + + function openNewSieveRuleDialog() { + clearSieveRuleBuilder(); + showSieveRuleDialog("new"); + setTimeout(() => $("#sieve-builder-name")?.focus(), 20); + } + + function loadSieveRuleIntoBuilder(rule) { + if (!rule?.editable) return toast("This rule uses Sieve syntax the visual editor cannot safely rewrite. Edit it in Advanced Sieve source.", "warning"); + state.sieveEditingRuleId = rule.id; + $("#sieve-builder-name").value = rule.name; + $("#sieve-builder-enabled").checked = rule.enabled; + $("#sieve-builder-stop").checked = rule.stop; + $("#sieve-builder-extra-conditions").replaceChildren(); + $("#sieve-builder-extra-actions").replaceChildren(); + const conditions = rule.condition.conditions; + setSieveConditionControls("", conditions[0]); + setSieveConditionControls("-2", conditions[1] || null); + setSieveConditionControls("-3", conditions[2] || null); + $("#sieve-builder-logic").value = rule.condition.logic === "anyof" ? "anyof" : "allof"; + $("#sieve-builder-logic-3").value = rule.condition.logic === "anyof" ? "anyof" : "allof"; + for (const condition of conditions.slice(3)) { + const row = createSieveDynamicCondition(); + setSieveDynamicCondition(row, condition, rule.condition.logic); + $("#sieve-builder-extra-conditions").append(row); + } + setSieveActionControls("", rule.actions[0] || { action:"keep", target:"" }); + setSieveActionControls("-2", rule.actions[1] || null); + for (const action of rule.actions.slice(2)) { + const row = createSieveDynamicAction(); + setSieveDynamicAction(row, action); + $("#sieve-builder-extra-actions").append(row); + } + $("#sieve-builder-replace").disabled = false; + updateSieveBuilder(); + renderManagedSieveRules(); + showSieveRuleDialog("edit", rule.name); + setTimeout(() => $("#sieve-builder-name")?.focus(), 20); + } + + function clearSieveRuleBuilder() { + state.sieveEditingRuleId = null; + $("#sieve-builder-name").value = ""; + $("#sieve-builder-enabled").checked = true; + $("#sieve-builder-stop").checked = true; + $("#sieve-builder-condition").value = "from"; + $("#sieve-builder-match").value = "contains"; + $("#sieve-builder-value").value = ""; + $("#sieve-builder-header").value = ""; + $("#sieve-builder-negate").checked = false; + for (const suffix of ["-2", "-3"]) { + $(`#sieve-builder-condition${suffix}`).value = ""; + $(`#sieve-builder-match${suffix}`).value = "contains"; + $(`#sieve-builder-value${suffix}`).value = ""; + $(`#sieve-builder-header${suffix}`).value = ""; + $(`#sieve-builder-negate${suffix}`).checked = false; + } + $("#sieve-builder-logic").value = "allof"; + $("#sieve-builder-logic-3").value = "allof"; + $("#sieve-builder-extra-conditions").replaceChildren(); + $("#sieve-builder-action").value = "move"; + $("#sieve-builder-target").value = ""; + $("#sieve-builder-action-2").value = ""; + $("#sieve-builder-target-2").value = ""; + $("#sieve-builder-extra-actions").replaceChildren(); + $("#sieve-builder-replace").disabled = true; + updateSieveBuilder(); + renderManagedSieveRules(); + if ($("#sieve-rule-dialog")?.open) { + $("#sieve-rule-dialog-title").textContent = "Create filter"; + $("#sieve-rule-dialog-subtitle").textContent = "Build the rule visually and verify the preview before applying it."; + } + } + + function moveParsedSieveRule(ruleId, delta) { + const editor = $("#sieve-editor"); + const rules = parseSieveRules(editor.value); + const index = rules.findIndex((rule) => rule.id === ruleId); + const target = index + delta; + if (index < 0 || target < 0 || target >= rules.length) return; + const first = delta > 0 ? rules[index] : rules[target]; + const second = delta > 0 ? rules[target] : rules[index]; + const separator = editor.value.slice(first.end, second.start); + const replacement = second.full + separator + first.full; + editor.value = editor.value.slice(0, first.start) + replacement + editor.value.slice(second.end); + state.sieveEditingRuleId = null; + renderManagedSieveRules(); + $("#sieve-result").textContent = "Rule order changed — save to apply"; + } + + function toggleSieveRuleEnabled(rule) { + const editor = $("#sieve-editor"); + const source = editor.value; + const replacement = rule.enabled ? `false # ${rule.actualCondition}` : rule.actualCondition; + editor.value = source.slice(0, rule.conditionStart) + replacement + source.slice(rule.conditionEnd); + state.sieveEditingRuleId = null; + renderManagedSieveRules(); + $("#sieve-result").textContent = `${rule.name} ${rule.enabled ? "disabled" : "enabled"} — save to apply`; + } + + async function deleteSieveRule(rule) { + if (!await appConfirm(`Delete Sieve rule “${rule.name}”?`, { title:"Delete filter rule", confirmLabel:"Delete", danger:true })) return; + const editor = $("#sieve-editor"); + editor.value = `${editor.value.slice(0, rule.start).trimEnd()}\n\n${editor.value.slice(rule.end).trimStart()}`.trim() + "\n"; + state.sieveEditingRuleId = null; + clearSieveRuleBuilder(); + renderManagedSieveRules(); + $("#sieve-result").textContent = `Rule “${rule.name}” removed — save to apply`; + } + + function renderManagedSieveRules() { + const list = $("#sieve-managed-rules"); + if (!list) return; + const rules = parseSieveRules(); + const count = $("#sieve-rule-count"); + if (count) count.textContent = String(rules.length); + if (state.sieveEditingRuleId && !rules.some((rule) => rule.id === state.sieveEditingRuleId)) { + state.sieveEditingRuleId = null; + const update = $("#sieve-builder-replace"); + if (update) update.disabled = true; + } + list.replaceChildren(); + if (!rules.length) { + list.append(el("div", "muted", "No named rules found. Rules using # rule:[name] are parsed automatically.")); + return; + } + rules.forEach((rule, index) => { + const item = el("article", `sieve-managed-rule${rule.enabled ? "" : " disabled"}${rule.editable ? "" : " unsupported"}${state.sieveEditingRuleId === rule.id ? " selected" : ""}`); + const main = el("div", "sieve-managed-rule-main"); + const title = el("div", "sieve-managed-rule-title"); + title.append(el("strong", "", rule.name), el("span", `sieve-rule-state ${rule.enabled ? "enabled" : ""}`, rule.enabled ? "ON" : "OFF")); + const summary = el("div", "sieve-managed-rule-summary"); + const when = el("div"); when.innerHTML = `When: ${escapeHtml(rule.condition.summary || rule.actualCondition)}`; + const actionText = rule.actions.length ? rule.actions.map((action) => action.summary).join("; ") : "No action parsed"; + const then = el("div"); then.innerHTML = `Then: ${escapeHtml(actionText)}${rule.stop ? "; stop processing more rules" : ""}`; + summary.append(when, then); + main.append(title, summary); + if (!rule.editable) main.append(el("div", "sieve-rule-warning", "Uses syntax not fully supported by the visual editor; source is preserved unchanged.")); + const actions = el("div", "sieve-managed-rule-actions"); + const edit = el("button", "button quiet small", "Edit"); edit.type="button"; edit.disabled=!rule.editable; edit.addEventListener("click", () => loadSieveRuleIntoBuilder(rule)); + const toggle = el("button", "button quiet small", rule.enabled ? "Disable" : "Enable"); toggle.type="button"; toggle.addEventListener("click", () => toggleSieveRuleEnabled(rule)); + const up = el("button", "button quiet small", "↑"); up.type="button"; up.title="Move rule up"; up.disabled=index===0; up.addEventListener("click", () => moveParsedSieveRule(rule.id, -1)); + const down = el("button", "button quiet small", "↓"); down.type="button"; down.title="Move rule down"; down.disabled=index===rules.length-1; down.addEventListener("click", () => moveParsedSieveRule(rule.id, 1)); + const remove = el("button", "button quiet small danger", "Delete"); remove.type="button"; remove.addEventListener("click", () => deleteSieveRule(rule)); + actions.append(edit, toggle, up, down, remove); + item.append(main, actions); + list.append(item); + }); + } + + function createSieveDynamicCondition() { + const row = el("div", "sieve-dynamic-row sieve-dynamic-condition"); + row.innerHTML = ``; + const refresh = () => { + const kind = row.querySelector(".sieve-dynamic-kind").value; + row.querySelector(".sieve-dynamic-header-wrap").classList.toggle("hidden", kind !== "header"); + updateSieveBuilder(); + }; + row.querySelector(".sieve-dynamic-kind").addEventListener("change", refresh); + row.querySelectorAll("select,input").forEach((node) => node.addEventListener("input", updateSieveBuilder)); + row.querySelector(".sieve-dynamic-remove").addEventListener("click", () => { row.remove(); updateSieveBuilder(); }); + return row; + } + + function buildSieveDynamicCondition(row) { + const condition = row.querySelector(".sieve-dynamic-kind").value; + const value = row.querySelector(".sieve-dynamic-value").value.trim(); + if (!condition || !value) throw new Error("Every additional condition needs a value"); + const match = row.querySelector(".sieve-dynamic-match").value; + const matchToken = match === "is" ? ":is" : match === "matches" ? ":matches" : ":contains"; + let test; + let header = ""; + if (condition === "from") test = `address ${matchToken} "from" "${sieveQuote(value)}"`; + else if (condition === "from-domain") test = `address :domain ${matchToken} "from" "${sieveQuote(value)}"`; + else if (condition === "to") test = `address ${matchToken} ["to", "cc"] "${sieveQuote(value)}"`; + else if (condition === "header-from") test = `header ${matchToken} "from" "${sieveQuote(value)}"`; + else if (condition === "header-to") test = `header ${matchToken} "to" "${sieveQuote(value)}"`; + else if (condition === "subject") test = `header ${matchToken} "subject" "${sieveQuote(value)}"`; + else if (condition === "list-id") test = `header ${matchToken} "list-id" "${sieveQuote(value)}"`; + else { + header = row.querySelector(".sieve-dynamic-header").value.trim(); + if (!/^[A-Za-z0-9-]+$/.test(header)) throw new Error("Additional custom header name is invalid"); + test = `header ${matchToken} "${sieveQuote(header)}" "${sieveQuote(value)}"`; + } + const negate = row.querySelector(".sieve-dynamic-negate").checked; + if (negate) test = `not ${test}`; + return { test, condition, match, value, header, negate, logic:row.querySelector(".sieve-dynamic-logic").value === "anyof" ? "anyof" : "allof", summary:`${negate ? "NOT " : ""}${sieveConditionLabel(condition, header)} ${sieveMatchLabel(match)} “${value}”` }; + } + + function createSieveDynamicAction() { + const row = el("div", "sieve-dynamic-row sieve-dynamic-action-row"); + row.innerHTML = ``; + const refresh = () => { configureSieveDynamicAction(row); updateSieveBuilder(); }; + row.querySelector(".sieve-dynamic-action").addEventListener("change", refresh); + row.querySelector(".sieve-dynamic-target").addEventListener("input", updateSieveBuilder); + row.querySelector(".sieve-dynamic-remove").addEventListener("click", () => { row.remove(); updateSieveBuilder(); }); + configureSieveDynamicAction(row); + return row; + } + + function configureSieveDynamicAction(row) { + const action = row.querySelector(".sieve-dynamic-action").value; + const wrap = row.querySelector(".sieve-dynamic-target-wrap"); + const label = row.querySelector(".sieve-dynamic-target-label"); + const target = row.querySelector(".sieve-dynamic-target"); + const needsTarget = ["move", "copy", "redirect", "redirect-copy", "add-flag", "set-flags"].includes(action); + wrap.classList.toggle("hidden", !needsTarget); + label.textContent = ["redirect", "redirect-copy"].includes(action) ? "Address" : ["add-flag", "set-flags"].includes(action) ? "IMAP flag(s)" : "Folder"; + target.placeholder = ["redirect", "redirect-copy"].includes(action) ? "user@example.com" : action === "set-flags" ? "\\Seen, \\Flagged" : action === "add-flag" ? "$Label1" : "Archive"; + target.removeAttribute("list"); + if (["move", "copy"].includes(action)) target.setAttribute("list", "sieve-folder-options"); + } + + function buildSieveDynamicAction(row) { + return buildSieveActionFromValues(row.querySelector(".sieve-dynamic-action").value, row.querySelector(".sieve-dynamic-target").value.trim(), "Additional action"); + } + + function cleanSieveRuleName(value) { + const name = String(value || "").trim(); + if (!name) throw new Error("Rule name is required"); + if (/[\]\r\n]/.test(name)) throw new Error("Rule name cannot contain ] or a line break"); + return name; + } + + function buildSieveVisualRule() { + const name = cleanSieveRuleName($("#sieve-builder-name").value); + const enabled = $("#sieve-builder-enabled").checked; + const primary = buildSieveCondition("", true); + const secondary = buildSieveCondition("-2", false); + const tertiary = buildSieveCondition("-3", false); + let test = primary.test; + let summary = primary.summary; + if (secondary) { + const logic = $("#sieve-builder-logic").value === "anyof" ? "anyof" : "allof"; + test = `${logic} (${test}, ${secondary.test})`; + summary = `${summary} ${logic === "anyof" ? "OR" : "AND"} ${secondary.summary}`; + } + if (tertiary) { + const logic = $("#sieve-builder-logic-3").value === "anyof" ? "anyof" : "allof"; + test = `${logic} (${test}, ${tertiary.test})`; + summary = `${summary} ${logic === "anyof" ? "OR" : "AND"} ${tertiary.summary}`; + } + for (const row of $$("#sieve-builder-extra-conditions .sieve-dynamic-condition")) { + const extra = buildSieveDynamicCondition(row); + test = `${extra.logic} (${test}, ${extra.test})`; + summary = `${summary} ${extra.logic === "anyof" ? "OR" : "AND"} ${extra.summary}`; + } + + const actions = [buildSieveAction("", true), buildSieveAction("-2", false), ...$$("#sieve-builder-extra-actions .sieve-dynamic-action-row").map(buildSieveDynamicAction)].filter(Boolean); + const commands = actions.map((item) => item.command); + const stop = $("#sieve-builder-stop").checked; + if (stop) commands.push("stop;"); + const requirements = new Set(); + for (const item of actions) for (const requirement of item.requirements) requirements.add(requirement); + const conditionCode = enabled ? test : `false # ${test}`; + return { + name, enabled, stop, requirements:[...requirements], conditionSummary:summary, + actionSummary:actions.map((item) => item.summary).join("; ") + (stop ? "; stop processing more rules" : ""), + rule:`# rule:[${name}]\nif ${conditionCode}\n{\n\t${commands.join("\n\t")}\n}`, + }; + } + + function configureSieveActionFields(suffix = "") { + const action = $(`#sieve-builder-action${suffix}`).value; + const wrap = $(`#sieve-builder-target-wrap${suffix}`); + const label = $(`#sieve-builder-target-label${suffix}`); + const target = $(`#sieve-builder-target${suffix}`); + const needsTarget = ["move", "copy", "redirect", "redirect-copy", "add-flag", "set-flags"].includes(action); + wrap?.classList.toggle("hidden", !needsTarget); + if (!target || !label) return; + label.textContent = ["redirect", "redirect-copy"].includes(action) ? "Address" : ["add-flag", "set-flags"].includes(action) ? "IMAP flag(s)" : "Folder"; + target.placeholder = ["redirect", "redirect-copy"].includes(action) ? "user@example.com" : action === "set-flags" ? "\\Seen, \\Flagged" : action === "add-flag" ? "$Label1" : "Archive"; + target.removeAttribute("list"); + if (["move", "copy"].includes(action)) target.setAttribute("list", "sieve-folder-options"); + } + + function applySieveCapabilityMatrix(extensions) { + const supports = (name) => extensions.has(name.toLowerCase()); + const actionAllowed = (action) => { + if (["move", "copy"].includes(action) && !supports("fileinto")) return false; + if (["copy", "redirect-copy"].includes(action) && !supports("copy")) return false; + if (["mark-read", "add-flag", "set-flags"].includes(action) && !supports("imap4flags")) return false; + return true; + }; + for (const select of [$("#sieve-builder-action"), $("#sieve-builder-action-2"), ...$$("#sieve-builder-extra-actions .sieve-dynamic-action")]) { + if (!select) continue; + for (const option of select.options) option.disabled = Boolean(option.value) && !actionAllowed(option.value); + if (select.selectedOptions[0]?.disabled) select.value = select.id === "sieve-builder-action-2" ? "" : "keep"; + } + $("#sieve-vacation-append").disabled = Boolean(state.sieveInfo) && !supports("vacation"); + } + + function renderSieveBuilderPreview() { + const preview = $("#sieve-builder-preview-text"); + const code = $("#sieve-builder-preview-code"); + const statePill = $("#sieve-builder-preview-state"); + if (!preview || !code || !statePill) return; + try { + const built = buildSieveVisualRule(); + preview.innerHTML = `
    When: ${escapeHtml(built.conditionSummary)}
    Then: ${escapeHtml(built.actionSummary || "No action")}
    `; + code.textContent = built.rule; + statePill.textContent = built.enabled ? "Enabled" : "Disabled"; + statePill.className = `sieve-status-pill ${built.enabled ? "ok" : ""}`; + } catch (error) { + preview.textContent = error.message; + code.textContent = ""; + statePill.textContent = "Incomplete"; + statePill.className = "sieve-status-pill error"; + } + } + + function updateSieveBuilder() { + const condition = $("#sieve-builder-condition").value; + $("#sieve-builder-header-wrap").classList.toggle("hidden", condition !== "header"); + + for (const suffix of ["-2", "-3"]) { + const nextCondition = $(`#sieve-builder-condition${suffix}`).value; + const enabled = Boolean(nextCondition); + $(`#sieve-builder-header-wrap${suffix}`).classList.toggle("hidden", nextCondition !== "header"); + $(`#sieve-builder-match${suffix}`).disabled = !enabled; + $(`#sieve-builder-value${suffix}`).disabled = !enabled; + $(`#sieve-builder-negate${suffix}`).disabled = !enabled; + const customHeader = $(`#sieve-builder-header${suffix}`); + if (customHeader) customHeader.disabled = !enabled || nextCondition !== "header"; + } + $("#sieve-builder-logic").disabled = !Boolean($("#sieve-builder-condition-2").value); + $("#sieve-builder-logic-3").disabled = !Boolean($("#sieve-builder-condition-3").value); + + configureSieveActionFields(""); + configureSieveActionFields("-2"); + $$("#sieve-builder-extra-actions .sieve-dynamic-action-row").forEach(configureSieveDynamicAction); + $$("#sieve-builder-extra-conditions .sieve-dynamic-condition").forEach((row) => { + row.querySelector(".sieve-dynamic-header-wrap").classList.toggle("hidden", row.querySelector(".sieve-dynamic-kind").value !== "header"); + }); + + const list = $("#sieve-folder-options"); + list.replaceChildren(...state.folders.map((folder) => { + const option = document.createElement("option"); + option.value = folder.name; + return option; + })); + + const extensions = new Set((state.sieveInfo?.extensions || []).map((value) => value.toLowerCase())); + applySieveCapabilityMatrix(extensions); + const actions = ["", "-2"].map((suffix) => $(`#sieve-builder-action${suffix}`)?.value).filter(Boolean) + .concat($$("#sieve-builder-extra-actions .sieve-dynamic-action").map((node) => node.value).filter(Boolean)); + const requirements = new Set(); + for (const actionName of actions) for (const requirement of sieveBuilderRequirements("", actionName)) requirements.add(requirement); + const missing = [...requirements].filter((requirement) => state.sieveInfo && !extensions.has(requirement.toLowerCase())); + $("#sieve-builder-capability").textContent = missing.length + ? `Server does not advertise: ${missing.join(", ")}` + : requirements.size ? `Requires: ${[...requirements].join(", ")}` : "No optional extension required"; + const vacationSupported = extensions.has("vacation"); + const vacationScheduled = Boolean($("#sieve-vacation-start")?.value || $("#sieve-vacation-end")?.value); + const scheduleSupported = extensions.has("date") && extensions.has("relational"); + $("#sieve-vacation-capability").textContent = state.sieveInfo + ? !vacationSupported + ? "Server does not advertise vacation" + : vacationScheduled && !scheduleSupported + ? "Scheduled dates require date + relational" + : vacationScheduled + ? "Server supports vacation scheduling" + : "Server supports vacation" + : ""; + $("#sieve-builder-replace").disabled = !state.sieveEditingRuleId; + renderSieveBuilderPreview(); + } + + + function buildSieveVacationRule() { + const subject = $("#sieve-vacation-subject").value.trim() || "Out of office"; + const message = $("#sieve-vacation-message").value.trim(); + const days = Math.max(1, Math.min(30, Number.parseInt($("#sieve-vacation-days").value, 10) || 7)); + const start = $("#sieve-vacation-start").value.trim(); + const end = $("#sieve-vacation-end").value.trim(); + if (!message) throw new Error("Autoresponder message is required"); + if (start && !/^\d{4}-\d{2}-\d{2}$/.test(start)) throw new Error("Vacation start date is invalid"); + if (end && !/^\d{4}-\d{2}-\d{2}$/.test(end)) throw new Error("Vacation end date is invalid"); + if (start && end && end < start) throw new Error("Vacation end date must not be before the start date"); + + const extensions = new Set((state.sieveInfo?.extensions || []).map((value) => value.toLowerCase())); + const requirements = new Set(["vacation"]); + const tests = []; + if (start || end) { + if (state.sieveInfo && (!extensions.has("date") || !extensions.has("relational"))) { + throw new Error("This server does not advertise the Sieve date + relational extensions required for scheduled vacation"); + } + requirements.add("date"); + requirements.add("relational"); + if (start) tests.push(`currentdate :value "ge" "date" "${sieveQuote(start)}"`); + if (end) tests.push(`currentdate :value "le" "date" "${sieveQuote(end)}"`); + } + if ($("#sieve-vacation-skip-lists").checked) tests.push('not exists ["list-id"]'); + if ($("#sieve-vacation-skip-auto").checked) tests.push('not header :matches "auto-submitted" ["auto-replied", "auto-generated"]'); + const command = `vacation :days ${days} :subject "${sieveQuote(subject)}" "${sieveQuote(message)}";`; + const rule = tests.length + ? `# RustBox autoresponder\nif allof (${tests.join(", ")}) {\n ${command}\n}` + : `# RustBox autoresponder\n${command}`; + return { requirements: [...requirements], rule }; + } + + async function appendSieveVacation() { + try { + const built = buildSieveVacationRule(); + const editor = $("#sieve-editor"); + editor.value = mergeSieveRequirements([editor.value.trim(), built.rule].filter(Boolean).join("\n\n"), built.requirements); + $("#sieve-result").textContent = "Autoresponder appended"; + await validateSieve(); + } catch (error) { toast(error.message, "error"); } + } + + async function applySieveVisualRule(updateSelected) { + try { + const built = buildSieveVisualRule(); + const editor = $("#sieve-editor"); + let body = editor.value; + if (updateSelected) { + if (!state.sieveEditingRuleId) throw new Error("Select a parsed rule to update first"); + const selected = parseSieveRules(body).find((rule) => rule.id === state.sieveEditingRuleId); + if (!selected) throw new Error("The selected rule no longer exists in the source"); + body = `${body.slice(0, selected.start)}${built.rule}${body.slice(selected.end)}`; + } else { + body = [body.trim(), built.rule].filter(Boolean).join("\n\n"); + } + editor.value = mergeSieveRequirements(body, built.requirements); + const parsed = parseSieveRules(editor.value); + const matching = parsed.find((rule) => rule.name === built.name); + state.sieveEditingRuleId = matching?.id || null; + renderManagedSieveRules(); + updateSieveBuilder(); + $("#sieve-result").textContent = updateSelected ? `Rule "${built.name}" updated - save to apply` : `Rule "${built.name}" added - save to apply`; + await validateSieve(); + if ($("#sieve-rule-dialog")?.open) $("#sieve-rule-dialog").close(); + } catch (error) { + toast(error.message, "error"); + } + } + + function renderSieveScripts() { + const list = $("#sieve-scripts"); + list.replaceChildren(); + if (!state.sieveScripts.length) list.append(el("div", "empty-list", "No scripts")); + for (const script of state.sieveScripts) { + const button = el("button", `sieve-script ${script.active ? "active" : ""}`); + button.type = "button"; + button.innerHTML = `${escapeHtml(script.name)}${script.active ? "ACTIVE" : ""}`; + button.addEventListener("click", () => openSieveScript(script.name)); + list.append(button); + } + } + + async function openSieveScript(name) { + try { + const data = await request(`/sieve/scripts/${encodeURIComponent(name)}`, { method: "GET" }); + $("#sieve-name").value = data.name; + $("#sieve-editor").value = data.content; + state.sieveEditingRuleId = null; + clearSieveRuleBuilder(); + renderManagedSieveRules(); + $("#sieve-result").textContent = "Loaded"; + } catch (error) { toast(error.message, "error"); } + } + + function newSieveScript() { + $("#sieve-name").value = "rustbox"; + $("#sieve-editor").value = `require ["fileinto"];\n`; + state.sieveEditingRuleId = null; + clearSieveRuleBuilder(); + renderManagedSieveRules(); + $("#sieve-result").textContent = "New script"; + $("#sieve-name").focus(); + } + + async function validateSieve() { + try { + const data = await request("/sieve/check", { method: "POST", body: JSON.stringify({ content: $("#sieve-editor").value }) }); + $("#sieve-result").textContent = data.valid ? (data.warning || "Valid script") : (data.error || "Invalid script"); + return data.valid; + } catch (error) { + $("#sieve-result").textContent = error.message; + toast(error.message, "error"); + return false; + } + } + + async function backupSieveScripts() { + try { + if (!state.sieveScripts.length) await loadSieve(); + if (!state.sieveScripts.length) return toast("No Sieve scripts to back up", "info"); + const scripts = []; + for (const script of state.sieveScripts) { + const data = await request(`/sieve/scripts/${encodeURIComponent(script.name)}`, { method:"GET" }); + scripts.push({ name:script.name, active:Boolean(script.active), content:data.content || "" }); + } + const payload = { + format: "rustbox-sieve-backup", + version: 1, + exported_at: new Date().toISOString(), + scripts, + }; + const blob = new Blob([JSON.stringify(payload, null, 2)], { type:"application/json;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = `rustbox-sieve-${new Date().toISOString().slice(0, 10)}.json`; + document.body.append(link); + link.click(); + link.remove(); + setTimeout(() => URL.revokeObjectURL(url), 1000); + toast(`Backed up ${scripts.length} Sieve script${scripts.length === 1 ? "" : "s"}`, "success"); + } catch (error) { toast(error.message, "error"); } + } + + async function restoreSieveBackup(file) { + if (!file) return; + try { + if (file.size > 2 * 1024 * 1024) throw new Error("Sieve backup is too large"); + const payload = JSON.parse(await file.text()); + if (payload?.format !== "rustbox-sieve-backup" || payload?.version !== 1 || !Array.isArray(payload.scripts)) { + throw new Error("This is not a supported RustBox Sieve backup"); + } + const scripts = payload.scripts.slice(0, 100).map((script) => ({ + name: String(script?.name || "").trim(), + active: Boolean(script?.active), + content: String(script?.content || ""), + })); + if (!scripts.length || scripts.some((script) => !script.name || script.name.length > 128 || /[\r\n]/.test(script.name))) { + throw new Error("Backup contains an invalid script name"); + } + if (payload.scripts.length > 100) throw new Error("Backup contains too many scripts"); + const activeCount = scripts.filter((script) => script.active).length; + if (activeCount > 1) throw new Error("Backup marks more than one Sieve script as active"); + const existing = new Set(state.sieveScripts.map((script) => script.name)); + const overwriteCount = scripts.filter((script) => existing.has(script.name)).length; + const confirmed = await appConfirm( + `Restore ${scripts.length} Sieve script${scripts.length === 1 ? "" : "s"}${overwriteCount ? ` and overwrite ${overwriteCount} existing script${overwriteCount === 1 ? "" : "s"}` : ""}? Existing scripts not present in the backup will be kept.`, + { title:"Restore Sieve backup", confirmLabel:"Restore", danger:overwriteCount > 0 } + ); + if (!confirmed) return; + + // Validate the complete backup against the current server before writing any script. + for (const script of scripts) { + const result = await request("/sieve/check", { method:"POST", body:JSON.stringify({ content:script.content }) }); + if (!result.valid) throw new Error(`Script “${script.name}” is not valid on this server: ${result.error || "validation failed"}`); + } + for (const script of scripts) { + await request(`/sieve/scripts/${encodeURIComponent(script.name)}`, { method:"PUT", body:JSON.stringify({ content:script.content }) }); + } + const active = scripts.find((script) => script.active); + if (active) await request(`/sieve/scripts/${encodeURIComponent(active.name)}/activate`, { method:"POST", body:"{}" }); + await loadSieve(); + toast(`Restored ${scripts.length} Sieve script${scripts.length === 1 ? "" : "s"}`, "success"); + } catch (error) { toast(error.message, "error"); } + finally { $("#sieve-restore-file").value = ""; } + } + + async function saveSieve() { + const name = $("#sieve-name").value.trim(); + if (!name) return toast("Script name is required", "error"); + const existing = state.sieveScripts.find((script) => script.name === name); + if (existing?.active) { + const confirmed = await appConfirm(`“${name}” is the active Sieve script. Validate and overwrite it?`, { title:"Overwrite active filter script", confirmLabel:"Overwrite", danger:true }); + if (!confirmed) return; + } + if (!await validateSieve()) return; + try { + const data = await request(`/sieve/scripts/${encodeURIComponent(name)}`, { method: "PUT", body: JSON.stringify({ content: $("#sieve-editor").value }) }); + $("#sieve-result").textContent = data.warning || "Saved"; + await loadSieve(); + toast(`Sieve script “${name}” saved`, "success"); + } catch (error) { toast(error.message, "error"); } + } + + async function activateSieve() { + const name = $("#sieve-name").value.trim(); + if (!name) return toast("Select a script first", "error"); + try { + await request(`/sieve/scripts/${encodeURIComponent(name)}/activate`, { method: "POST", body: "{}" }); + await loadSieve(); + toast(`Sieve script “${name}” activated`, "success"); + } catch (error) { toast(error.message, "error"); } + } + + async function deactivateSieve() { + try { + await request("/sieve/deactivate", { method: "POST", body: "{}" }); + await loadSieve(); + toast("Sieve script deactivated", "success"); + } catch (error) { toast(error.message, "error"); } + } + + async function deleteSieve() { + const name = $("#sieve-name").value.trim(); + if (!name || !await appConfirm(`Delete Sieve script “${name}”?`, { title:"Delete Sieve script", confirmLabel:"Delete", danger:true })) return; + try { + await request(`/sieve/scripts/${encodeURIComponent(name)}`, { method: "DELETE" }); + $("#sieve-name").value = ""; + $("#sieve-editor").value = ""; + await loadSieve(); + toast(`Sieve script “${name}” deleted`, "success"); + } catch (error) { toast(error.message, "error"); } + } + + function bindEditor() { + $$(".editor-toolbar [data-command]").forEach((button) => button.addEventListener("click", () => { + $("#compose-editor").focus(); + document.execCommand(button.dataset.command, false, button.dataset.commandValue || null); + })); + $("#editor-link").addEventListener("click", async () => { + const url = await appPrompt("Insert link", { value:"https://", type:"url", confirmLabel:"Insert link", required:true }); + if (!url) return; + $("#compose-editor").focus(); + document.execCommand("createLink", false, url); + }); + $("#editor-font").addEventListener("change", (event) => { + if (!event.target.value) return; + $("#compose-editor").focus(); + document.execCommand("fontName", false, event.target.value); + scheduleDraftSave(); + }); + $("#editor-size").addEventListener("change", (event) => { + if (!event.target.value) return; + $("#compose-editor").focus(); + document.execCommand("fontSize", false, event.target.value); + scheduleDraftSave(); + }); + $("#editor-color").addEventListener("input", (event) => { + $("#compose-editor").focus(); + document.execCommand("foreColor", false, event.target.value); + scheduleDraftSave(); + }); + } + + function bindPaneResizer(selector, preferenceKey, minPx, maxPx) { + const handle = $(selector); + if (!handle) return; + handle.addEventListener("pointerdown", (event) => { + if (innerWidth <= 820 || event.button !== 0) return; + event.preventDefault(); + const scaleFactor = Number(getComputedStyle(root).getPropertyValue("--ui-scale-factor")) || 1; + const startX = event.clientX; + const startRendered = Number(state.prefs[preferenceKey] || minPx) * scaleFactor; + handle.setPointerCapture?.(event.pointerId); + document.body.classList.add("resizing-pane"); + const move = (moveEvent) => { + const rendered = Math.max(minPx * scaleFactor, Math.min(maxPx * scaleFactor, startRendered + (moveEvent.clientX - startX))); + const base = Math.round(rendered / scaleFactor); + state.prefs[preferenceKey] = base; + const cssName = preferenceKey === "folderPaneWidth" ? "--folder-pane-width" : "--message-pane-width"; + root.style.setProperty(cssName, `${Math.round(rendered)}px`); + handle.setAttribute("aria-valuenow", String(base)); + }; + const finish = () => { + handle.removeEventListener("pointermove", move); + handle.removeEventListener("pointerup", finish); + handle.removeEventListener("pointercancel", finish); + document.body.classList.remove("resizing-pane"); + savePreference(preferenceKey, state.prefs[preferenceKey]); + }; + handle.addEventListener("pointermove", move); + handle.addEventListener("pointerup", finish); + handle.addEventListener("pointercancel", finish); + }); + } + + function bindEvents() { + bindPaneResizer("#folder-resizer", "folderPaneWidth", 170, 420); + bindPaneResizer("#message-resizer", "messagePaneWidth", 300, 760); + bindRecipientAutocomplete("#compose-to"); + bindRecipientAutocomplete("#compose-cc"); + bindRecipientAutocomplete("#compose-bcc"); + $("#login-form").addEventListener("submit", login); + $("#logout-btn").addEventListener("click", logout); + $("#compose-btn").addEventListener("click", () => openCompose("new")); + $("#compose-fullscreen").addEventListener("click", toggleComposeFullscreen); + $("#compose-form").addEventListener("submit", sendMessage); + $("#compose-attach").addEventListener("click", () => $("#compose-files").click()); + $("#compose-files").addEventListener("change", async (event) => { await uploadComposeFiles(event.target.files); event.target.value = ""; }); + $("#editor-image").addEventListener("click", () => { state.composeInsertRange = captureEditorRange(); $("#compose-images").click(); }); + $("#editor-image-edit").addEventListener("click", () => openInlineImageEditor()); + $("#compose-images").addEventListener("change", async (event) => { await uploadInlineImage(event.target.files, state.composeInsertRange); state.composeInsertRange = null; event.target.value = ""; }); + $("#image-edit-form").addEventListener("submit", applyInlineImageEditor); + $("#image-edit-reset").addEventListener("click", resetImageCropControls); + ["#image-crop-top", "#image-crop-right", "#image-crop-bottom", "#image-crop-left"].forEach((selector) => $(selector).addEventListener("input", drawInlineImageCropPreview)); + $("#image-edit-width").addEventListener("input", updateImageEditOutputs); + $("#image-edit-dialog").addEventListener("close", () => { state.imageEditorBitmap?.close?.(); state.imageEditorBitmap = null; state.imageEditorBlob = null; }); + $("#compose-recover").addEventListener("click", recoverDraft); + $("#compose-mode").addEventListener("change", async (event) => { + const editor = $("#compose-editor"); + const previous = editor.dataset.mode || "html"; + const next = event.target.value; + const richContent = previous === "html" && next !== "html" && Boolean(editor.querySelector("img,a,strong,b,em,i,u,s,ul,ol,li,blockquote,[style]")); + if (richContent && !await appConfirm("Switching away from HTML can hide or discard formatting when the message is sent. Continue?", { title:"Change compose format", confirmLabel:"Switch format" })) { + event.target.value = previous; + return; + } + setComposeMode(next, true); + insertIdentitySignature(editor); + scheduleDraftSave(); + }); + $("#compose-from").addEventListener("change", () => { insertIdentitySignature($("#compose-editor")); updateComposePgpUi(true); }); + ["#compose-to","#compose-cc","#compose-bcc"].forEach((selector) => $(selector).addEventListener("input", () => { + state.pgpRecipientStatus = []; + updateComposePgpUi(); + scheduleDraftSave(); + })); + $("#compose-subject").addEventListener("input", scheduleDraftSave); + ["#compose-priority", "#compose-mdn", "#compose-pgp-mode", "#compose-pgp-inline", "#compose-pgp-attach-key", "#compose-pgp-attach-recipient-keys"].forEach((selector) => $(selector).addEventListener("change", async () => { + if (selector === "#compose-pgp-attach-recipient-keys" && $(selector).checked) await checkComposePgpRecipients(false, composePgpVisibleRecipients()); + updateComposePgpUi(); + scheduleDraftSave(); + })); + $("#compose-editor").addEventListener("input", () => { if (state.composeSelectedImage && !state.composeSelectedImage.isConnected) setSelectedInlineImage(null); scheduleDraftSave(); scheduleUnusedInlineAttachmentCleanup(); }); + $("#compose-editor").addEventListener("click", (event) => { + const image = event.target.closest?.("img[data-rustbox-cid]"); + setSelectedInlineImage(image || null); + }); + $("#compose-editor").addEventListener("dblclick", (event) => { + const image = event.target.closest?.("img[data-rustbox-cid]"); + if (image) { event.preventDefault(); openInlineImageEditor(image); } + }); + $("#compose-editor").addEventListener("paste", async (event) => { + const images = [...(event.clipboardData?.items || [])] + .filter((item) => item.kind === "file" && /^image\/(png|jpeg|gif|webp)$/i.test(item.type)) + .map((item) => item.getAsFile()) + .filter(Boolean); + if (!images.length) return; + event.preventDefault(); + if ($("#compose-mode").value !== "html") return toast("Switch to HTML mode to paste inline images", "warning"); + const range = captureEditorRange(); + await uploadInlineImage(images, range); + }); + $("#compose-editor").addEventListener("dragover", (event) => { if ([...event.dataTransfer.types].includes("Files")) event.preventDefault(); }); + $("#compose-editor").addEventListener("drop", async (event) => { + const files = [...event.dataTransfer.files]; + if (!files.length) return; + event.preventDefault(); + const allImages = files.every((file) => /^image\/(png|jpeg|gif|webp)$/i.test(file.type)); + if ($("#compose-mode").value === "html" && allImages) await uploadInlineImage(files, editorRangeFromPoint(event.clientX, event.clientY)); + else await uploadComposeFiles(files); + }); + $("#reply-btn").addEventListener("click", () => openCompose("reply")); + $("#reply-all-btn").addEventListener("click", () => openCompose("reply-all")); + $("#forward-btn").addEventListener("click", () => openCompose("forward")); + $("#delete-btn").addEventListener("click", deleteCurrent); + $("#unread-btn").addEventListener("click", toggleUnread); + $("#flag-btn").addEventListener("click", toggleFlagged); + $("#archive-btn").addEventListener("click", archiveCurrent); + $("#spam-btn").addEventListener("click", junkCurrent); + $("#move-select").addEventListener("change", async (event) => { const target = event.target.value; event.target.value = ""; if (target) await moveCurrentTo(target); }); + $("#copy-select").addEventListener("change", async (event) => { const target = event.target.value; event.target.value = ""; if (target) await copyCurrentTo(target); }); + $("#bulk-select-all").addEventListener("change", (event) => { const source = state.searchAllResults || state.messages; for (const message of source) { const key = messageKey(message); event.target.checked ? state.selectedMessages.add(key) : state.selectedMessages.delete(key); } renderMessages(); }); + $("#bulk-read").addEventListener("click", () => performBulk("read")); + $("#bulk-unread").addEventListener("click", () => performBulk("unread")); + $("#bulk-flag").addEventListener("click", () => performBulk("flag")); + $("#bulk-unflag").addEventListener("click", () => performBulk("unflag")); + $("#bulk-archive").addEventListener("click", () => performBulk("archive")); + $("#bulk-select-search").addEventListener("click", selectAllSearchResults); + $("#bulk-junk").addEventListener("click", () => performBulk("junk")); + $("#bulk-delete").addEventListener("click", () => performBulk("delete")); + $("#bulk-move-select").addEventListener("change", async (event) => { const target=event.target.value; event.target.value=""; if (target) await performBulk("move", target); }); + $("#bulk-copy-select").addEventListener("change", async (event) => { const target=event.target.value; event.target.value=""; if (target) await performBulk("copy", target); }); + $("#bulk-tag-select").addEventListener("change", async (event) => { const tagId=event.target.value; event.target.value=""; if (tagId) await bulkAddTag(tagId); }); + $("#select-mode-btn").addEventListener("click", () => setSelectionMode(!state.selectionMode)); + $("#selection-done").addEventListener("click", () => setSelectionMode(false)); + $("#refresh-btn").addEventListener("click", () => loadFolders(false)); + $("#message-sort").addEventListener("change", async (event) => { + const value = event.target.value; + state.mailPrefs.default_sort = value; + $("#mail-pref-sort").value = value; + try { + state.mailPrefs = await request("/settings/mailbox", { method:"PUT", body:JSON.stringify({ default_sort:value }) }); + state.page = 0; + await loadMessages(true); + toast("Message sort updated", "success"); + } catch (error) { toast(error.message, "error"); } + }); + $("#load-more").addEventListener("click", async () => { state.page += 1; await loadMessages(false); }); + $("#message-list").addEventListener("scroll", scheduleMessageListStateSave, { passive:true }); + window.addEventListener("offline", () => setConnectivityState(false)); + window.addEventListener("online", async () => { + setConnectivityState(true); + disconnectRealtime(); + connectRealtime(); + if (state.accounts.length) { + await refreshFolderListOnly(); + if (state.searchContext) await runSearch(new URLSearchParams(state.searchContext.params), state.searchContext.label, { restoreScrollTop:state.searchContext.scrollTop }); + else await loadMessages(true); + } + }); + $("#search-form").addEventListener("submit", searchMail); + $("#advanced-search-btn").addEventListener("click", openAdvancedSearch); + $("#advanced-search-form").addEventListener("submit", submitAdvancedSearch); + $("#search-reset-btn").addEventListener("click", resetAdvancedSearch); + $("#search-save-btn").addEventListener("click", saveAdvancedSearch); + $("#contacts-btn").addEventListener("click", openContacts); + $("#contact-group-filter").addEventListener("change", async () => { renderContactGroupFilter(); await loadContacts($("#contact-search").value.trim()); }); + $("#contact-group-new").addEventListener("click", createContactGroup); + $("#contact-group-rename").addEventListener("click", renameContactGroup); + $("#contact-group-delete").addEventListener("click", deleteContactGroup); + $("#tag-add-btn").addEventListener("click", () => openTagDialog()); + $("#tag-form").addEventListener("submit", saveTag); + $("#tag-delete").addEventListener("click", deleteTag); + $("#calendar-btn").addEventListener("click", openCalendar); + $("#settings-btn").addEventListener("click", () => openSettings("appearance")); + $("#shortcuts-open").addEventListener("click", () => $("#shortcuts-dialog").showModal()); + $("#account-add").addEventListener("click", () => { $("#add-account-error").classList.add("hidden"); $("#add-account-dialog").showModal(); setTimeout(() => $("#add-account-email").focus(), 20); }); + $("#account-revoke-others").addEventListener("click", revokeOtherSessions); + $("#add-account-form").addEventListener("submit", addBrowserAccount); + $("#mobile-folders").addEventListener("click", () => $("#folder-pane").classList.toggle("open")); + $("#reader-back").addEventListener("click", closeReader); + $("#reader-prev").addEventListener("click", () => navigateMessage(-1)); + $("#reader-next").addEventListener("click", () => navigateMessage(1)); + $("#thread-toggle").addEventListener("click", () => { + state.threadExpanded = !state.threadExpanded; + localStorage.setItem("rustbox.threadExpanded", String(state.threadExpanded)); + if (state.currentDetail) renderThread(state.currentDetail); + }); + $("#folder-manage-btn").addEventListener("click", openFolderDialog); + $("#create-folder-btn").addEventListener("click", createFolder); + $("#rename-folder-btn").addEventListener("click", renameFolder); + $("#delete-folder-btn").addEventListener("click", deleteFolder); + $("#subscribe-folder-btn").addEventListener("click", toggleCurrentFolderSubscription); + $("#headers-btn").addEventListener("click", showHeaders); + $("#source-btn").addEventListener("click", showMessageSource); + $("#quick-filter-btn").addEventListener("click", () => createFilterFromCurrentMessage("sender")); + $("#reader-from").addEventListener("click", (event) => { const rect = event.currentTarget.getBoundingClientRect(); openCurrentSenderMenu(rect.left, rect.bottom + 4); }); + $("#reader-from").addEventListener("keydown", (event) => { if (["Enter", " "].includes(event.key)) { event.preventDefault(); const rect = event.currentTarget.getBoundingClientRect(); openCurrentSenderMenu(rect.left, rect.bottom + 4); } }); + $("#print-btn").addEventListener("click", printCurrentMessage); + $("#pgp-reader-process").addEventListener("click", processCurrentPgpMessage); + $("#pgp-reader-discover").addEventListener("click", discoverCurrentPgpSigner); + $("#copy-headers-btn").addEventListener("click", copyHeaders); + $$(".close-dialog").forEach((button) => button.addEventListener("click", async () => { + const dialog = button.closest("dialog"); + if (dialog?.id === "compose-dialog") await closeComposer(); + else dialog?.close(); + })); + $("#compose-dialog").addEventListener("cancel", async (event) => { event.preventDefault(); await closeComposer(); }); + window.addEventListener("beforeunload", () => { if (state.composeDirty && draftHasContent()) storeLocalDraftSnapshot(); }); + $("#compose-pgp-check").addEventListener("click", () => checkComposePgpRecipients(true)); + $("#compose-pgp-discover").addEventListener("click", discoverComposePgpRecipients); + ["#compose-to", "#compose-cc", "#compose-bcc"].forEach((selector) => $(selector).addEventListener("change", () => { state.pgpRecipientStatus = []; updateComposePgpUi(); })); + $("#pgp-refresh").addEventListener("click", loadOpenPgp); + $("#pgp-pref-save").addEventListener("click", savePgpPreferences); + $("#pgp-generate").addEventListener("click", generatePgpKey); + $("#pgp-import").addEventListener("click", () => importPgpText()); + $("#pgp-import-file").addEventListener("click", () => $("#pgp-import-file-input").click()); + $("#pgp-import-file-input").addEventListener("change", async (event) => { const file = event.target.files?.[0]; if (file) await importPgpFile(file); event.target.value=""; }); + $("#pgp-discover").addEventListener("click", () => discoverPgpEmail()); + $$(".settings-tab").forEach((button) => button.addEventListener("click", () => { selectSettingsTab(button.dataset.tab); if (button.dataset.tab === "sieve") loadSieve(); if (button.dataset.tab === "identities") { loadIdentities(); loadOpenPgp(); } if (button.dataset.tab === "openpgp") loadOpenPgp(); if (button.dataset.tab === "notifications") loadNotificationPreferences(); if (button.dataset.tab === "mailbox") loadMailPreferences(); if (button.dataset.tab === "diagnostics") loadDiagnostics(); if (button.dataset.tab === "account") { renderAccountSettings(); loadActiveSessions(); } if (button.dataset.tab === "folders") { loadSpecialFolders(); loadQuota(); } })); + $("#pref-theme").addEventListener("change", (event) => savePreference("theme", event.target.value)); + $("#pref-density").addEventListener("change", (event) => savePreference("density", event.target.value)); + $("#pref-preview").addEventListener("change", (event) => savePreference("preview", event.target.value)); + $("#pref-scale").addEventListener("change", (event) => savePreference("scale", event.target.value)); + $("#pref-language").addEventListener("change", (event) => savePreference("language", event.target.value)); + $("#pref-color-preset").addEventListener("change", (event) => savePreference("colorPreset", event.target.value)); + $("#pref-accent").addEventListener("input", (event) => { $("#pref-accent-value").textContent = event.target.value; savePreference("accentColor", event.target.value); }); + [["#pref-background","#pref-background-value","backgroundColor"],["#pref-panel","#pref-panel-value","panelColor"],["#pref-text-color","#pref-text-color-value","textColor"]].forEach(([input,output,key]) => $(input).addEventListener("input", (event) => { $(output).textContent = event.target.value; savePreference(key, event.target.value); })); + $("#pref-font-family").addEventListener("change", (event) => savePreference("fontFamily", event.target.value)); + [["#pref-font-size","fontSize"],["#pref-row-height","rowHeight"],["#pref-folder-width","folderPaneWidth"],["#pref-message-width","messagePaneWidth"],["#pref-radius","borderRadius"]].forEach(([selector,key]) => $(selector).addEventListener("input", (event) => savePreference(key, Number(event.target.value)))); + $("#pref-contrast").addEventListener("change", (event) => savePreference("contrast", event.target.value)); + $("#pref-date-format").addEventListener("change", (event) => { savePreference("dateFormat", event.target.value); renderMessages(); if (state.currentDetail) renderMessageDetail(state.currentDetail); }); + $("#pref-time-format").addEventListener("change", (event) => { savePreference("timeFormat", event.target.value); renderMessages(); if (state.currentDetail) renderMessageDetail(state.currentDetail); }); + $("#pref-avatars").addEventListener("change", (event) => savePreference("showAvatars", event.target.checked)); + $("#pref-attachment-icons").addEventListener("change", (event) => savePreference("showAttachmentIcons", event.target.checked)); + $("#pref-flags").addEventListener("change", (event) => savePreference("showFlags", event.target.checked)); + $("#pref-reduce-motion").addEventListener("change", (event) => savePreference("reduceMotion", event.target.checked)); + [["#pref-icon-size","iconSize"],["#pref-sidebar-height","sidebarItemHeight"],["#pref-composer-width","composerWidth"],["#pref-unread-weight","unreadFontWeight"]].forEach(([selector,key]) => $(selector).addEventListener("input", (event) => savePreference(key, Number(event.target.value)))); + $("#pref-reader-width").addEventListener("change", (event) => savePreference("readerMaxWidth", Number(event.target.value))); + $("#pref-toolbar-density").addEventListener("change", (event) => savePreference("toolbarDensity", event.target.value)); + $("#pref-selection-color").addEventListener("input", (event) => { $("#pref-selection-color-value").textContent = event.target.value; savePreference("selectionColor", event.target.value); }); + $("#pref-unread-badge").addEventListener("change", (event) => savePreference("unreadBadgeStyle", event.target.value)); + $("#pref-scrollbar-density").addEventListener("change", (event) => savePreference("scrollbarDensity", event.target.value)); + $("#pref-compact-headers").addEventListener("change", (event) => savePreference("compactHeaders", event.target.checked)); + $("#pref-show-borders").addEventListener("change", (event) => savePreference("showBorders", event.target.checked)); + $("#pref-monochrome").addEventListener("change", (event) => savePreference("monochrome", event.target.checked)); + [["folder","folderBackground","#101310"],["message","messageBackground","#181c19"],["reader","readerBackground","#181c19"]].forEach(([name,key,fallback]) => { + const enabled = $(`#pref-${name}-bg-enabled`); const color = $(`#pref-${name}-bg`); + enabled.addEventListener("change", () => savePreference(key, enabled.checked ? color.value : "")); + color.addEventListener("input", () => { if (enabled.checked) savePreference(key, color.value); }); + }); + $("#compose-pref-format").addEventListener("change", (event) => savePreference("composeFormat", event.target.value)); + $("#compose-pref-autosave").addEventListener("change", (event) => savePreference("composeAutosaveMs", Number(event.target.value))); + $("#compose-pref-priority").addEventListener("change", (event) => savePreference("defaultPriority", event.target.value)); + $("#compose-pref-mdn").addEventListener("change", (event) => savePreference("requestMdnDefault", event.target.checked)); + $("#compose-pref-attachment-reminder").addEventListener("change", (event) => savePreference("attachmentReminder", event.target.checked)); + $("#compose-pref-font").addEventListener("change", (event) => savePreference("composeFontFamily", event.target.value)); + $("#compose-pref-reply-position").addEventListener("change", (event) => savePreference("replyPosition", event.target.value)); + $("#compose-pref-quote-style").addEventListener("change", (event) => savePreference("quoteStyle", event.target.value)); + $("#compose-pref-forward-mode").addEventListener("change", (event) => savePreference("forwardMode", event.target.value)); + $("#compose-pref-spellcheck").addEventListener("change", (event) => savePreference("spellcheckEnabled", event.target.checked)); + $("#compose-markdown-preview").addEventListener("click", previewMarkdown); + $$(`[data-appearance-preset]`).forEach((button) => button.addEventListener("click", () => applyAppearancePreset(button.dataset.appearancePreset))); + $("#pref-reset").addEventListener("click", resetAppearance); + $("#special-folders-save").addEventListener("click", saveSpecialFolders); + $("#special-folders-auto").addEventListener("click", autoDetectSpecialFolders); + $("#identity-form").addEventListener("submit", saveIdentity); + $("#identity-new").addEventListener("click", () => editIdentity(null)); + $("#identity-delete").addEventListener("click", deleteIdentity); + $$("[data-signature-command]").forEach((button) => button.addEventListener("click", () => { + const editor = $("#identity-signature"); + editor.focus(); + document.execCommand(button.dataset.signatureCommand, false, null); + })); + $("#identity-signature-link").addEventListener("click", async () => { + const editor = $("#identity-signature"); + const range = captureEditorRange(editor); + const url = await appPrompt("Insert signature link", { placeholder:"https://example.com", type:"url", confirmLabel:"Insert" }); + if (!url) return; + editor.focus(); + restoreEditorRange(range, editor); + document.execCommand("createLink", false, url); + }); + $("#identity-signature-image-btn").addEventListener("click", () => { state.identitySignatureRange = captureEditorRange($("#identity-signature")); $("#identity-signature-image").click(); }); + $("#identity-signature-image").addEventListener("change", async (event) => { const file = event.target.files?.[0]; await insertIdentitySignatureImage(file); event.target.value = ""; }); + $("#identity-signature").addEventListener("paste", async (event) => { + const file = [...(event.clipboardData?.items || [])].find((item) => item.kind === "file" && /^image\/(png|jpeg|gif|webp)$/i.test(item.type))?.getAsFile(); + if (!file) return; + event.preventDefault(); + await insertIdentitySignatureImage(file, captureEditorRange($("#identity-signature"))); + }); + $("#contact-form").addEventListener("submit", saveContact); + $("#contact-new").addEventListener("click", () => editContact(null)); + $("#contact-import").addEventListener("click", () => $("#contact-import-file").click()); + $("#contact-import-file").addEventListener("change", (event) => importContactVcard(event.target.files)); + $("#contact-export").addEventListener("click", downloadContactExport); + $("#contact-duplicates").addEventListener("click", showContactDuplicates); + $("#contact-photo-upload").addEventListener("click", () => $("#contact-photo-file").click()); + $("#contact-photo-file").addEventListener("change", (event) => uploadContactPhoto(event.target.files)); + $("#contact-photo-delete").addEventListener("click", deleteContactPhoto); + $("#contact-delete").addEventListener("click", deleteContact); + $("#contact-compose").addEventListener("click", composeToContact); + $("#contact-search").addEventListener("input", (event) => { clearTimeout(event.target._timer); event.target._timer = setTimeout(() => loadContacts(event.target.value.trim()), 180); }); + $("#calendar-form").addEventListener("submit", saveCalendarEvent); + $("#calendar-import").addEventListener("click", () => $("#calendar-import-file").click()); + $("#calendar-import-file").addEventListener("change", (event) => importCalendarIcs(event.target.files)); + $("#calendar-export").addEventListener("click", exportCalendarIcs); + $("#calendar-recurrence").addEventListener("change", updateCalendarRecurrenceUi); + $("#calendar-new").addEventListener("click", () => editCalendarEvent(null)); + $("#calendar-cancel").addEventListener("click", () => $("#calendar-form").classList.add("hidden")); + $("#calendar-delete").addEventListener("click", deleteCalendarEvent); + $("#calendar-view").addEventListener("change", async (event) => { state.calendarView = event.target.value; localStorage.setItem("rustbox.calendarView", state.calendarView); await loadCalendar(); }); + $("#calendar-prev").addEventListener("click", async () => { shiftCalendarCursor(-1); await loadCalendar(); }); + $("#calendar-next").addEventListener("click", async () => { shiftCalendarCursor(1); await loadCalendar(); }); + $("#calendar-today").addEventListener("click", async () => { state.calendarCursor = new Date(); await loadCalendar(); }); + $("#notify-save").addEventListener("click", saveNotificationPreferences); + $("#notify-permission").addEventListener("click", requestNotificationPermission); + $("#mail-pref-save").addEventListener("click", saveMailPreferences); + $("#mail-pref-reconcile-labels")?.addEventListener("click", reconcileImapLabels); + $("#diagnostics-refresh").addEventListener("click", loadDiagnostics); + $("#diagnostic-imap-reconnect").addEventListener("click", reconnectImap); + $("#diagnostic-search-rebuild").addEventListener("click", rebuildSearchIndex); + $("#remote-image-load").addEventListener("click", () => { if (state.currentDetail) renderMessageBody(state.currentDetail, true); }); + const sieveBuilderFields = [ + "#sieve-builder-name", "#sieve-builder-enabled", "#sieve-builder-stop", + "#sieve-builder-condition", "#sieve-builder-match", "#sieve-builder-header", "#sieve-builder-value", "#sieve-builder-negate", + "#sieve-builder-condition-2", "#sieve-builder-match-2", "#sieve-builder-header-2", "#sieve-builder-value-2", "#sieve-builder-negate-2", "#sieve-builder-logic", + "#sieve-builder-condition-3", "#sieve-builder-match-3", "#sieve-builder-header-3", "#sieve-builder-value-3", "#sieve-builder-negate-3", "#sieve-builder-logic-3", + "#sieve-builder-action", "#sieve-builder-target", "#sieve-builder-action-2", "#sieve-builder-target-2", + ]; + sieveBuilderFields.forEach((selector) => { + const node = $(selector); + if (!node) return; + node.addEventListener("input", updateSieveBuilder); + node.addEventListener("change", updateSieveBuilder); + }); + $("#sieve-rule-new").addEventListener("click", openNewSieveRuleDialog); + $("#sieve-builder-add-condition").addEventListener("click", () => { $("#sieve-builder-extra-conditions").append(createSieveDynamicCondition()); updateSieveBuilder(); }); + $("#sieve-builder-add-action").addEventListener("click", () => { $("#sieve-builder-extra-actions").append(createSieveDynamicAction()); updateSieveBuilder(); }); + $("#sieve-vacation-start").addEventListener("change", updateSieveBuilder); + $("#sieve-vacation-end").addEventListener("change", updateSieveBuilder); + $("#sieve-builder-append").addEventListener("click", () => applySieveVisualRule(false)); + $("#sieve-builder-replace").addEventListener("click", () => applySieveVisualRule(true)); + $("#sieve-builder-clear").addEventListener("click", clearSieveRuleBuilder); + $("#sieve-editor").addEventListener("input", () => { + state.sieveEditingRuleId = null; + renderManagedSieveRules(); + updateSieveBuilder(); + }); + $("#sieve-vacation-append").addEventListener("click", appendSieveVacation); + $("#sieve-backup").addEventListener("click", backupSieveScripts); + $("#sieve-restore").addEventListener("click", () => $("#sieve-restore-file").click()); + $("#sieve-restore-file").addEventListener("change", (event) => restoreSieveBackup(event.target.files?.[0])); + $("#sieve-refresh").addEventListener("click", loadSieve); + $("#sieve-new").addEventListener("click", newSieveScript); + $("#sieve-check").addEventListener("click", validateSieve); + $("#sieve-save").addEventListener("click", saveSieve); + $("#sieve-activate").addEventListener("click", activateSieve); + $("#sieve-deactivate").addEventListener("click", deactivateSieve); + $("#sieve-delete").addEventListener("click", deleteSieve); + bindEditor(); + document.addEventListener("click", (event) => { if (!event.target.closest("#context-menu")) hideContextMenu(); }); + window.addEventListener("blur", hideContextMenu); + document.addEventListener("scroll", hideContextMenu, true); + document.addEventListener("keydown", (event) => { + const tag = document.activeElement?.tagName || ""; + if (event.key === "/" && !["INPUT", "TEXTAREA"].includes(tag) && !document.activeElement?.isContentEditable) { + event.preventDefault(); $("#search-input").focus(); + } + if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "n") { event.preventDefault(); openCompose("new"); } + if (event.key === "?" && !["INPUT", "TEXTAREA", "SELECT"].includes(tag) && !document.activeElement?.isContentEditable) { + event.preventDefault(); + if (!document.querySelector("dialog[open]")) $("#shortcuts-dialog").showModal(); + } + const dialogOpen = Boolean(document.querySelector("dialog[open]")); + const keyboardIdle = !dialogOpen && !["INPUT", "TEXTAREA", "SELECT"].includes(tag) && !document.activeElement?.isContentEditable; + if (keyboardIdle) { + const globalKey = event.key.toLowerCase(); + if (globalKey === "c") { event.preventDefault(); openCompose("new"); return; } + if (globalKey === "v") { event.preventDefault(); setSelectionMode(!state.selectionMode); return; } + } + if (keyboardIdle && state.currentDetail) { + const key = event.key.toLowerCase(); + if (key === "x") { + const current = state.messages.find((message) => message.uid === state.currentDetail.uid && (message.folder || state.folder) === state.currentDetail.folder); + if (current) { event.preventDefault(); setMessageSelected(current, !state.selectedMessages.has(messageKey(current))); } + } + else if (key === "j") { event.preventDefault(); navigateMessage(1); } + else if (key === "k") { event.preventDefault(); navigateMessage(-1); } + else if (key === "r") { event.preventDefault(); openCompose("reply"); } + else if (key === "a") { event.preventDefault(); openCompose("reply-all"); } + else if (key === "f") { event.preventDefault(); openCompose("forward"); } + else if (key === "e") { event.preventDefault(); archiveCurrent(); } + else if (key === "m") { event.preventDefault(); toggleUnread(); } + else if (key === "s") { event.preventDefault(); toggleFlagged(); } + else if (event.key === "!") { event.preventDefault(); junkCurrent(); } + else if (event.key === "Delete") { event.preventDefault(); deleteCurrent(); } + } + if (event.key === "Escape" && innerWidth <= 820) closeReader(); + }); + matchMedia("(prefers-color-scheme: light)").addEventListener("change", () => { if (state.prefs.theme === "system") applyPreferences(); }); + } + + async function boot() { + applyPreferences(); + bindEvents(); + await loadAuthPolicy(); + const authenticated = await loadAccounts(); + if (authenticated) { await loadPreferences(); await loadMailPreferences(); await loadOpenPgp(); await loadIdentities(); await loadFolders(true); await loadSpecialFolders(); await loadTags(); await loadNotificationPreferences(); connectRealtime(); } else { await loadTranslations(state.prefs.language); } + } + + boot(); +})(); diff --git a/ui/icons.svg b/ui/icons.svg new file mode 100644 index 0000000..5d8389b --- /dev/null +++ b/ui/icons.svg @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui/index.html b/ui/index.html new file mode 100644 index 0000000..337bc56 --- /dev/null +++ b/ui/index.html @@ -0,0 +1,698 @@ + + + + + + + + RustBox + + + + +
    + + RustBox +
    + + + +
    + +
    +
    + + RustBox +
    + +
    + + + + + +
    +
    + +
    + + +
    +
    +
    Inbox0 messages
    +
    + + + +
    +
    + +
    + + +
    + +
    +
    + + No message selectedSelect a message from the list. +
    + +
    +
    +
    + + +
    +
    New message
    +
    + + + + + +
    Options
    + +
    +
    + + + + + + + + + + + + + + + + + + + + + + + + +
    +
    + +
    +
    +
    + + +
    +
    Markdown preview
    +
    +
    +
    + + +
    +
    Edit inline image
    +
    +
    +
    + + +
    + Crop / cut + + + + +
    +

    Resizing changes only the displayed size. Cropping creates a new CID image and replaces the previous inline attachment.

    +
    +
    +
    +
    +
    + + +
    +
    Folders
    +
    +
    +
    +
    + + +
    +
    Settings
    +
    + +
    +
    +

    Appearance

    Personalize RustBox without changing mail behavior.

    +
    + + + + +
    +
    + + + + + + + + + + + + + + + + + + + +
    +
    + + + + +
    +
    + Advanced interface styling +
    + + + + + + + + + +
    +
    + + + +
    +
    + + + +
    +
    +
    + + + + + + + + + + +
    +
    +
    +
    + + + + +
    +
    Identity & signature
    +
    + +
    +
    Sender identityAddress and headers used when this identity is selected in the composer.
    +
    + + + + + +
    +
    +
    +
    Signature / footerMaintain separate text and HTML versions. Imported images are embedded into outgoing mail as inline CID images.
    +
    + + +
    +
    + + +
    +
    +
    +
    DefaultsChoose where this signature is inserted and, when OpenPGP is configured, its default behavior.
    +
    + + + + + + + +
    +
    +
    +
    +
    +
    + + +
    +
    Create filterBuild the rule visually and verify the preview before applying it.
    +
    +
    +
    +
    +
    RuleGive it a readable name and decide whether it is active.
    +
    + + + +
    +
    +
    +
    WhenChoose what the incoming message must match.
    +
    + + + + + + + + + + + + + + + + + +
    +
    +
    +
    +
    +
    ThenActions run in the order shown below.
    +
    + + + + +
    +
    +
    +
    +
    +
    What will happenLive explanation of the rule before you save it.
    Ready
    +
    Complete the rule to see its effect.
    +
    
    +                    
    + +
    + +
    +
    +
    +
    + + +
    +
    Keyboard shortcuts
    +
    +

    Global

    /
    Focus search
    C / Ctrl + N
    New message
    V
    Toggle selection mode
    ?
    Show shortcuts
    +

    Message

    J / K
    Next / previous message
    X
    Select / unselect current message
    R
    Reply
    A
    Reply all
    F
    Forward
    E
    Archive
    M
    Read / unread
    S
    Flag / unflag
    !
    Spam / not spam
    Delete
    Delete / move to Trash
    +
    +
    +
    +
    + + +
    +
    Label
    +
    + + + +
    +
    +
    +
    + + +
    +
    Advanced search
    +
    +
    + + + + + + + + + + + +
    +
    +
    Saved searches
    +
    +
    +
    + + + + + +
    +
    +
    +
    + + +
    +
    Contacts
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    +
    Photo
    + +
    + + + +
    +
    + + + + + + + + + + + + + +
    Groups
    +
    +
    +
    +
    +
    + + +
    +
    Duplicate contacts
    +

    Duplicates are detected when two contacts share a primary or alternate e-mail address. Choose which record to keep.

    +
    +
    +
    +
    + + +
    +
    Calendar
    +
    +
    + +
    +
    + +
    +
    Message headers
    +
    
    +      
    +
    +
    + + + + + + +
    + + +