From fff8521a17b58e035bac4aa605daf23e21d5aafe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Thu, 29 Oct 2026 19:28:47 +0100 Subject: [PATCH] v0.0.2 --- CHANGELOG.md | 16 + Cargo.lock | 2 +- Cargo.toml | 2 +- NOTICE.md | 2 +- README.md | 18 +- RELEASE-MANIFEST.txt | 9 +- build.rs | 116 +- config.example.toml | 10 +- docs/IMPLEMENTATION_STATUS.md | 19 +- docs/RELEASING.md | 5 + docs/ROADMAP_GAPS.md | 4 +- docs/ROUNDCUBE_SKIN_VALIDATION.md | 20 + docs/UI.md | 4 +- docs/releases/v0.0.2.md | 21 + migrations/V042__trusted_senders.sql | 4 + migrations/V043__response_templates.sql | 7 + migrations/V044__interface_skin.sql | 1 + migrations/V045__response_template_format.sql | 1 + scripts/build-release.sh | 5 + src/auth/session.rs | 51 +- src/auth/user_data.rs | 5 +- src/config.rs | 17 + src/db/display_preferences.rs | 382 +- src/db/mod.rs | 2 + src/db/pool.rs | 203 +- src/db/response_templates.rs | 150 + src/db/trusted_senders.rs | 46 + src/pgp/discovery.rs | 2 +- src/routes/calendar.rs | 2 +- src/routes/mod.rs | 18 + src/routes/response_templates.rs | 77 + src/routes/special_folders.rs | 50 +- src/routes/tests.rs | 6 +- src/routes/trusted_senders.rs | 62 + src/routes/unsubscribe.rs | 2 +- src/sieve/error.rs | 2 +- src/ui.rs | 24 +- src/web/app.js | 7050 ----------------- src/web/css/20-shell.css | 4 + src/web/css/30-mail.css | 98 + src/web/css/40-compose.css | 40 +- src/web/css/50-settings.css | 36 + src/web/css/55-roundcube-skins.css | 434 + src/web/css/56-interface-styles.css | 362 + src/web/css/60-responsive.css | 21 +- src/web/css/manifest.txt | 2 + src/web/index.html | 1605 +++- src/web/js/00-bootstrap.js | 153 + src/web/js/10-core.js | 333 + src/web/js/20-preferences-auth.js | 558 ++ src/web/js/30-folders-tags.js | 453 ++ src/web/js/40-messages-reader.js | 719 ++ src/web/js/50-compose.js | 1138 +++ src/web/js/60-mail-actions-search.js | 1145 +++ src/web/js/70-contacts.js | 294 + src/web/js/71-calendar.js | 269 + src/web/js/72-openpgp-identities.js | 557 ++ src/web/js/80-settings-realtime.js | 342 + src/web/js/90-sieve.js | 1025 +++ src/web/js/95-bindings-boot.js | 454 ++ src/web/js/manifest.txt | 13 + tests/roundcube_skin_contract.py | 120 + tests/theme_fixture.html | 12 + ui/app.css | 1373 ---- ui/app.js | 7048 ---------------- ui/icons.svg | 56 - ui/index.html | 698 -- 67 files changed, 11016 insertions(+), 16763 deletions(-) create mode 100644 docs/ROUNDCUBE_SKIN_VALIDATION.md create mode 100644 docs/releases/v0.0.2.md create mode 100644 migrations/V042__trusted_senders.sql create mode 100644 migrations/V043__response_templates.sql create mode 100644 migrations/V044__interface_skin.sql create mode 100644 migrations/V045__response_template_format.sql create mode 100644 src/db/response_templates.rs create mode 100644 src/db/trusted_senders.rs create mode 100644 src/routes/response_templates.rs create mode 100644 src/routes/trusted_senders.rs delete mode 100644 src/web/app.js create mode 100644 src/web/css/55-roundcube-skins.css create mode 100644 src/web/css/56-interface-styles.css create mode 100644 src/web/js/00-bootstrap.js create mode 100644 src/web/js/10-core.js create mode 100644 src/web/js/20-preferences-auth.js create mode 100644 src/web/js/30-folders-tags.js create mode 100644 src/web/js/40-messages-reader.js create mode 100644 src/web/js/50-compose.js create mode 100644 src/web/js/60-mail-actions-search.js create mode 100644 src/web/js/70-contacts.js create mode 100644 src/web/js/71-calendar.js create mode 100644 src/web/js/72-openpgp-identities.js create mode 100644 src/web/js/80-settings-realtime.js create mode 100644 src/web/js/90-sieve.js create mode 100644 src/web/js/95-bindings-boot.js create mode 100644 src/web/js/manifest.txt create mode 100644 tests/roundcube_skin_contract.py create mode 100644 tests/theme_fixture.html delete mode 100644 ui/app.css delete mode 100644 ui/app.js delete mode 100644 ui/icons.svg delete mode 100644 ui/index.html diff --git a/CHANGELOG.md b/CHANGELOG.md index 56390f5..4f23e84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,22 @@ # RustBox changelog +### 0.0.2 + +- Split the monolithic browser application JavaScript into functional modules under `src/web/js/`; `build.rs` now assembles the JavaScript manifest into the embedded fingerprinted bundle alongside the existing CSS build. +- Added a generated standalone `ui/app.js` workflow for preview/release snapshots while keeping `src/web/js/` as the only JavaScript source of truth. +- Removed the top-level Compact/Balanced/Comfortable/Reading appearance preset cards. +- Expanded **Interface style** with RustBox (custom), Roundcube Classic, Roundcube Larry, Roundcube Elastic, Clean, Graphite and High contrast. +- Fixed styles no longer overwrite the user's RustBox custom colors/layout values; switching back to RustBox restores the saved manual configuration. +- Fixed interface styles lock palette/geometry controls while leaving Theme, language, date/time formatting and accessibility/user visibility toggles available. +- Added dedicated light/dark palettes for Larry, Clean, Graphite and High contrast while retaining the existing Classic/Elastic compatibility skins. +- Updated UI/build contract tests for the modular JavaScript build and expanded interface-style validation. + ### 0.0.1 +- Reworked Roundcube appearance compatibility: dedicated Classic and Elastic interface skins now use upstream-aligned light palettes, dedicated dark variants, exact Classic red/gray selection accents, Elastic cyan/dark tokens, and palette locking so generic RustBox colors cannot bleed into the selected skin. +- Replaced the mobile folder-drawer close affordance with a left-facing SVG back arrow in a dedicated mobile drawer header. +- Added persisted `interface_skin` display preferences and two schema migrations for skin selection and saved-response format. +- Added Roundcube-style saved/canned responses with text/HTML formats, settings CRUD UI and composer insertion; HTML is sanitized before insertion. +- Added static theme-contract tests and browser-rendered visual fixtures for Classic/Elastic light/dark and mobile drawer validation. - 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. diff --git a/Cargo.lock b/Cargo.lock index b98544a..013dfeb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2957,7 +2957,7 @@ dependencies = [ [[package]] name = "rustbox" -version = "0.0.1" +version = "0.0.2" dependencies = [ "async-imap", "async-native-tls", diff --git a/Cargo.toml b/Cargo.toml index 717788f..9b47a0b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustbox" -version = "0.0.1" +version = "0.0.2" edition = "2024" rust-version = "1.88" default-run = "rustbox" diff --git a/NOTICE.md b/NOTICE.md index 10e2e77..e7d86a9 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -1,6 +1,6 @@ # RustBox attribution / notice -RustBox 0.0.1 is derived from the Oxi webmail project: +RustBox 0.0.2 is derived from the Oxi webmail project: - Upstream: `c0h1b4/oxi` - Base snapshot: `7f1507c486b78cf33e54beb701dd460d3bd2bd5d` diff --git a/README.md b/README.md index ef93d64..57ccb02 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# RustBox 0.0.1 +# RustBox 0.0.2 RustBox is a webmail derived from `c0h1b4/oxi`. The consumed Oxi revision is recorded in `UPSTREAM_BASE`. @@ -16,7 +16,9 @@ rustbox/ │ ├── ui.rs │ └── web/ │ ├── index.html -│ ├── app.js +│ ├── js/ +│ │ ├── manifest.txt +│ │ └── *.js │ ├── icons.svg │ └── css/ ├── migrations/ @@ -31,7 +33,7 @@ rustbox/ └── 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. +`build.rs` combines the CSS modules from `src/web/css/manifest.txt` and JavaScript modules from `src/web/js/manifest.txt`. Cargo embeds the generated CSS/JS bundles, HTML and SVG sprite in the RustBox executable. There is no standalone source `src/web/app.js`; `ui/app.js` is only a generated preview/release snapshot. ## Configure mail servers @@ -61,7 +63,7 @@ outgoing_headers = [ 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 +## Current 0.0.2 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`. @@ -101,7 +103,7 @@ IMAP IDLE events are delivered to the browser over the embedded WebSocket endpoi 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. +The native RustBox interface intentionally uses flat surfaces, small radii and one SVG icon set. Compatibility skins may reproduce upstream visual treatments such as gradients, while toolbar icons remain SVG rather than Unicode glyphs. ## ManageSieve @@ -131,7 +133,7 @@ Initialize Git after extracting the release archive: ```bash git init git add . -git commit -m "RustBox 0.0.1 baseline" +git commit -m "RustBox 0.0.2 release" ./scripts/setup-upstream.sh ``` @@ -161,6 +163,6 @@ See `docs/UPSTREAM.md` for details. 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 +## Internal roadmap 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. +The authoritative internal feature plan is `docs/RUSTBOX_PLAN.md`. Current implementation status is tracked in `docs/IMPLEMENTATION_STATUS.md`. The historical baseline plan remains in `docs/RUSTBOX_PLAN.md`; current release deltas are documented in `CHANGELOG.md` and `docs/releases/`. diff --git a/RELEASE-MANIFEST.txt b/RELEASE-MANIFEST.txt index b115aee..3ae7105 100644 --- a/RELEASE-MANIFEST.txt +++ b/RELEASE-MANIFEST.txt @@ -1,4 +1,4 @@ -RustBox 0.0.1 source release +RustBox 0.0.2 source release Runtime/source model: - one root Cargo package @@ -19,7 +19,10 @@ Oxi tracking: - Oxi frontend changes are reviewed and manually ported to src/web/ - docs/UI.md documents the browser UI/build conventions -Current 0.0.1 working snapshot additions: +Current 0.0.2 release additions: +- JavaScript source split into functional `src/web/js/` modules and assembled by Cargo build.rs +- Interface style selector expanded to RustBox custom, Roundcube Classic/Larry/Elastic, Clean, Graphite and High contrast +- legacy Compact/Balanced/Comfortable/Reading appearance cards removed - 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 @@ -30,7 +33,7 @@ Current 0.0.1 working snapshot additions: - 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 +- 45 contiguous SQLite migrations wired sequentially; V042 adds per-user trusted senders, V043 saved responses, V044 interface skins and V045 saved-response format - 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 diff --git a/build.rs b/build.rs index 915deaf..3610c9b 100644 --- a/build.rs +++ b/build.rs @@ -1,35 +1,31 @@ -use std::{env, fs, path::PathBuf}; +use std::{ + env, fs, + path::{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"); +fn fnv1a64(parts: &[&[u8]]) -> u64 { + let mut hash = 0xcbf29ce484222325u64; + for part in parts { + for byte in *part { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x100000001b3); + } + } + hash +} +fn bundle_manifest( + root: &Path, + directory: &Path, + manifest: &Path, + extension: &str, + banner: &str, +) -> String { 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"); + let manifest_text = fs::read_to_string(manifest) + .unwrap_or_else(|error| panic!("{}: {error}", manifest.display())); + let mut bundle = String::from(banner); + bundle.push('\n'); for raw in manifest_text.lines() { let name = raw.trim(); @@ -37,22 +33,66 @@ fn main() { continue; } assert!( - name.ends_with(".css") + name.ends_with(extension) && !name.contains('/') && !name.contains('\\') && !name.contains(".."), - "invalid CSS manifest entry: {name}" + "invalid {extension} manifest entry: {name}" + ); + let path = directory.join(name); + assert!( + path.starts_with(root), + "asset path escaped project root: {}", + path.display() ); - let path = css_dir.join(name); println!("cargo:rerun-if-changed={}", path.display()); - let css = + let source = fs::read_to_string(&path).unwrap_or_else(|error| panic!("{}: {error}", path.display())); - bundle.push_str("\n/* "); + bundle.push_str("\n/* ---- "); bundle.push_str(name); - bundle.push_str(" */\n"); - bundle.push_str(&css); - bundle.push('\n'); + bundle.push_str(" ---- */\n"); + bundle.push_str(&source); + if !source.ends_with('\n') { + bundle.push('\n'); + } } - fs::write(out.join("rustbox-ui.css"), bundle).expect("write RustBox CSS bundle"); + bundle +} + +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 js_dir = root.join("src/web/js"); + + for path in [ + root.join("src/web/index.html"), + root.join("src/web/icons.svg"), + root.join("src/web/i18n/en.json"), + root.join("src/web/i18n/pl.json"), + ] { + println!("cargo:rerun-if-changed={}", path.display()); + } + + let css_bundle = bundle_manifest( + &root, + &css_dir, + &css_dir.join("manifest.txt"), + ".css", + "/* RustBox UI CSS bundle - generated by build.rs */", + ); + let js_bundle = bundle_manifest( + &root, + &js_dir, + &js_dir.join("manifest.txt"), + ".js", + "/* RustBox UI JavaScript bundle - generated by build.rs; edit src/web/js/*.js */", + ); + + let asset_id = fnv1a64(&[css_bundle.as_bytes(), js_bundle.as_bytes()]); + println!("cargo:rustc-env=RUSTBOX_ASSET_ID={asset_id:016x}"); + + fs::write(out.join("rustbox-ui.css"), css_bundle).expect("write RustBox CSS bundle"); + fs::write(out.join("rustbox-ui.js"), js_bundle).expect("write RustBox JavaScript bundle"); } diff --git a/config.example.toml b/config.example.toml index fe49658..20c1f7f 100644 --- a/config.example.toml +++ b/config.example.toml @@ -1,4 +1,4 @@ -# RustBox 0.0.1 +# RustBox 0.0.2 host = "127.0.0.1" port = 3001 environment = "production" @@ -28,6 +28,14 @@ sieve_timeout_secs = 10 trusted_proxies = "127.0.0.1,::1" +# Optional server-wide defaults for special IMAP folders. These are used when +# a mailbox has no per-account override and the named folder exists. +# default_sent_folder = "Sent" +# default_drafts_folder = "Drafts" +# default_trash_folder = "Trash" +# default_junk_folder = "Junk" +# default_archive_folder = "Archive" + # Optional headers added to every outgoing message. Use `Header-Name: value`. # RustBox rejects structural/MIME header overrides and CR/LF injection. outgoing_headers = [] diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index dfdbd69..7feab46 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,6 +1,6 @@ -# RustBox 0.0.1 - implementation status +# RustBox 0.0.2 - 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. +This file tracks the internal `0.0.2` 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 @@ -16,6 +16,7 @@ This file tracks the internal `0.0.1` baseline. The user-approved `docs/RUSTBOX_ - 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. +- Administrators can define server-wide default Sent/Drafts/Trash/Junk/Archive folder names; per-account settings still override them when present. - 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. @@ -30,7 +31,7 @@ This file tracks the internal `0.0.1` baseline. The user-approved `docs/RUSTBOX_ - 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. +- Attachment context menu supports in-app preview/download for safe image/PDF/text/HTML/video classes; HTML is sandboxed and video uses native browser controls. 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 @@ -103,14 +104,14 @@ This file tracks the internal `0.0.1` baseline. The user-approved `docs/RUSTBOX_ - 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. +- All 45 SQLite migrations are present, contiguous and wired sequentially in the migration runner during static validation; V042 adds trusted senders, V043 saved responses, V044 persisted interface skins and V045 saved-response format. ## 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. +- Notifications are wired through IMAP IDLE/WebSocket, include permission status/test controls, and local calendar reminders can show browser notifications and play the configured sound while RustBox is open. Service-worker/background delivery is still 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 @@ -133,13 +134,15 @@ This file tracks the internal `0.0.1` baseline. The user-approved `docs/RUSTBOX_ - 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. +- The mobile composer keeps address labels and controls aligned, and the mobile folder drawer can be closed by its toggle, backdrop, Escape or folder selection while hierarchy disclosure controls keep larger touch targets. - 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. +- Reader addresses expose one-click contact actions; trusted senders can be added from a message so remote images load automatically unless the global policy is Never. - 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. +- Embedded CSS/JS use content-fingerprinted URLs with `Cache-Control: public, max-age=31536000, immutable`; HTML and unversioned compatibility aliases remain `no-store`. - 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. @@ -152,6 +155,6 @@ This file tracks the internal `0.0.1` baseline. The user-approved `docs/RUSTBOX_ - 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. +- Appearance uses persisted `interface_skin` modes for RustBox (custom), Roundcube Classic, Roundcube Larry, Roundcube Elastic, Clean, Graphite and High contrast. The old Compact/Balanced/Comfortable/Reading preset cards were removed. Fixed interface styles apply their own visual geometry and palette without overwriting the user's stored RustBox custom values; selecting RustBox re-enables manual color, spacing, pane and typography controls. Classic Dark remains an adaptation because upstream Classic has no official dark skin. +- 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. Saved/canned responses can be managed as text or sanitized HTML and inserted from the composer toolbar. - 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 index 535c7c3..31fac1d 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -21,3 +21,8 @@ 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. + + +## Browser asset build + +Browser JavaScript source lives in functional modules under `src/web/js/`, ordered by `src/web/js/manifest.txt`. `build.rs` concatenates those modules into the embedded `rustbox-ui.js` asset during every Cargo build. `ui/app.js` is a generated standalone preview/release snapshot and must not be edited as source. diff --git a/docs/ROADMAP_GAPS.md b/docs/ROADMAP_GAPS.md index 7645525..7c9eb1d 100644 --- a/docs/ROADMAP_GAPS.md +++ b/docs/ROADMAP_GAPS.md @@ -1,4 +1,4 @@ -# RustBox 0.0.1 — remaining roadmap gaps +# RustBox 0.0.2 — remaining roadmap gaps Snapshot after the OpenPGP/key-lifecycle and unbounded Sieve action/condition work in this iteration. @@ -74,7 +74,7 @@ OpenPGP is functionally complete for the scope defined in `RUSTBOX_PLAN.md` §9: ### 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. +- [ ] Background/service-worker notifications while the page is inactive, if retained for 0.0.2. - [ ] Richer per-account notification policy beyond global/per-folder controls. - [ ] Background task/queue status plus recent-error/repair workflows. diff --git a/docs/ROUNDCUBE_SKIN_VALIDATION.md b/docs/ROUNDCUBE_SKIN_VALIDATION.md new file mode 100644 index 0000000..abdd299 --- /dev/null +++ b/docs/ROUNDCUBE_SKIN_VALIDATION.md @@ -0,0 +1,20 @@ +# Roundcube skin validation + +This snapshot maps RustBox components onto the official Roundcube Classic and Elastic visual tokens and adds a Larry-compatible fixed interface style. + +## Source mapping + +- **Classic Light**: `skins/classic/common.css` and Classic mail styles: `#F6F6F6` page background, `#F2F2F2` box background, `#CC3333` selected message/accent, `#929292` selected folder, square controls, Lucida/Verdana/Arial stack. +- **Classic Dark**: upstream Classic has no official dark mode. RustBox preserves the Classic red/gray hierarchy, square controls and gradients while adapting surfaces/text for dark contrast. +- **Elastic Light**: `skins/elastic/styles/colors.less` and variables: `#37beff` main, `#00acff` link/accent, `#f4f4f4` header, `#ddd` borders, white list/content surfaces. +- **Elastic Dark**: upstream dark tokens: `#21292c` background, `#c5d1d3` text, `#4d6066` border, `#374549` selected row, `#2f3a3f` dark header/task surface. + +## Automated checks + +`python3 tests/roundcube_skin_contract.py` + +The test verifies all migrations on a blank SQLite database, `interface_skin`, saved-response `format`, unique HTML ids, SVG back-arrow markup, both skin/theme selector pairs, upstream palette tokens, CSS parseability and frontend/backend registration. + +`scripts/bundle-ui-js.py --output /tmp/rustbox-ui.js` followed by `node --check /tmp/rustbox-ui.js` validates the JavaScript manifest as one build artifact. `ui/app.js` is the generated standalone snapshot and is checked separately. + +The browser fixture in `tests/theme_fixture.html` is rendered with the real bundled CSS. Validation screenshots in `tests/theme-shots/` cover Classic Light/Dark, Elastic Light/Dark and both mobile drawer variants. diff --git a/docs/UI.md b/docs/UI.md index 29f5348..ddb42f9 100644 --- a/docs/UI.md +++ b/docs/UI.md @@ -9,13 +9,13 @@ Design direction: - 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; +- no Unicode pseudo-icons; the native RustBox style stays flat while compatibility skins may reproduce their source skin treatment; - 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. +`build.rs` reads both `src/web/css/manifest.txt` and `src/web/js/manifest.txt`, concatenates each module set in declared order and writes generated CSS/JavaScript bundles to Cargo `OUT_DIR`. `src/ui.rs` embeds those generated bundles together with `index.html` and `icons.svg` into the RustBox executable. The source of truth for browser JavaScript is `src/web/js/`; `src/web/app.js` does not exist. No Node.js, Bun, TypeScript, React or Next.js build step exists. diff --git a/docs/releases/v0.0.2.md b/docs/releases/v0.0.2.md new file mode 100644 index 0000000..4933ab4 --- /dev/null +++ b/docs/releases/v0.0.2.md @@ -0,0 +1,21 @@ +# RustBox 0.0.2 + +RustBox 0.0.2 modularizes the browser JavaScript build and restructures appearance selection around fixed interface styles plus a fully customizable RustBox mode. + +## UI build + +- Source JavaScript lives in `src/web/js/` and is ordered by `src/web/js/manifest.txt`. +- Cargo `build.rs` concatenates the modules into `rustbox-ui.js`, fingerprints the combined CSS/JS asset set and embeds the generated bundle. +- `scripts/bundle-ui-js.py` generates `ui/app.js` only for standalone UI previews/release snapshots. + +## Interface styles + +- RustBox (custom) +- Roundcube Classic +- Roundcube Larry +- Roundcube Elastic +- Clean +- Graphite +- High contrast + +The removed Compact/Balanced/Comfortable/Reading preset cards are no longer part of Appearance. Fixed styles use their own geometry and palette without destroying the stored RustBox custom values. diff --git a/migrations/V042__trusted_senders.sql b/migrations/V042__trusted_senders.sql new file mode 100644 index 0000000..16d4bfb --- /dev/null +++ b/migrations/V042__trusted_senders.sql @@ -0,0 +1,4 @@ +CREATE TABLE IF NOT EXISTS trusted_senders ( + email TEXT PRIMARY KEY COLLATE NOCASE, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); diff --git a/migrations/V043__response_templates.sql b/migrations/V043__response_templates.sql new file mode 100644 index 0000000..723acb0 --- /dev/null +++ b/migrations/V043__response_templates.sql @@ -0,0 +1,7 @@ +CREATE TABLE IF NOT EXISTS response_templates ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL COLLATE NOCASE UNIQUE, + body TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); diff --git a/migrations/V044__interface_skin.sql b/migrations/V044__interface_skin.sql new file mode 100644 index 0000000..5d8bb36 --- /dev/null +++ b/migrations/V044__interface_skin.sql @@ -0,0 +1 @@ +ALTER TABLE display_preferences ADD COLUMN interface_skin TEXT NOT NULL DEFAULT 'rustbox'; diff --git a/migrations/V045__response_template_format.sql b/migrations/V045__response_template_format.sql new file mode 100644 index 0000000..b747076 --- /dev/null +++ b/migrations/V045__response_template_format.sql @@ -0,0 +1 @@ +ALTER TABLE response_templates ADD COLUMN format TEXT NOT NULL DEFAULT 'text'; diff --git a/scripts/build-release.sh b/scripts/build-release.sh index 01c2ba0..ec91d9b 100755 --- a/scripts/build-release.sh +++ b/scripts/build-release.sh @@ -14,6 +14,11 @@ if find "$ROOT" -type f \( -name '*.ts' -o -name '*.tsx' \) -print -quit | grep exit 1 fi +if [[ -e "$ROOT/src/web/app.js" || ! -f "$ROOT/src/web/js/manifest.txt" ]]; then + echo "error: browser JavaScript must be modularized under src/web/js/" >&2 + exit 1 +fi + cargo test --locked cargo build --release --locked diff --git a/src/auth/session.rs b/src/auth/session.rs index dc71637..cdc6954 100644 --- a/src/auth/session.rs +++ b/src/auth/session.rs @@ -1,8 +1,8 @@ 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 base64::engine::general_purpose::URL_SAFE_NO_PAD; use dashmap::DashMap; use rand::RngCore; @@ -102,17 +102,8 @@ impl SessionStore { 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, + browser_id, email, password, user_hash, imap_host, imap_port, imap_tls, smtp_host, + smtp_port, smtp_tls, None, ) } @@ -234,7 +225,11 @@ impl SessionStore { /// Store a display-only User-Agent string for active-session diagnostics. /// It is never used for authentication or authorization decisions. pub fn set_user_agent(&self, account_id: &str, user_agent: String) { - let Some(session_id) = self.account_to_session.get(account_id).map(|entry| entry.clone()) else { + 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) { @@ -267,7 +262,11 @@ impl SessionStore { /// 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 { + let Some(session_id) = self + .account_to_session + .get(account_id) + .map(|entry| entry.clone()) + else { return false; }; let belongs_to_user = self @@ -890,7 +889,11 @@ mod tests { // --- Consistency tests --- - fn add_test_account(store: &SessionStore, browser_id: &str, email: &str) -> (SessionId, AccountId) { + fn add_test_account( + store: &SessionStore, + browser_id: &str, + email: &str, + ) -> (SessionId, AccountId) { store.add_account_to_browser( browser_id, email.into(), @@ -1231,7 +1234,11 @@ mod tests { store.purge_expired(); - assert_eq!(store.sessions.len(), 1, "only the live session should remain"); + 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" @@ -1299,7 +1306,11 @@ mod tests { thread::sleep(Duration::from_millis(100)); - assert!(store.get_account_session(&browser_id, &account_id, &token).is_none()); + assert!( + store + .get_account_session(&browser_id, &account_id, &token) + .is_none() + ); assert!( !store.account_to_session.contains_key(&account_id), @@ -1325,7 +1336,9 @@ mod tests { ); thread::sleep(Duration::from_millis(100)); - assert!(store.get(&token).is_some(), "remembered session should outlive default timeout"); + 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 index d4ef46c..0f61768 100644 --- a/src/auth/user_data.rs +++ b/src/auth/user_data.rs @@ -94,7 +94,10 @@ mod tests { 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("db.sqlite").exists(), + "db.sqlite should exist" + ); assert!( user_dir.join("tantivy").is_dir(), "tantivy/ subdirectory should exist" diff --git a/src/config.rs b/src/config.rs index 4af598a..89bbb75 100644 --- a/src/config.rs +++ b/src/config.rs @@ -121,6 +121,18 @@ pub struct AppConfig { /// Disabled by default; public-key export is always available. #[serde(default)] pub pgp_allow_private_export: bool, + + /// Administrator defaults for special IMAP folders. Per-account settings override these. + #[serde(default)] + pub default_sent_folder: Option, + #[serde(default)] + pub default_drafts_folder: Option, + #[serde(default)] + pub default_trash_folder: Option, + #[serde(default)] + pub default_junk_folder: Option, + #[serde(default)] + pub default_archive_folder: Option, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -385,6 +397,11 @@ mod tests { assert!(config.trusted_proxies.is_none()); assert!(config.outgoing_headers.is_empty()); assert!(!config.pgp_allow_private_export); + assert!(config.default_sent_folder.is_none()); + assert!(config.default_drafts_folder.is_none()); + assert!(config.default_trash_folder.is_none()); + assert!(config.default_junk_folder.is_none()); + assert!(config.default_archive_folder.is_none()); } #[test] diff --git a/src/db/display_preferences.rs b/src/db/display_preferences.rs index df273c0..7de9bd1 100644 --- a/src/db/display_preferences.rs +++ b/src/db/display_preferences.rs @@ -53,6 +53,7 @@ pub struct DisplayPreferences { pub quote_style: String, pub forward_mode: String, pub spellcheck_enabled: bool, + pub interface_skin: String, pub updated_at: String, } @@ -109,9 +110,12 @@ pub struct UpdateDisplayPreferences { pub quote_style: Option, pub forward_mode: Option, pub spellcheck_enabled: Option, + pub interface_skin: Option, } -fn deserialize_animation_mode_field<'de, D>(deserializer: D) -> Result>, D::Error> +fn deserialize_animation_mode_field<'de, D>( + deserializer: D, +) -> Result>, D::Error> where D: Deserializer<'de>, { @@ -171,13 +175,14 @@ fn defaults() -> DisplayPreferences { quote_style: "standard".into(), forward_mode: "inline".into(), spellcheck_enabled: true, + interface_skin: "rustbox".into(), 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", + "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, interface_skin, 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)?, @@ -190,7 +195,7 @@ pub fn get_preferences(conn: &Connection) -> Result 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)?, + 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, interface_skin: row.get(50)?, updated_at: row.get(51)?, }), ); match result { @@ -201,89 +206,293 @@ pub fn get_preferences(conn: &Connection) -> Result } fn enum_value(name: &str, value: &str, allowed: &[&str]) -> Result<(), String> { - if allowed.contains(&value) { Ok(()) } else { Err(format!("Invalid {name}: {value}")) } + 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}")) } + 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}"))?; +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}")); } } + 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 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"); + 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(ref v) = data.interface_skin { + enum_value( + "interface_skin", + v, + &[ + "rustbox", + "roundcube-classic", + "roundcube-larry", + "roundcube-elastic", + "clean", + "graphite", + "high-contrast", + ], + )?; + } + 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 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"); + set_text!(interface_skin, "interface_skin"); - if sets.is_empty() { return get_preferences(conn); } + 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); + 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}"))?; + conn.execute(&sql, refs.as_slice()) + .map_err(|e| format!("Failed to update display preferences: {e}"))?; get_preferences(conn) } @@ -300,19 +509,50 @@ mod tests { 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(); + "show_avatars":false, "reduce_motion":true, "interface_skin":"roundcube-elastic" + })) + .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); + assert_eq!(prefs.interface_skin, "roundcube-elastic"); + } + + #[test] + fn built_in_interface_skins_are_accepted() { + for skin in [ + "rustbox", + "roundcube-classic", + "roundcube-larry", + "roundcube-elastic", + "clean", + "graphite", + "high-contrast", + ] { + let conn = open_test_db(); + let update: UpdateDisplayPreferences = + serde_json::from_value(serde_json::json!({"interface_skin": skin})).unwrap(); + let prefs = update_preferences(&conn, &update).unwrap(); + assert_eq!(prefs.interface_skin, skin); + } + } + + #[test] + fn invalid_interface_skin_is_rejected() { + let conn = open_test_db(); + let update: UpdateDisplayPreferences = + serde_json::from_value(serde_json::json!({"interface_skin":"roundcube-unknown"})) + .unwrap(); + assert!(update_preferences(&conn, &update).is_err()); } #[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(); + 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/mod.rs b/src/db/mod.rs index 3af2ba4..dd2e5f0 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -11,8 +11,10 @@ pub mod mail_preferences; pub mod messages; pub mod notification_preferences; pub mod pgp; +pub mod response_templates; pub mod pool; pub mod special_folders; pub mod tags; +pub mod trusted_senders; pub mod saved_searches; diff --git a/src/db/pool.rs b/src/db/pool.rs index a398f16..e71b640 100644 --- a/src/db/pool.rs +++ b/src/db/pool.rs @@ -35,46 +35,164 @@ impl UserDb { /// 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")), + ( + 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")), + ( + 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")), + ( + 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")), + ( + 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")), + ( + 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"), + ), + ( + 42, + include_str!("../../migrations/V042__trusted_senders.sql"), + ), + ( + 43, + include_str!("../../migrations/V043__response_templates.sql"), + ), + ( + 44, + include_str!("../../migrations/V044__interface_skin.sql"), + ), + ( + 45, + include_str!("../../migrations/V045__response_template_format.sql"), + ), ]; /// Run any pending migrations based on SQLite's `user_version` PRAGMA. @@ -128,8 +246,7 @@ pub fn open_user_db(data_dir: &str, user_hash: &str) -> Result) -> rusqlite::Result { + Ok(ResponseTemplate { + id: row.get(0)?, + name: row.get(1)?, + body: row.get(2)?, + format: row.get(3)?, + created_at: row.get(4)?, + updated_at: row.get(5)?, + }) +} + +pub fn list(conn: &Connection) -> Result, String> { + let mut stmt = conn + .prepare("SELECT id,name,body,format,created_at,updated_at FROM response_templates ORDER BY name COLLATE NOCASE") + .map_err(|e| format!("Failed to prepare response templates: {e}"))?; + let rows = stmt + .query_map([], from_row) + .map_err(|e| format!("Failed to list response templates: {e}"))?; + rows.map(|row| row.map_err(|e| format!("Failed to read response template: {e}"))) + .collect() +} + +pub fn get(conn: &Connection, id: i64) -> Result, String> { + match conn.query_row( + "SELECT id,name,body,format,created_at,updated_at FROM response_templates WHERE id=?1", + params![id], + from_row, + ) { + Ok(value) => Ok(Some(value)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(format!("Failed to get response template: {e}")), + } +} + +pub fn create( + conn: &Connection, + name: &str, + body: &str, + format: &str, +) -> Result { + let name = name.trim(); + if name.is_empty() { + return Err("Template name is required".to_string()); + } + if name.chars().count() > 120 { + return Err("Template name is too long".to_string()); + } + if body.len() > 256 * 1024 { + return Err("Template body is too large".to_string()); + } + let format = match format { + "html" => "html", + "text" | "" => "text", + _ => return Err("Invalid response template format".to_string()), + }; + conn.execute( + "INSERT INTO response_templates(name,body,format) VALUES (?1,?2,?3)", + params![name, body, format], + ) + .map_err(|e| { + if e.to_string().contains("UNIQUE constraint failed") { + "A response template with this name already exists".to_string() + } else { + format!("Failed to create response template: {e}") + } + })?; + get(conn, conn.last_insert_rowid())? + .ok_or_else(|| "Failed to read response template".to_string()) +} + +pub fn update( + conn: &Connection, + id: i64, + name: &str, + body: &str, + format: &str, +) -> Result, String> { + let name = name.trim(); + if name.is_empty() { + return Err("Template name is required".to_string()); + } + if name.chars().count() > 120 { + return Err("Template name is too long".to_string()); + } + if body.len() > 256 * 1024 { + return Err("Template body is too large".to_string()); + } + let format = match format { + "html" => "html", + "text" | "" => "text", + _ => return Err("Invalid response template format".to_string()), + }; + let changed = conn.execute( + "UPDATE response_templates SET name=?1,body=?2,format=?3,updated_at=datetime('now') WHERE id=?4", + params![name, body, format, id], + ).map_err(|e| if e.to_string().contains("UNIQUE constraint failed") { + "A response template with this name already exists".to_string() + } else { format!("Failed to update response template: {e}") })?; + if changed == 0 { + return Ok(None); + } + get(conn, id) +} + +pub fn delete(conn: &Connection, id: i64) -> Result { + Ok(conn + .execute("DELETE FROM response_templates WHERE id=?1", params![id]) + .map_err(|e| format!("Failed to delete response template: {e}"))? + > 0) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::pool::open_test_db; + + #[test] + fn response_templates_support_text_and_html() { + let conn = open_test_db(); + let text = create(&conn, "Thanks", "Thank you", "text").unwrap(); + assert_eq!(text.format, "text"); + let html = create(&conn, "Welcome", "Welcome", "html").unwrap(); + assert_eq!(html.format, "html"); + let updated = update(&conn, html.id, "Welcome", "Hello", "html") + .unwrap() + .unwrap(); + assert_eq!(updated.body, "Hello"); + assert_eq!(list(&conn).unwrap().len(), 2); + assert!(delete(&conn, text.id).unwrap()); + } + + #[test] + fn response_template_format_is_validated() { + let conn = open_test_db(); + assert!(create(&conn, "Bad", "Body", "markdown").is_err()); + } +} diff --git a/src/db/trusted_senders.rs b/src/db/trusted_senders.rs new file mode 100644 index 0000000..4040103 --- /dev/null +++ b/src/db/trusted_senders.rs @@ -0,0 +1,46 @@ +use rusqlite::Connection; + +pub fn list(conn: &Connection) -> Result, String> { + let mut stmt = conn + .prepare("SELECT email FROM trusted_senders ORDER BY lower(email)") + .map_err(|e| format!("Failed to prepare trusted sender list: {e}"))?; + let rows = stmt + .query_map([], |row| row.get::<_, String>(0)) + .map_err(|e| format!("Failed to query trusted senders: {e}"))?; + rows.collect::, _>>() + .map_err(|e| format!("Failed to read trusted sender: {e}")) +} + +pub fn add(conn: &Connection, email: &str) -> Result<(), String> { + conn.execute( + "INSERT OR IGNORE INTO trusted_senders(email) VALUES(?1)", + [email], + ) + .map_err(|e| format!("Failed to trust sender: {e}"))?; + Ok(()) +} + +pub fn remove(conn: &Connection, email: &str) -> Result<(), String> { + conn.execute( + "DELETE FROM trusted_senders WHERE email = ?1 COLLATE NOCASE", + [email], + ) + .map_err(|e| format!("Failed to remove trusted sender: {e}"))?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::pool::open_test_db; + + #[test] + fn trusted_senders_roundtrip_case_insensitive() { + let conn = open_test_db(); + add(&conn, "Alice@Example.com").unwrap(); + add(&conn, "alice@example.com").unwrap(); + assert_eq!(list(&conn).unwrap().len(), 1); + remove(&conn, "ALICE@example.com").unwrap(); + assert!(list(&conn).unwrap().is_empty()); + } +} diff --git a/src/pgp/discovery.rs b/src/pgp/discovery.rs index 99a6ee6..7eeee47 100644 --- a/src/pgp/discovery.rs +++ b/src/pgp/discovery.rs @@ -126,7 +126,7 @@ async fn https_get_address( format!("{host}:{port}") }; let request = format!( - "GET {target} HTTP/1.1\r\nHost: {host_header}\r\nUser-Agent: RustBox/0.0.1\r\nAccept: application/octet-stream, application/pgp-keys, text/plain\r\nConnection: close\r\n\r\n" + "GET {target} HTTP/1.1\r\nHost: {host_header}\r\nUser-Agent: RustBox/0.0.2\r\nAccept: application/octet-stream, application/pgp-keys, text/plain\r\nConnection: close\r\n\r\n" ); timeout(duration, stream.write_all(request.as_bytes())) .await diff --git a/src/routes/calendar.rs b/src/routes/calendar.rs index 8476157..f24a007 100644 --- a/src/routes/calendar.rs +++ b/src/routes/calendar.rs @@ -192,7 +192,7 @@ pub async fn export_ics( .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; let mut body = String::from( - "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//RustBox//Calendar 0.0.1//EN\r\nCALSCALE:GREGORIAN\r\n", + "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//RustBox//Calendar 0.0.2//EN\r\nCALSCALE:GREGORIAN\r\n", ); for event in &events { append_ics_event(&mut body, event); diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 7095c27..c1a8ee4 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -16,11 +16,13 @@ pub mod messages; pub mod notification_preferences; pub mod pgp; pub mod quota; +pub mod response_templates; pub mod search; pub mod send; pub mod sieve; pub mod special_folders; pub mod tags; +pub mod trusted_senders; pub mod unsubscribe; use std::net::{IpAddr, Ipv4Addr, SocketAddr}; @@ -247,6 +249,14 @@ pub fn create_router( get(attachments::get_attachment_content), ) .route("/search", get(search::search_messages)) + .route( + "/response-templates", + get(response_templates::list).post(response_templates::create), + ) + .route( + "/response-templates/{id}", + put(response_templates::update).delete(response_templates::delete), + ) .route( "/search/saved", get(search::list_saved_searches).post(search::create_saved_search), @@ -399,6 +409,14 @@ pub fn create_router( get(mail_preferences::get_mail_preferences) .put(mail_preferences::update_mail_preferences), ) + .route( + "/settings/trusted-senders", + get(trusted_senders::list_trusted_senders).post(trusted_senders::add_trusted_sender), + ) + .route( + "/settings/trusted-senders/{email}", + delete(trusted_senders::remove_trusted_sender), + ) .route( "/folders/{folder}/preferences", get(folder_preferences::get_folder_preferences) diff --git a/src/routes/response_templates.rs b/src/routes/response_templates.rs new file mode 100644 index 0000000..faf615e --- /dev/null +++ b/src/routes/response_templates.rs @@ -0,0 +1,77 @@ +use std::sync::Arc; + +use axum::{ + extract::Path, + response::{IntoResponse, Response}, + Extension, Json, +}; +use serde::Deserialize; + +use crate::{ + auth::session::SessionState, + config::AppConfig, + db, + error::AppError, +}; + +#[derive(Debug, Deserialize)] +pub struct ResponseTemplateRequest { + name: String, + #[serde(default)] + body: String, + #[serde(default = "default_template_format")] + format: String, +} + +fn default_template_format() -> String { "text".to_string() } + +pub async fn list( + 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 templates = db::response_templates::list(&conn).map_err(AppError::InternalError)?; + Ok(Json(serde_json::json!({ "templates": templates })).into_response()) +} + +pub async fn create( + 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 template = db::response_templates::create(&conn, &data.name, &data.body, &data.format) + .map_err(AppError::BadRequest)?; + Ok((axum::http::StatusCode::CREATED, Json(template)).into_response()) +} + +pub async fn update( + 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::response_templates::update(&conn, id, &data.name, &data.body, &data.format) + .map_err(AppError::BadRequest)? { + Some(template) => Ok(Json(template).into_response()), + None => Err(AppError::NotFound("Response template not found".to_string())), + } +} + +pub async fn delete( + 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::response_templates::delete(&conn, id).map_err(AppError::InternalError)? { + Ok(Json(serde_json::json!({ "status": "deleted" })).into_response()) + } else { + Err(AppError::NotFound("Response template not found".to_string())) + } +} diff --git a/src/routes/special_folders.rs b/src/routes/special_folders.rs index 56b23d5..5d06f3e 100644 --- a/src/routes/special_folders.rs +++ b/src/routes/special_folders.rs @@ -2,6 +2,7 @@ use std::sync::Arc; use axum::response::{IntoResponse, Response}; use axum::{Extension, Json}; +use rusqlite::Connection; use crate::auth::session::SessionState; use crate::config::AppConfig; @@ -9,13 +10,57 @@ use crate::db; use crate::db::special_folders::SpecialFolders; use crate::error::AppError; +fn configured_folder_if_present( + conn: &Connection, + value: &Option, +) -> Result, String> { + let Some(folder) = value + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + else { + return Ok(None); + }; + 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 configured special folder {folder}: {e}"))?; + Ok(exists.then(|| folder.to_string())) +} + +fn effective_special_folders( + conn: &Connection, + config: &AppConfig, +) -> Result { + let mut value = db::special_folders::get_all(conn)?; + if value.sent.is_none() { + value.sent = configured_folder_if_present(conn, &config.default_sent_folder)?; + } + if value.drafts.is_none() { + value.drafts = configured_folder_if_present(conn, &config.default_drafts_folder)?; + } + if value.trash.is_none() { + value.trash = configured_folder_if_present(conn, &config.default_trash_folder)?; + } + if value.junk.is_none() { + value.junk = configured_folder_if_present(conn, &config.default_junk_folder)?; + } + if value.archive.is_none() { + value.archive = configured_folder_if_present(conn, &config.default_archive_folder)?; + } + Ok(value) +} + 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)?; + let value = effective_special_folders(&conn, &config).map_err(AppError::InternalError)?; Ok(Json(value).into_response()) } @@ -26,7 +71,7 @@ pub async fn update_special_folders( ) -> 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| { + 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:") { @@ -35,5 +80,6 @@ pub async fn update_special_folders( AppError::InternalError(message) } })?; + let saved = effective_special_folders(&conn, &config).map_err(AppError::InternalError)?; Ok(Json(saved).into_response()) } diff --git a/src/routes/tests.rs b/src/routes/tests.rs index 9b486e5..ddc3637 100644 --- a/src/routes/tests.rs +++ b/src/routes/tests.rs @@ -319,7 +319,8 @@ async fn root_serves_embedded_rustbox_ui() { 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")); + assert!(html.contains("assets/app.")); + assert!(html.contains(".js")); } #[tokio::test] @@ -386,7 +387,8 @@ async fn base_path_serves_the_same_embedded_ui() { 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")); + assert!(html.contains("/mail/assets/app.")); + assert!(html.contains(".js")); } #[tokio::test] diff --git a/src/routes/trusted_senders.rs b/src/routes/trusted_senders.rs new file mode 100644 index 0000000..a0365cf --- /dev/null +++ b/src/routes/trusted_senders.rs @@ -0,0 +1,62 @@ +use std::sync::Arc; + +use axum::extract::Path; +use axum::response::{IntoResponse, Response}; +use axum::{Extension, Json}; +use serde::Deserialize; + +use crate::auth::session::SessionState; +use crate::config::AppConfig; +use crate::db; +use crate::error::AppError; + +#[derive(Deserialize)] +pub struct TrustSenderRequest { + pub email: String, +} + +fn normalize_email(value: &str) -> Result { + let email = value.trim().to_ascii_lowercase(); + if email.is_empty() + || email.len() > 320 + || !email.contains('@') + || email.chars().any(char::is_whitespace) + { + return Err(AppError::BadRequest("Invalid sender email".to_string())); + } + Ok(email) +} + +pub async fn list_trusted_senders( + 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 senders = db::trusted_senders::list(&conn).map_err(AppError::InternalError)?; + Ok(Json(serde_json::json!({ "senders": senders })).into_response()) +} + +pub async fn add_trusted_sender( + Extension(session): Extension, + Extension(config): Extension>, + Json(body): Json, +) -> Result { + let email = normalize_email(&body.email)?; + let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + db::trusted_senders::add(&conn, &email).map_err(AppError::InternalError)?; + Ok(Json(serde_json::json!({ "email": email, "trusted": true })).into_response()) +} + +pub async fn remove_trusted_sender( + Extension(session): Extension, + Extension(config): Extension>, + Path(email): Path, +) -> Result { + let email = normalize_email(&email)?; + let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) + .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; + db::trusted_senders::remove(&conn, &email).map_err(AppError::InternalError)?; + Ok(Json(serde_json::json!({ "email": email, "trusted": false })).into_response()) +} diff --git a/src/routes/unsubscribe.rs b/src/routes/unsubscribe.rs index 1dc8a56..8139ef6 100644 --- a/src/routes/unsubscribe.rs +++ b/src/routes/unsubscribe.rs @@ -229,7 +229,7 @@ async fn post_one_click_to_address( format!("{host}:{port}") }; let request = format!( - "POST {path} HTTP/1.1\r\nHost: {host_header}\r\nUser-Agent: RustBox/0.0.1\r\nContent-Type: application/x-www-form-urlencoded\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{ONE_CLICK_BODY}", + "POST {path} HTTP/1.1\r\nHost: {host_header}\r\nUser-Agent: RustBox/0.0.2\r\nContent-Type: application/x-www-form-urlencoded\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{ONE_CLICK_BODY}", ONE_CLICK_BODY.len() ); diff --git a/src/sieve/error.rs b/src/sieve/error.rs index d314a83..48c9854 100644 --- a/src/sieve/error.rs +++ b/src/sieve/error.rs @@ -29,7 +29,7 @@ impl fmt::Display for SieveError { write!(f, "ManageSieve server rejected the command: {msg}") } Self::Timeout => write!(f, "ManageSieve operation timed out"), - Self::ScriptTooLarge => write!(f, "Sieve script exceeds the RustBox 0.0.1 size limit"), + Self::ScriptTooLarge => write!(f, "Sieve script exceeds the RustBox 0.0.2 size limit"), } } } diff --git a/src/ui.rs b/src/ui.rs index f089908..b5427d8 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -8,11 +8,12 @@ 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 APP_JS: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/rustbox-ui.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 ASSET_ID: &str = env!("RUSTBOX_ASSET_ID"); const FAVICON: &str = r##""##; @@ -45,9 +46,25 @@ pub async fn serve_ui(uri: Uri, Extension(config): Extension>) -> .trim_start_matches('/') }; + let versioned_css = format!("assets/app.{ASSET_ID}.css"); + let versioned_js = format!("assets/app.{ASSET_ID}.js"); + if relative == versioned_css.as_str() { + return bytes_response( + "text/css; charset=utf-8", + "public, max-age=31536000, immutable", + APP_CSS, + ); + } + if relative == versioned_js.as_str() { + return bytes_response( + "text/javascript; charset=utf-8", + "public, max-age=31536000, immutable", + APP_JS, + ); + } + 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. + // Keep unversioned aliases non-cacheable for compatibility with old bookmarks/proxies. "assets/app.css" => bytes_response("text/css; charset=utf-8", "no-store", APP_CSS), "assets/app.js" => bytes_response("text/javascript; charset=utf-8", "no-store", APP_JS), "assets/icons.svg" => bytes_response("image/svg+xml", "no-store", ICONS), @@ -76,6 +93,7 @@ pub async fn serve_ui(uri: Uri, Extension(config): Extension>) -> let html = INDEX_HTML .replace("__RUSTBOX_BASE_PATH__", base_path) .replace("__RUSTBOX_ASSET_PREFIX__", asset_prefix) + .replace("__RUSTBOX_ASSET_ID__", ASSET_ID) .replace("__RUSTBOX_VERSION__", env!("CARGO_PKG_VERSION")) .replace("__RUSTBOX_OXI_BASE__", OXI_BASE.trim()); ( diff --git a/src/web/app.js b/src/web/app.js deleted file mode 100644 index 14b6249..0000000 --- a/src/web/app.js +++ /dev/null @@ -1,7050 +0,0 @@ -(() => { - "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/20-shell.css b/src/web/css/20-shell.css index 7d3e12e..fad90b5 100644 --- a/src/web/css/20-shell.css +++ b/src/web/css/20-shell.css @@ -233,6 +233,10 @@ display: none; } +.folder-drawer-scrim { + display: none; +} + .search-filter-button { width: 24px; height: 24px; diff --git a/src/web/css/30-mail.css b/src/web/css/30-mail.css index 3ee8854..f318524 100644 --- a/src/web/css/30-mail.css +++ b/src/web/css/30-mail.css @@ -324,6 +324,73 @@ font-size: 12px; } +.sender-line { + display: flex; + align-items: center; + gap: 5px; + min-width: 0; +} + +.sender-line #reader-from { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.address-contact-button { + flex: 0 0 auto; + width: 24px; + height: 24px; + display: inline-grid; + place-items: center; + padding: 0; + border: 0; + border-radius: var(--radius-control); + background: transparent; + color: var(--muted); + font-size: 10px; +} + +.address-contact-button:hover, +.address-contact-button:focus-visible { + background: var(--hover); + color: var(--text); +} + +.address-contact-button svg { + width: 13px; + height: 13px; +} + +.reader-recipients { + display: grid; + gap: 2px; + min-width: 0; +} + +.recipient-line { + display: flex; + align-items: center; + gap: 4px; + min-width: 0; + color: var(--muted); + font-size: 10px; +} + +.recipient-line>span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.recipient-line .address-contact-button { + width: 21px; + height: 21px; + grid-template-columns: 12px 5px; +} + .sender span, .message-meta time { color: var(--muted); @@ -1032,4 +1099,35 @@ html[data-toolbar-density="spacious"] .reader-toolbar { .bulk-toolbar .move-control select { width: 116px; } +} + +@media (max-width: 820px) { + .folder-row { + grid-template-columns: 32px minmax(0, 1fr); + } + + .folder-expander { + width: 32px; + height: 34px; + } + + .folder-expander svg { + width: 15px; + height: 15px; + } + + .folder-row .folder-button { + min-height: 34px; + } + + .message-meta { + align-items: start; + } +} + +.attachment-preview-body video { + width: 100%; + height: 100%; + max-height: 72dvh; + background: #000; } \ No newline at end of file diff --git a/src/web/css/40-compose.css b/src/web/css/40-compose.css index 345abbc..23424f4 100644 --- a/src/web/css/40-compose.css +++ b/src/web/css/40-compose.css @@ -762,14 +762,30 @@ } @media (max-width: 560px) { - - .compose-addresses>label, - .compose-options-row { - grid-template-columns: 1fr; - gap: 3px; + .compose-addresses { + gap: 7px; + max-height: min(44dvh, 390px); + padding: 8px; + } + + .compose-addresses>label { + grid-template-columns: 44px minmax(0, 1fr); + gap: 7px; + min-height: 38px; + align-items: center; + } + + .compose-addresses>label>span { + text-align: right; + padding-top: 0; + line-height: 1.2; + } + + .compose-options-row { + grid-template-columns: 1fr; + gap: 4px; } - .compose-addresses>label>span, .compose-options-row>span { text-align: left; padding-top: 0; @@ -783,4 +799,14 @@ max-height: 112px; overflow: auto; } -} \ No newline at end of file +} +.compose-response-control { + min-width: 118px; + max-width: 190px; +} + +.compose-response-control select { + min-height: 28px; + padding-top: 2px; + padding-bottom: 2px; +} diff --git a/src/web/css/50-settings.css b/src/web/css/50-settings.css index 550b043..d32db16 100644 --- a/src/web/css/50-settings.css +++ b/src/web/css/50-settings.css @@ -2357,4 +2357,40 @@ kbd { align-items: flex-start; flex-direction: column } +} + +.response-template-heading { + margin-top: 18px; +} + +.response-template-list { + display: grid; + gap: 6px; +} + +.response-template-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 8px 10px; + border: 1px solid var(--line); + border-radius: var(--radius-control); + background: var(--panel-2); +} + +.response-template-meta { + min-width: 0; + display: grid; + gap: 2px; +} + +.response-template-meta strong { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.response-template-meta span { + font-size: 10px; } \ No newline at end of file diff --git a/src/web/css/55-roundcube-skins.css b/src/web/css/55-roundcube-skins.css new file mode 100644 index 0000000..315863b --- /dev/null +++ b/src/web/css/55-roundcube-skins.css @@ -0,0 +1,434 @@ +/* Roundcube-compatible visual skins. + Palette values are aligned to Roundcube Classic and Elastic upstream CSS. + Classic never shipped an official dark skin; the dark block is a contrast-preserving adaptation. */ + +/* ---------- Roundcube Classic ---------- */ +html[data-interface-skin="roundcube-classic"] { + --font-sans: "Lucida Grande", Verdana, Arial, Helvetica, sans-serif; + --font-mono: Monaco, "Courier New", monospace; + --radius-control: 0px; + --radius-panel: 0px; +} + +html[data-interface-skin="roundcube-classic"][data-theme="light"] { + color-scheme: light; + --bg: #f6f6f6; + --panel: #ffffff; + --panel-2: #f2f2f2; + --panel-3: #ebebeb; + --sidebar: #ffffff; + --line: #ebebeb; + --line-strong: #999999; + --text: #000000; + --muted: #666666; + --subtle: #888888; + --accent: #cc3333; + --accent-strong: #b32323; + --accent-ink: #ffffff; + --danger: #cc0000; + --warning: #8a6700; + --success: #4b7f3e; + --field: #ffffff; + --selected: #cc3333; + --hover: #f2f2f2; + --shadow: rgba(0, 0, 0, .22); + --folder-bg: #ffffff; + --message-bg: #ffffff; + --reader-bg: #ffffff; +} + +html[data-interface-skin="roundcube-classic"][data-theme="dark"] { + color-scheme: dark; + --bg: #191919; + --panel: #242424; + --panel-2: #2e2e2e; + --panel-3: #363636; + --sidebar: #202020; + --line: #383838; + --line-strong: #707070; + --text: #f2f2f2; + --muted: #b7b7b7; + --subtle: #929292; + --accent: #d34b4b; + --accent-strong: #e06060; + --accent-ink: #ffffff; + --danger: #ff6666; + --warning: #d2aa4b; + --success: #83ad76; + --field: #1d1d1d; + --selected: #9e3333; + --hover: #303030; + --shadow: rgba(0, 0, 0, .45); + --folder-bg: #202020; + --message-bg: #242424; + --reader-bg: #242424; +} + +html[data-interface-skin="roundcube-classic"] body { + font-family: var(--font-sans); + letter-spacing: 0; +} + +html[data-interface-skin="roundcube-classic"] .appbar, +html[data-interface-skin="roundcube-classic"] .pane-toolbar, +html[data-interface-skin="roundcube-classic"] .reader-toolbar, +html[data-interface-skin="roundcube-classic"] .dialog-header { + box-shadow: none; +} + +html[data-interface-skin="roundcube-classic"][data-theme="light"] .appbar, +html[data-interface-skin="roundcube-classic"][data-theme="light"] .pane-toolbar, +html[data-interface-skin="roundcube-classic"][data-theme="light"] .reader-toolbar, +html[data-interface-skin="roundcube-classic"][data-theme="light"] .dialog-header, +html[data-interface-skin="roundcube-classic"][data-theme="light"] .section-heading { + background: linear-gradient(#f4f4f4, #cccccc); + border-color: #999999; + color: #333333; +} + +html[data-interface-skin="roundcube-classic"][data-theme="dark"] .appbar, +html[data-interface-skin="roundcube-classic"][data-theme="dark"] .pane-toolbar, +html[data-interface-skin="roundcube-classic"][data-theme="dark"] .reader-toolbar, +html[data-interface-skin="roundcube-classic"][data-theme="dark"] .dialog-header, +html[data-interface-skin="roundcube-classic"][data-theme="dark"] .section-heading { + background: linear-gradient(#3a3a3a, #292929); + border-color: #666666; + color: #f2f2f2; +} + +html[data-interface-skin="roundcube-classic"] .section-heading { + min-height: 22px; + margin: 7px -10px 3px; + padding: 3px 8px; + border-top: 1px solid var(--line-strong); + border-bottom: 1px solid var(--line-strong); + font-size: 10px; + letter-spacing: 0; + text-transform: none; +} + +html[data-interface-skin="roundcube-classic"] input, +html[data-interface-skin="roundcube-classic"] select, +html[data-interface-skin="roundcube-classic"] textarea, +html[data-interface-skin="roundcube-classic"] .search-field { + border-color: var(--line-strong); + border-radius: 0; + box-shadow: none; +} + +html[data-interface-skin="roundcube-classic"] input:focus, +html[data-interface-skin="roundcube-classic"] select:focus, +html[data-interface-skin="roundcube-classic"] textarea:focus, +html[data-interface-skin="roundcube-classic"] [contenteditable="true"]:focus, +html[data-interface-skin="roundcube-classic"] .search-field:focus-within { + border-color: #cc3333; + box-shadow: 0 0 0 1px #cc3333; +} + +html[data-interface-skin="roundcube-classic"] .button, +html[data-interface-skin="roundcube-classic"] .appearance-preset { + border-radius: 0; + box-shadow: none; +} + +html[data-interface-skin="roundcube-classic"][data-theme="light"] .button, +html[data-interface-skin="roundcube-classic"][data-theme="light"] .button.primary { + border-color: #a4a4a4; + background: linear-gradient(#ffffff, #e2e2e2); + color: #333333; +} + +html[data-interface-skin="roundcube-classic"][data-theme="light"] .button:hover, +html[data-interface-skin="roundcube-classic"][data-theme="light"] .button.primary:hover { + border-color: #777777; + background: linear-gradient(#ffffff, #d5d5d5); +} + +html[data-interface-skin="roundcube-classic"][data-theme="dark"] .button, +html[data-interface-skin="roundcube-classic"][data-theme="dark"] .button.primary { + border-color: #6f6f6f; + background: linear-gradient(#3c3c3c, #292929); + color: #f2f2f2; +} + +html[data-interface-skin="roundcube-classic"] .button.danger, +html[data-interface-skin="roundcube-classic"] a { + color: var(--danger); +} + +html[data-interface-skin="roundcube-classic"] .folder-button { + border-radius: 0; + min-height: var(--sidebar-item-height); + padding-top: 2px; + padding-bottom: 2px; +} + +html[data-interface-skin="roundcube-classic"] .folder-button.active { + background: #929292; + border-color: #929292; + color: #ffffff; + font-weight: 700; +} + +html[data-interface-skin="roundcube-classic"] .folder-button.active .folder-count, +html[data-interface-skin="roundcube-classic"] .folder-button.active svg { + color: #ffffff; +} + +html[data-interface-skin="roundcube-classic"] .message-row { + border-bottom-color: var(--line); + border-radius: 0; + box-shadow: none; +} + +html[data-interface-skin="roundcube-classic"] .message-row.active, +html[data-interface-skin="roundcube-classic"] .message-row.selected { + background: var(--selected); + color: #ffffff; +} + +html[data-interface-skin="roundcube-classic"] .message-row.active .message-date, +html[data-interface-skin="roundcube-classic"] .message-row.active .message-snippet, +html[data-interface-skin="roundcube-classic"] .message-row.active .message-state, +html[data-interface-skin="roundcube-classic"] .message-row.selected .message-date, +html[data-interface-skin="roundcube-classic"] .message-row.selected .message-snippet { + color: #ffffff; + border-color: rgba(255,255,255,.8); +} + +html[data-interface-skin="roundcube-classic"] .settings-tab { + border-radius: 0; +} + +html[data-interface-skin="roundcube-classic"] .settings-tab.active { + background: #929292; + color: #ffffff; +} + +html[data-interface-skin="roundcube-classic"] .dialog-card, +html[data-interface-skin="roundcube-classic"] .login-panel, +html[data-interface-skin="roundcube-classic"] .appearance-preset, +html[data-interface-skin="roundcube-classic"] .diagnostic-card { + border-radius: 0; +} + +/* ---------- Roundcube Elastic ---------- */ +html[data-interface-skin="roundcube-elastic"] { + --font-sans: Roboto, "Segoe UI", Arial, sans-serif; + --radius-control: .3rem; + --radius-panel: .3rem; +} + +html[data-interface-skin="roundcube-elastic"][data-theme="light"] { + color-scheme: light; + --bg: #ffffff; + --panel: #ffffff; + --panel-2: #f4f4f4; + --panel-3: #fbfbfb; + --sidebar: #ffffff; + --line: #dddddd; + --line-strong: #ced4da; + --text: #2b3438; + --muted: #737f84; + --subtle: #879298; + --accent: #37beff; + --accent-strong: #00acff; + --accent-ink: #ffffff; + --danger: #ff5552; + --warning: #a27d00; + --success: #41b849; + --field: #ffffff; + --selected: #ebf8ff; + --hover: #f4fafe; + --shadow: rgba(22, 27, 29, .18); + --folder-bg: #ffffff; + --message-bg: #ffffff; + --reader-bg: #ffffff; +} + +html[data-interface-skin="roundcube-elastic"][data-theme="dark"] { + color-scheme: dark; + --bg: #21292c; + --panel: #21292c; + --panel-2: #2f3a3f; + --panel-3: #283236; + --sidebar: #21292c; + --line: #3a494e; + --line-strong: #4d6066; + --text: #c5d1d3; + --muted: #9dafb3; + --subtle: #85999e; + --accent: #37beff; + --accent-strong: #55c7ff; + --accent-ink: #07181f; + --danger: #ff7774; + --warning: #ffd452; + --success: #65c86c; + --field: #21292c; + --selected: #374549; + --hover: #2c373a; + --shadow: rgba(0, 0, 0, .42); + --folder-bg: #21292c; + --message-bg: #21292c; + --reader-bg: #21292c; +} + +html[data-interface-skin="roundcube-elastic"] body { + font-family: var(--font-sans); + line-height: 1.5; +} + +html[data-interface-skin="roundcube-elastic"] .appbar, +html[data-interface-skin="roundcube-elastic"] .pane-toolbar, +html[data-interface-skin="roundcube-elastic"] .reader-toolbar, +html[data-interface-skin="roundcube-elastic"] .dialog-header { + background: #f4f4f4; + box-shadow: none; +} + +html[data-interface-skin="roundcube-elastic"][data-theme="dark"] .appbar, +html[data-interface-skin="roundcube-elastic"][data-theme="dark"] .pane-toolbar, +html[data-interface-skin="roundcube-elastic"][data-theme="dark"] .reader-toolbar, +html[data-interface-skin="roundcube-elastic"][data-theme="dark"] .dialog-header { + background: #2f3a3f; +} + +html[data-interface-skin="roundcube-elastic"] input, +html[data-interface-skin="roundcube-elastic"] select, +html[data-interface-skin="roundcube-elastic"] textarea, +html[data-interface-skin="roundcube-elastic"] .search-field { + border-color: var(--line-strong); + box-shadow: none; +} + +html[data-interface-skin="roundcube-elastic"][data-theme="dark"] input, +html[data-interface-skin="roundcube-elastic"][data-theme="dark"] select, +html[data-interface-skin="roundcube-elastic"][data-theme="dark"] textarea, +html[data-interface-skin="roundcube-elastic"][data-theme="dark"] .search-field { + border-color: #7c949c; +} + +html[data-interface-skin="roundcube-elastic"] input:focus, +html[data-interface-skin="roundcube-elastic"] select:focus, +html[data-interface-skin="roundcube-elastic"] textarea:focus, +html[data-interface-skin="roundcube-elastic"] [contenteditable="true"]:focus, +html[data-interface-skin="roundcube-elastic"] .search-field:focus-within { + border-color: #37beff; + box-shadow: 0 0 0 1px #37beff; +} + +html[data-interface-skin="roundcube-elastic"] .button.primary { + border-color: #37beff; + background: #37beff; + color: #ffffff; +} + +html[data-interface-skin="roundcube-elastic"] .button.primary:hover { + border-color: #00acff; + background: #00acff; +} + +html[data-interface-skin="roundcube-elastic"][data-theme="dark"] .button.primary { + border-color: #006a9d; + background: #006a9d; + color: #ffffff; +} + +html[data-interface-skin="roundcube-elastic"][data-theme="dark"] .button.primary:hover { + border-color: #007fb9; + background: #007fb9; +} + +html[data-interface-skin="roundcube-elastic"] .folder-button.active, +html[data-interface-skin="roundcube-elastic"] .message-row.active, +html[data-interface-skin="roundcube-elastic"] .message-row.selected, +html[data-interface-skin="roundcube-elastic"] .settings-tab.active { + background: var(--selected); + border-color: var(--line); + color: var(--text); +} + +html[data-interface-skin="roundcube-elastic"] .folder-count.unread, +html[data-interface-skin="roundcube-elastic"] .message-row.unread .message-state, +html[data-interface-skin="roundcube-elastic"] .brand-icon { + color: #37beff; +} + +html[data-interface-skin="roundcube-elastic"] .message-row.unread .message-state { + background: #37beff; + border-color: #37beff; +} + +html[data-interface-skin="roundcube-elastic"] .section-heading { + color: var(--muted); + letter-spacing: .04em; +} + +html[data-interface-skin="roundcube-elastic"] .appearance-preset:focus-visible, +html[data-interface-skin="roundcube-elastic"] .appearance-preset:hover { + border-color: #37beff; +} + +/* Mobile drawer follows the active skin and uses Roundcube Elastic's back affordance. */ +@media (max-width: 820px) { + .folder-drawer-header.mobile-only { + display: grid; + width: auto; + min-height: 42px; + grid-template-columns: 34px minmax(0, 1fr) 34px; + align-items: center; + gap: 6px; + margin: -10px -10px 8px; + padding: 4px 8px; + border-bottom: 1px solid var(--line); + background: var(--panel-2); + } + + .folder-drawer-header strong { + grid-column: 2; + justify-self: center; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .folder-drawer-close { + grid-column: 1; + position: static; + width: 34px; + height: 34px; + background: transparent; + border: 0; + color: var(--text); + } + + .folder-drawer-close svg { + width: 20px; + height: 20px; + stroke-width: 2; + } + + .folder-pane .compose-button { + padding-right: var(--button-pad-x); + } + + html[data-interface-skin="roundcube-classic"] .folder-drawer-header { + min-height: 32px; + background: linear-gradient(#f4f4f4, #cccccc); + border-color: #999999; + } + + html[data-interface-skin="roundcube-classic"][data-theme="dark"] .folder-drawer-header { + background: linear-gradient(#3a3a3a, #292929); + border-color: #666666; + } + + html[data-interface-skin="roundcube-elastic"] .folder-drawer-header { + min-height: 58px; + background: #f4f4f4; + } + + html[data-interface-skin="roundcube-elastic"][data-theme="dark"] .folder-drawer-header { + background: #2f3a3f; + } +} diff --git a/src/web/css/56-interface-styles.css b/src/web/css/56-interface-styles.css new file mode 100644 index 0000000..26f3035 --- /dev/null +++ b/src/web/css/56-interface-styles.css @@ -0,0 +1,362 @@ +/* Fixed interface styles for RustBox 0.0.2. + These styles intentionally ignore RustBox custom palette variables. */ + +/* ---------- Roundcube Larry ---------- */ +html[data-interface-skin="roundcube-larry"] { + --font-sans: "Lucida Grande", "Segoe UI", Arial, Helvetica, sans-serif; + --radius-control: 3px; + --radius-panel: 4px; +} + +html[data-interface-skin="roundcube-larry"][data-theme="light"] { + color-scheme: light; + --bg: #e9e9e9; + --panel: #ffffff; + --panel-2: #f2f2f2; + --panel-3: #e6e6e6; + --sidebar: #f0f0f0; + --line: #c7c7c7; + --line-strong: #9a9a9a; + --text: #333333; + --muted: #666666; + --subtle: #858585; + --accent: #005d76; + --accent-strong: #00475b; + --accent-ink: #ffffff; + --danger: #b73838; + --warning: #8b6b15; + --success: #4f7b43; + --field: #ffffff; + --selected: #c7e3ef; + --hover: #e3f1f6; + --shadow: rgba(0, 0, 0, .22); + --folder-bg: #f0f0f0; + --message-bg: #ffffff; + --reader-bg: #ffffff; +} + +html[data-interface-skin="roundcube-larry"][data-theme="dark"] { + color-scheme: dark; + --bg: #1d2225; + --panel: #252c30; + --panel-2: #30383d; + --panel-3: #384146; + --sidebar: #20272a; + --line: #465158; + --line-strong: #65737a; + --text: #edf2f4; + --muted: #b6c0c5; + --subtle: #8f9ba1; + --accent: #5aa9c2; + --accent-strong: #73bdd4; + --accent-ink: #07171c; + --danger: #ef7777; + --warning: #d6b85d; + --success: #82b578; + --field: #1d2225; + --selected: #314f5a; + --hover: #2d3a3f; + --shadow: rgba(0, 0, 0, .42); + --folder-bg: #20272a; + --message-bg: #252c30; + --reader-bg: #252c30; +} + +html[data-interface-skin="roundcube-larry"] .appbar, +html[data-interface-skin="roundcube-larry"] .pane-toolbar, +html[data-interface-skin="roundcube-larry"] .reader-toolbar, +html[data-interface-skin="roundcube-larry"] .dialog-header { + background: #3c3c3c; + border-color: #202020; + color: #ffffff; + box-shadow: none; +} + +html[data-interface-skin="roundcube-larry"][data-theme="dark"] .appbar, +html[data-interface-skin="roundcube-larry"][data-theme="dark"] .pane-toolbar, +html[data-interface-skin="roundcube-larry"][data-theme="dark"] .reader-toolbar, +html[data-interface-skin="roundcube-larry"][data-theme="dark"] .dialog-header { + background: #171b1d; + border-color: #3b4449; +} + +html[data-interface-skin="roundcube-larry"] .folder-button.active, +html[data-interface-skin="roundcube-larry"] .message-row.active, +html[data-interface-skin="roundcube-larry"] .message-row.selected, +html[data-interface-skin="roundcube-larry"] .settings-tab.active { + background: var(--selected); + border-color: color-mix(in srgb, var(--accent) 45%, var(--line)); + color: var(--text); +} + +html[data-interface-skin="roundcube-larry"] .button.primary { + background: var(--accent); + border-color: var(--accent-strong); + color: #ffffff; +} + +html[data-interface-skin="roundcube-larry"] input:focus, +html[data-interface-skin="roundcube-larry"] select:focus, +html[data-interface-skin="roundcube-larry"] textarea:focus, +html[data-interface-skin="roundcube-larry"] [contenteditable="true"]:focus, +html[data-interface-skin="roundcube-larry"] .search-field:focus-within { + border-color: var(--accent); + box-shadow: 0 0 0 1px var(--accent); +} + +/* ---------- Clean ---------- */ +html[data-interface-skin="clean"][data-theme="light"] { + color-scheme: light; + --bg: #f5f7fa; + --panel: #ffffff; + --panel-2: #f8fafc; + --panel-3: #eef2f7; + --sidebar: #f8fafc; + --line: #dfe5ec; + --line-strong: #c8d1dc; + --text: #17202a; + --muted: #667281; + --subtle: #8793a1; + --accent: #4d75e6; + --accent-strong: #365fcf; + --accent-ink: #ffffff; + --danger: #c94b50; + --warning: #98721a; + --success: #4c8553; + --field: #ffffff; + --selected: #e8eefc; + --hover: #f1f4fa; + --shadow: rgba(30, 46, 66, .12); + --folder-bg: #f8fafc; + --message-bg: #ffffff; + --reader-bg: #ffffff; +} + +html[data-interface-skin="clean"][data-theme="dark"] { + color-scheme: dark; + --bg: #15181d; + --panel: #1b1f25; + --panel-2: #20262d; + --panel-3: #272e36; + --sidebar: #181c21; + --line: #303842; + --line-strong: #46515e; + --text: #eef2f6; + --muted: #a8b2bf; + --subtle: #818d9b; + --accent: #7d9bf2; + --accent-strong: #97aff6; + --accent-ink: #0c1429; + --danger: #ec777c; + --warning: #d7b35d; + --success: #84bd8a; + --field: #171b20; + --selected: #283653; + --hover: #222a34; + --shadow: rgba(0, 0, 0, .35); + --folder-bg: #181c21; + --message-bg: #1b1f25; + --reader-bg: #1b1f25; +} + +html[data-interface-skin="clean"] .appbar, +html[data-interface-skin="clean"] .pane-toolbar, +html[data-interface-skin="clean"] .reader-toolbar, +html[data-interface-skin="clean"] .dialog-header { + box-shadow: none; +} + +html[data-interface-skin="clean"] .folder-button.active, +html[data-interface-skin="clean"] .message-row.active, +html[data-interface-skin="clean"] .message-row.selected, +html[data-interface-skin="clean"] .settings-tab.active { + background: var(--selected); + color: var(--text); +} + +html[data-interface-skin="clean"] .button.primary { + background: var(--accent); + border-color: var(--accent); + color: #ffffff; +} + +/* ---------- Graphite ---------- */ +html[data-interface-skin="graphite"][data-theme="light"] { + color-scheme: light; + --bg: #e4e6e8; + --panel: #f6f7f8; + --panel-2: #eceef0; + --panel-3: #e1e4e7; + --sidebar: #dde0e3; + --line: #c5c9cd; + --line-strong: #9ea4aa; + --text: #22272b; + --muted: #60686e; + --subtle: #7f878d; + --accent: #5e6972; + --accent-strong: #424b53; + --accent-ink: #ffffff; + --danger: #a94d4d; + --warning: #7c681f; + --success: #557556; + --field: #ffffff; + --selected: #d1d5d9; + --hover: #e5e8eb; + --shadow: rgba(24, 28, 31, .18); + --folder-bg: #dde0e3; + --message-bg: #f6f7f8; + --reader-bg: #f6f7f8; +} + +html[data-interface-skin="graphite"][data-theme="dark"] { + color-scheme: dark; + --bg: #141619; + --panel: #1a1d21; + --panel-2: #20242a; + --panel-3: #292e34; + --sidebar: #171a1e; + --line: #343a41; + --line-strong: #4b535c; + --text: #e7eaed; + --muted: #a3aab1; + --subtle: #7d858d; + --accent: #98a1aa; + --accent-strong: #b2bac1; + --accent-ink: #121416; + --danger: #dc7777; + --warning: #c5aa5c; + --success: #81a983; + --field: #16191d; + --selected: #30363d; + --hover: #24292f; + --shadow: rgba(0, 0, 0, .42); + --folder-bg: #171a1e; + --message-bg: #1a1d21; + --reader-bg: #1a1d21; +} + +html[data-interface-skin="graphite"] .appbar, +html[data-interface-skin="graphite"] .pane-toolbar, +html[data-interface-skin="graphite"] .reader-toolbar, +html[data-interface-skin="graphite"] .dialog-header { + background: var(--panel-2); + box-shadow: none; +} + +html[data-interface-skin="graphite"] .folder-button.active, +html[data-interface-skin="graphite"] .message-row.active, +html[data-interface-skin="graphite"] .message-row.selected, +html[data-interface-skin="graphite"] .settings-tab.active { + background: var(--selected); + color: var(--text); +} + +html[data-interface-skin="graphite"] .button.primary { + background: var(--accent-strong); + border-color: var(--accent-strong); + color: #ffffff; +} + +/* ---------- High contrast ---------- */ +html[data-interface-skin="high-contrast"] { + --radius-control: 2px; + --radius-panel: 2px; +} + +html[data-interface-skin="high-contrast"][data-theme="light"] { + color-scheme: light; + --bg: #ffffff; + --panel: #ffffff; + --panel-2: #f2f2f2; + --panel-3: #e6e6e6; + --sidebar: #ffffff; + --line: #111111; + --line-strong: #000000; + --text: #000000; + --muted: #303030; + --subtle: #505050; + --accent: #004fc4; + --accent-strong: #003c96; + --accent-ink: #ffffff; + --danger: #b00020; + --warning: #6e5200; + --success: #126b24; + --field: #ffffff; + --selected: #ffe500; + --hover: #eaf2ff; + --shadow: rgba(0, 0, 0, .28); + --folder-bg: #ffffff; + --message-bg: #ffffff; + --reader-bg: #ffffff; +} + +html[data-interface-skin="high-contrast"][data-theme="dark"] { + color-scheme: dark; + --bg: #000000; + --panel: #000000; + --panel-2: #111111; + --panel-3: #1c1c1c; + --sidebar: #000000; + --line: #d8d8d8; + --line-strong: #ffffff; + --text: #ffffff; + --muted: #e0e0e0; + --subtle: #bdbdbd; + --accent: #64c8ff; + --accent-strong: #9adcff; + --accent-ink: #000000; + --danger: #ff808f; + --warning: #ffe066; + --success: #7ee08c; + --field: #000000; + --selected: #675d00; + --hover: #18222a; + --shadow: rgba(255, 255, 255, .12); + --folder-bg: #000000; + --message-bg: #000000; + --reader-bg: #000000; +} + +html[data-interface-skin="high-contrast"] .appbar, +html[data-interface-skin="high-contrast"] .pane-toolbar, +html[data-interface-skin="high-contrast"] .reader-toolbar, +html[data-interface-skin="high-contrast"] .dialog-header, +html[data-interface-skin="high-contrast"] input, +html[data-interface-skin="high-contrast"] select, +html[data-interface-skin="high-contrast"] textarea, +html[data-interface-skin="high-contrast"] .button, +html[data-interface-skin="high-contrast"] .message-row, +html[data-interface-skin="high-contrast"] .folder-button { + border-color: var(--line-strong); +} + +html[data-interface-skin="high-contrast"] .folder-button.active, +html[data-interface-skin="high-contrast"] .message-row.active, +html[data-interface-skin="high-contrast"] .message-row.selected, +html[data-interface-skin="high-contrast"] .settings-tab.active { + background: var(--selected); + color: var(--text); + outline: 2px solid var(--line-strong); + outline-offset: -2px; +} + +html[data-interface-skin="high-contrast"] .button.primary { + background: var(--accent); + border-color: var(--line-strong); + color: var(--accent-ink); + font-weight: 800; +} + +@media (max-width: 820px) { + html[data-interface-skin="roundcube-larry"] .folder-drawer-header { + min-height: 44px; + background: #3c3c3c; + border-color: #202020; + color: #ffffff; + } + + html[data-interface-skin="roundcube-larry"][data-theme="dark"] .folder-drawer-header { + background: #171b1d; + border-color: #3b4449; + } +} diff --git a/src/web/css/60-responsive.css b/src/web/css/60-responsive.css index 0b6df41..0587fe2 100644 --- a/src/web/css/60-responsive.css +++ b/src/web/css/60-responsive.css @@ -85,16 +85,35 @@ html[data-preview="bottom"] .reader-pane { top: 0; bottom: 0; left: 0; - width: 250px; + width: min(84vw, 320px); transform: translateX(-100%); transition: transform .15s ease; box-shadow: 12px 0 28px var(--shadow); } + .folder-drawer-scrim { + display: block; + position: absolute; + z-index: 19; + inset: 0; + border: 0; + padding: 0; + background: rgba(0, 0, 0, .38); + opacity: 0; + pointer-events: none; + transition: opacity .15s ease; + } + + .folder-drawer-scrim.open { + opacity: 1; + pointer-events: auto; + } + .folder-pane.open { transform: translateX(0); } + .message-pane { height: 100%; border-right: 0; diff --git a/src/web/css/manifest.txt b/src/web/css/manifest.txt index b6ac39d..40ca862 100644 --- a/src/web/css/manifest.txt +++ b/src/web/css/manifest.txt @@ -4,4 +4,6 @@ 30-mail.css 40-compose.css 50-settings.css +55-roundcube-skins.css +56-interface-styles.css 60-responsive.css diff --git a/src/web/index.html b/src/web/index.html index 337bc56..f2b6b2d 100644 --- a/src/web/index.html +++ b/src/web/index.html @@ -1,5 +1,6 @@ + @@ -7,24 +8,32 @@ RustBox - + +
    - + RustBox