v0.0.2
RustBox CI / rustbox (push) Waiting to run

This commit is contained in:
Mateusz Gruszczyński committed 2026-10-29 19:28:47 +01:00
1 parent d55640d37a
commit fff8521a17
67 files changed
+11014 -16761

No files matched your search

+16
View File
@@ -1,6 +1,22 @@
# RustBox changelog # 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 ### 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 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. - 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. - Added a persistent full-screen composer toggle.
Generated
+1 -1
View File
@@ -2957,7 +2957,7 @@ dependencies = [
[[package]] [[package]]
name = "rustbox" name = "rustbox"
version = "0.0.1" version = "0.0.2"
dependencies = [ dependencies = [
"async-imap", "async-imap",
"async-native-tls", "async-native-tls",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "rustbox" name = "rustbox"
version = "0.0.1" version = "0.0.2"
edition = "2024" edition = "2024"
rust-version = "1.88" rust-version = "1.88"
default-run = "rustbox" default-run = "rustbox"
+1 -1
View File
@@ -1,6 +1,6 @@
# RustBox attribution / notice # 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` - Upstream: `c0h1b4/oxi`
- Base snapshot: `7f1507c486b78cf33e54beb701dd460d3bd2bd5d` - Base snapshot: `7f1507c486b78cf33e54beb701dd460d3bd2bd5d`
+10 -8
View File
@@ -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`. RustBox is a webmail derived from `c0h1b4/oxi`. The consumed Oxi revision is recorded in `UPSTREAM_BASE`.
@@ -16,7 +16,9 @@ rustbox/
│ ├── ui.rs │ ├── ui.rs
│ └── web/ │ └── web/
│ ├── index.html │ ├── index.html
│ ├── app.js │ ├── js/
│ │ ├── manifest.txt
│ │ └── *.js
│ ├── icons.svg │ ├── icons.svg
│ └── css/ │ └── css/
├── migrations/ ├── migrations/
@@ -31,7 +33,7 @@ rustbox/
└── dev.sh └── 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 ## 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. 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`. 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. 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 ## ManageSieve
@@ -131,7 +133,7 @@ Initialize Git after extracting the release archive:
```bash ```bash
git init git init
git add . git add .
git commit -m "RustBox 0.0.1 baseline" git commit -m "RustBox 0.0.2 release"
./scripts/setup-upstream.sh ./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. 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/`.
+6 -3
View File
@@ -1,4 +1,4 @@
RustBox 0.0.1 source release RustBox 0.0.2 source release
Runtime/source model: Runtime/source model:
- one root Cargo package - one root Cargo package
@@ -19,7 +19,10 @@ Oxi tracking:
- Oxi frontend changes are reviewed and manually ported to src/web/ - Oxi frontend changes are reviewed and manually ported to src/web/
- docs/UI.md documents the browser UI/build conventions - docs/UI.md documents the browser UI/build conventions
Current 0.0.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 - persisted special folders and mailbox behavior preferences
- bulk IMAP actions, copy/move, folder mark-all-read/empty, message-to-folder drag/drop - 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 - 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 - 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 - 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 - 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 - 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 - exact-token thread matching and expanded keyboard selection/navigation shortcuts
- in-app attachment preview, clipboard CID image paste and upload progress - in-app attachment preview, clipboard CID image paste and upload progress
+78 -38
View File
@@ -1,35 +1,31 @@
use std::{env, fs, path::PathBuf}; use std::{
env, fs,
path::{Path, PathBuf},
};
fn main() { fn fnv1a64(parts: &[&[u8]]) -> u64 {
let root = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR")); let mut hash = 0xcbf29ce484222325u64;
let out = PathBuf::from(env::var_os("OUT_DIR").expect("OUT_DIR")); for part in parts {
let css_dir = root.join("src/web/css"); for byte in *part {
let manifest = css_dir.join("manifest.txt"); 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={}", manifest.display());
println!( let manifest_text = fs::read_to_string(manifest)
"cargo:rerun-if-changed={}", .unwrap_or_else(|error| panic!("{}: {error}", manifest.display()));
root.join("src/web/index.html").display() let mut bundle = String::from(banner);
); bundle.push('\n');
println!(
"cargo:rerun-if-changed={}",
root.join("src/web/app.js").display()
);
println!(
"cargo:rerun-if-changed={}",
root.join("src/web/icons.svg").display()
);
println!(
"cargo:rerun-if-changed={}",
root.join("src/web/i18n/en.json").display()
);
println!(
"cargo:rerun-if-changed={}",
root.join("src/web/i18n/pl.json").display()
);
let manifest_text = fs::read_to_string(&manifest).expect("read src/web/css/manifest.txt");
let mut bundle = String::from("/* RustBox UI bundle - generated by build.rs */\n");
for raw in manifest_text.lines() { for raw in manifest_text.lines() {
let name = raw.trim(); let name = raw.trim();
@@ -37,22 +33,66 @@ fn main() {
continue; continue;
} }
assert!( assert!(
name.ends_with(".css") name.ends_with(extension)
&& !name.contains('/') && !name.contains('/')
&& !name.contains('\\') && !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()); println!("cargo:rerun-if-changed={}", path.display());
let css = let source =
fs::read_to_string(&path).unwrap_or_else(|error| panic!("{}: {error}", path.display())); fs::read_to_string(&path).unwrap_or_else(|error| panic!("{}: {error}", path.display()));
bundle.push_str("\n/* "); bundle.push_str("\n/* ---- ");
bundle.push_str(name); bundle.push_str(name);
bundle.push_str(" */\n"); bundle.push_str(" ---- */\n");
bundle.push_str(&css); bundle.push_str(&source);
bundle.push('\n'); 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");
} }
+9 -1
View File
@@ -1,4 +1,4 @@
# RustBox 0.0.1 # RustBox 0.0.2
host = "127.0.0.1" host = "127.0.0.1"
port = 3001 port = 3001
environment = "production" environment = "production"
@@ -28,6 +28,14 @@ sieve_timeout_secs = 10
trusted_proxies = "127.0.0.1,::1" 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`. # Optional headers added to every outgoing message. Use `Header-Name: value`.
# RustBox rejects structural/MIME header overrides and CR/LF injection. # RustBox rejects structural/MIME header overrides and CR/LF injection.
outgoing_headers = [] outgoing_headers = []
+11 -8
View File
@@ -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 ## 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. - 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 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. - 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. - Sent-copy, delete-to-Trash, Archive and Junk actions use the configured special folders.
- Mark-all-read and empty-folder operations. - Mark-all-read and empty-folder operations.
- Message move and copy 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`. - 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. - 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. - 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. - 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 ### 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 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. - 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 ## 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. - 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. - 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. - 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. - 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 ## 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. - 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. - 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. - 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. - 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. - 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 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. - 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. - 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. - 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. - 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. - 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. - 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. - 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. - 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. - 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. - 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. - 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. - Message Print uses an in-page print frame instead of a popup and prints sanitized HTML or plain text with From/To/Date metadata.
+5
View File
@@ -21,3 +21,8 @@ RUSTBOX_CONFIG=./config.dev.toml ./target/release/rustbox --check-config
6. Package the repository without `target/`, `.dev/` or `.git/`. 6. Package the repository without `target/`, `.dev/` or `.git/`.
7. Generate SHA-256 sums for release archives. 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.
+2 -2
View File
@@ -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. 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 ### Search / notifications / diagnostics — 4
- [ ] Complete attachment content/metadata indexing policy and limits. - [ ] 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. - [ ] Richer per-account notification policy beyond global/per-folder controls.
- [ ] Background task/queue status plus recent-error/repair workflows. - [ ] Background task/queue status plus recent-error/repair workflows.
+20
View File
@@ -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.
+2 -2
View File
@@ -9,13 +9,13 @@ Design direction:
- dark, light and system themes; - dark, light and system themes;
- compact/comfortable density, preview-pane position and UI scale preferences; - compact/comfortable density, preview-pane position and UI scale preferences;
- a single embedded SVG icon sprite for interface actions; - 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. - 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. 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
`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. No Node.js, Bun, TypeScript, React or Next.js build step exists.
+21
View File
@@ -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.
+4
View File
@@ -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'))
);
+7
View File
@@ -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'))
);
+1
View File
@@ -0,0 +1 @@
ALTER TABLE display_preferences ADD COLUMN interface_skin TEXT NOT NULL DEFAULT 'rustbox';
@@ -0,0 +1 @@
ALTER TABLE response_templates ADD COLUMN format TEXT NOT NULL DEFAULT 'text';
+5
View File
@@ -14,6 +14,11 @@ if find "$ROOT" -type f \( -name '*.ts' -o -name '*.tsx' \) -print -quit | grep
exit 1 exit 1
fi 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 test --locked
cargo build --release --locked cargo build --release --locked
+32 -19
View File
@@ -1,8 +1,8 @@
use std::sync::Arc; use std::sync::Arc;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine; use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use dashmap::DashMap; use dashmap::DashMap;
use rand::RngCore; use rand::RngCore;
@@ -102,17 +102,8 @@ impl SessionStore {
smtp_tls: bool, smtp_tls: bool,
) -> (SessionId, AccountId) { ) -> (SessionId, AccountId) {
self.add_account_to_browser_with_timeout( self.add_account_to_browser_with_timeout(
browser_id, browser_id, email, password, user_hash, imap_host, imap_port, imap_tls, smtp_host,
email, smtp_port, smtp_tls, None,
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. /// Store a display-only User-Agent string for active-session diagnostics.
/// It is never used for authentication or authorization decisions. /// It is never used for authentication or authorization decisions.
pub fn set_user_agent(&self, account_id: &str, user_agent: String) { pub fn set_user_agent(&self, account_id: &str, user_agent: String) {
let Some(session_id) = self.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; return;
}; };
if let Some(mut session) = self.sessions.get_mut(&session_id) { 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. /// Revoke one session only when it belongs to the requested user.
pub fn remove_user_session(&self, user_hash: &str, account_id: &str) -> bool { pub fn remove_user_session(&self, user_hash: &str, account_id: &str) -> bool {
let Some(session_id) = self.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; return false;
}; };
let belongs_to_user = self let belongs_to_user = self
@@ -890,7 +889,11 @@ mod tests {
// --- Consistency 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( store.add_account_to_browser(
browser_id, browser_id,
email.into(), email.into(),
@@ -1231,7 +1234,11 @@ mod tests {
store.purge_expired(); 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!( assert!(
!store.account_to_session.contains_key(&account1), !store.account_to_session.contains_key(&account1),
"expired account mapping should be gone" "expired account mapping should be gone"
@@ -1299,7 +1306,11 @@ mod tests {
thread::sleep(Duration::from_millis(100)); 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!( assert!(
!store.account_to_session.contains_key(&account_id), !store.account_to_session.contains_key(&account_id),
@@ -1325,7 +1336,9 @@ mod tests {
); );
thread::sleep(Duration::from_millis(100)); 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"
);
} }
} }
+4 -1
View File
@@ -94,7 +94,10 @@ mod tests {
let user_dir = provision_user_data(data_dir, &user_hash).expect("provisioning failed"); let user_dir = provision_user_data(data_dir, &user_hash).expect("provisioning failed");
assert!(user_dir.exists(), "user directory should exist"); 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!( assert!(
user_dir.join("tantivy").is_dir(), user_dir.join("tantivy").is_dir(),
"tantivy/ subdirectory should exist" "tantivy/ subdirectory should exist"
+17
View File
@@ -121,6 +121,18 @@ pub struct AppConfig {
/// Disabled by default; public-key export is always available. /// Disabled by default; public-key export is always available.
#[serde(default)] #[serde(default)]
pub pgp_allow_private_export: bool, pub pgp_allow_private_export: bool,
/// Administrator defaults for special IMAP folders. Per-account settings override these.
#[serde(default)]
pub default_sent_folder: Option<String>,
#[serde(default)]
pub default_drafts_folder: Option<String>,
#[serde(default)]
pub default_trash_folder: Option<String>,
#[serde(default)]
pub default_junk_folder: Option<String>,
#[serde(default)]
pub default_archive_folder: Option<String>,
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
@@ -385,6 +397,11 @@ mod tests {
assert!(config.trusted_proxies.is_none()); assert!(config.trusted_proxies.is_none());
assert!(config.outgoing_headers.is_empty()); assert!(config.outgoing_headers.is_empty());
assert!(!config.pgp_allow_private_export); 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] #[test]
+311 -71
View File
@@ -53,6 +53,7 @@ pub struct DisplayPreferences {
pub quote_style: String, pub quote_style: String,
pub forward_mode: String, pub forward_mode: String,
pub spellcheck_enabled: bool, pub spellcheck_enabled: bool,
pub interface_skin: String,
pub updated_at: String, pub updated_at: String,
} }
@@ -109,9 +110,12 @@ pub struct UpdateDisplayPreferences {
pub quote_style: Option<String>, pub quote_style: Option<String>,
pub forward_mode: Option<String>, pub forward_mode: Option<String>,
pub spellcheck_enabled: Option<bool>, pub spellcheck_enabled: Option<bool>,
pub interface_skin: Option<String>,
} }
fn deserialize_animation_mode_field<'de, D>(deserializer: D) -> Result<Option<Option<String>>, D::Error> fn deserialize_animation_mode_field<'de, D>(
deserializer: D,
) -> Result<Option<Option<String>>, D::Error>
where where
D: Deserializer<'de>, D: Deserializer<'de>,
{ {
@@ -171,13 +175,14 @@ fn defaults() -> DisplayPreferences {
quote_style: "standard".into(), quote_style: "standard".into(),
forward_mode: "inline".into(), forward_mode: "inline".into(),
spellcheck_enabled: true, spellcheck_enabled: true,
interface_skin: "rustbox".into(),
updated_at: String::new(), updated_at: String::new(),
} }
} }
pub fn get_preferences(conn: &Connection) -> Result<DisplayPreferences, String> { pub fn get_preferences(conn: &Connection) -> Result<DisplayPreferences, String> {
let result = conn.query_row( 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 { |row| Ok(DisplayPreferences {
density: row.get(0)?, theme: row.get(1)?, language: row.get(2)?, compose_format: row.get(3)?, 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<DisplayPreferences, String>
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, 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)?, 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, 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 { match result {
@@ -201,89 +206,293 @@ pub fn get_preferences(conn: &Connection) -> Result<DisplayPreferences, String>
} }
fn enum_value(name: &str, value: &str, allowed: &[&str]) -> Result<(), String> { 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> { 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 { fn valid_hex(value: &str) -> bool {
value.len() == 7 && value.starts_with('#') && value[1..].chars().all(|c| c.is_ascii_hexdigit()) value.len() == 7 && value.starts_with('#') && value[1..].chars().all(|c| c.is_ascii_hexdigit())
} }
pub fn update_preferences(conn: &Connection, data: &UpdateDisplayPreferences) -> Result<DisplayPreferences, String> { pub fn update_preferences(
conn.execute("INSERT OR IGNORE INTO display_preferences (id) VALUES (1)", []) conn: &Connection,
.map_err(|e| format!("Failed to ensure preferences row: {e}"))?; data: &UpdateDisplayPreferences,
) -> Result<DisplayPreferences, String> {
conn.execute(
"INSERT OR IGNORE INTO display_preferences (id) VALUES (1)",
[],
)
.map_err(|e| format!("Failed to ensure preferences row: {e}"))?;
let mut sets = Vec::new(); let mut sets = Vec::new();
let mut values: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new(); let mut values: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
let mut idx = 1usize; 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_text {
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; } }; } ($field:ident, $column:literal) => {
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 value) = data.$field {
sets.push(format!(concat!($column, " = ?{}"), idx));
if let Some(ref v) = data.density { enum_value("density", v, &["compact", "comfortable"])?; } values.push(Box::new(value.clone()));
if let Some(ref v) = data.theme { enum_value("theme", v, &["light", "dark", "system"])?; } idx += 1;
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"])?; } macro_rules! set_int {
if let Some(ref v) = data.ui_scale { enum_value("ui_scale", v, &["small", "normal", "large"])?; } ($field:ident, $column:literal) => {
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(value) = data.$field {
if let Some(ref v) = data.font_family { enum_value("font_family", v, &["system", "inter", "humanist", "mono"])?; } sets.push(format!(concat!($column, " = ?{}"), idx));
if let Some(ref v) = data.contrast { enum_value("contrast", v, &["soft", "normal", "high"])?; } values.push(Box::new(value));
if let Some(ref v) = data.language { enum_value("language", v, &["en", "pl"])?; } idx += 1;
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"])?; } macro_rules! set_bool {
if let Some(ref v) = data.scrollbar_density { enum_value("scrollbar_density", v, &["hidden", "thin", "normal"])?; } ($field:ident, $column:literal) => {
if let Some(ref v) = data.compose_font_family { enum_value("compose_font_family", v, &["system", "humanist", "serif", "mono"])?; } if let Some(value) = data.$field {
if let Some(ref v) = data.reply_position { enum_value("reply_position", v, &["above", "below"])?; } sets.push(format!(concat!($column, " = ?{}"), idx));
if let Some(ref v) = data.quote_style { enum_value("quote_style", v, &["standard", "compact", "none"])?; } values.push(Box::new(value as i32));
if let Some(ref v) = data.forward_mode { enum_value("forward_mode", v, &["inline", "attachment"])?; } idx += 1;
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"); if let Some(ref v) = data.density {
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"); 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"); 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; } if let Some(ref value) = data.animation_mode {
set_text!(preview_pane, "preview_pane"); set_text!(ui_scale, "ui_scale"); set_text!(color_preset, "color_preset"); set_text!(accent_color, "accent_color"); sets.push(format!("animation_mode = ?{}", idx));
set_text!(background_color, "background_color"); set_text!(panel_color, "panel_color"); set_text!(text_color, "text_color"); values.push(Box::new(value.clone()));
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"); idx += 1;
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!(preview_pane, "preview_pane");
set_text!(date_format, "date_format"); set_text!(time_format, "time_format"); set_text!(ui_scale, "ui_scale");
set_int!(icon_size, "icon_size"); set_text!(toolbar_density, "toolbar_density"); set_int!(sidebar_item_height, "sidebar_item_height"); set_text!(color_preset, "color_preset");
set_int!(reader_max_width, "reader_max_width"); set_int!(composer_width, "composer_width"); set_text!(selection_color, "selection_color"); set_text!(accent_color, "accent_color");
set_int!(unread_font_weight, "unread_font_weight"); set_text!(unread_badge_style, "unread_badge_style"); set_bool!(compact_headers, "compact_headers"); set_text!(background_color, "background_color");
set_bool!(show_borders, "show_borders"); set_text!(scrollbar_density, "scrollbar_density"); set_bool!(monochrome, "monochrome"); set_text!(panel_color, "panel_color");
set_text!(folder_background, "folder_background"); set_text!(message_background, "message_background"); set_text!(reader_background, "reader_background"); set_text!(text_color, "text_color");
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!(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()); 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)); values.push(Box::new(1_i32));
let refs: Vec<&dyn rusqlite::types::ToSql> = values.iter().map(|v| v.as_ref()).collect(); 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) get_preferences(conn)
} }
@@ -300,19 +509,50 @@ mod tests {
let update: UpdateDisplayPreferences = serde_json::from_value(serde_json::json!({ let update: UpdateDisplayPreferences = serde_json::from_value(serde_json::json!({
"theme":"dark", "language":"pl", "color_preset":"blue", "accent_color":"#336699", "theme":"dark", "language":"pl", "color_preset":"blue", "accent_color":"#336699",
"font_size":15, "row_height":64, "folder_pane_width":250, "message_pane_width":480, "font_size":15, "row_height":64, "folder_pane_width":250, "message_pane_width":480,
"show_avatars":false, "reduce_motion":true "show_avatars":false, "reduce_motion":true, "interface_skin":"roundcube-elastic"
})).unwrap(); }))
.unwrap();
let prefs = update_preferences(&conn, &update).unwrap(); let prefs = update_preferences(&conn, &update).unwrap();
assert_eq!(prefs.language, "pl"); assert_eq!(prefs.language, "pl");
assert_eq!(prefs.accent_color, "#336699"); assert_eq!(prefs.accent_color, "#336699");
assert_eq!(prefs.font_size, 15); assert_eq!(prefs.font_size, 15);
assert!(!prefs.show_avatars); 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] #[test]
fn invalid_customization_is_rejected() { fn invalid_customization_is_rejected() {
let conn = open_test_db(); 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()); assert!(update_preferences(&conn, &update).is_err());
} }
} }
+2
View File
@@ -11,8 +11,10 @@ pub mod mail_preferences;
pub mod messages; pub mod messages;
pub mod notification_preferences; pub mod notification_preferences;
pub mod pgp; pub mod pgp;
pub mod response_templates;
pub mod pool; pub mod pool;
pub mod special_folders; pub mod special_folders;
pub mod tags; pub mod tags;
pub mod trusted_senders;
pub mod saved_searches; pub mod saved_searches;
+161 -42
View File
@@ -35,46 +35,164 @@ impl UserDb {
/// All migration scripts in order. Each entry is (version, sql). /// All migration scripts in order. Each entry is (version, sql).
const MIGRATIONS: &[(u32, &str)] = &[ const MIGRATIONS: &[(u32, &str)] = &[
(1, include_str!("../../migrations/V001__initial_schema.sql")), (1, include_str!("../../migrations/V001__initial_schema.sql")),
(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")), (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")), 4,
(6, include_str!("../../migrations/V006__cache_attachments_and_headers.sql")), 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")), (7, include_str!("../../migrations/V007__contacts.sql")),
(8, include_str!("../../migrations/V008__identities.sql")), (8, include_str!("../../migrations/V008__identities.sql")),
(9, include_str!("../../migrations/V009__notification_preferences.sql")), (
(10, include_str!("../../migrations/V010__contact_groups.sql")), 9,
(11, include_str!("../../migrations/V011__message_reaction.sql")), 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")), (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")), (15, include_str!("../../migrations/V015__deep_index.sql")),
(16, include_str!("../../migrations/V016__calendar.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")), 17,
(19, include_str!("../../migrations/V019__known_addresses.sql")), include_str!("../../migrations/V017__add_email_theme.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")), 18,
(23, include_str!("../../migrations/V023__saved_searches.sql")), include_str!("../../migrations/V018__animation_mode.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")), 19,
(27, include_str!("../../migrations/V027__contact_profile.sql")), include_str!("../../migrations/V019__known_addresses.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")), 20,
(31, include_str!("../../migrations/V031__compose_preferences.sql")), include_str!("../../migrations/V020__appearance_personalization.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")), 21,
(35, include_str!("../../migrations/V035__compose_behavior_preferences.sql")), include_str!("../../migrations/V021__special_folders.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")), 22,
(39, include_str!("../../migrations/V039__draft_openpgp_options.sql")), include_str!("../../migrations/V022__identity_options.sql"),
(40, include_str!("../../migrations/V040__draft_openpgp_attach_key.sql")), ),
(41, include_str!("../../migrations/V041__draft_openpgp_recipient_keys.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. /// 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<Connection, Strin
fs::create_dir_all(&dir).map_err(|e| format!("Failed to create db dir: {e}"))?; fs::create_dir_all(&dir).map_err(|e| format!("Failed to create db dir: {e}"))?;
let db_path = dir.join("db.sqlite"); let db_path = dir.join("db.sqlite");
let conn = let conn = Connection::open(&db_path).map_err(|e| format!("Failed to open SQLite: {e}"))?;
Connection::open(&db_path).map_err(|e| format!("Failed to open SQLite: {e}"))?;
// Enable WAL mode for better concurrent read performance. // Enable WAL mode for better concurrent read performance.
conn.execute_batch("PRAGMA journal_mode=WAL;") conn.execute_batch("PRAGMA journal_mode=WAL;")
@@ -216,12 +333,14 @@ mod async_tests {
let (started, ready) = tokio::sync::oneshot::channel(); let (started, ready) = tokio::sync::oneshot::channel();
let (release, wait) = std::sync::mpsc::channel(); let (release, wait) = std::sync::mpsc::channel();
let job = tokio::spawn(async move { let job = tokio::spawn(async move {
database.run(move |_| { database
started.send(()).unwrap(); .run(move |_| {
wait.recv_timeout(std::time::Duration::from_secs(2)) started.send(()).unwrap();
.map_err(|e| format!("runtime could not release database job: {e}"))?; wait.recv_timeout(std::time::Duration::from_secs(2))
Ok(()) .map_err(|e| format!("runtime could not release database job: {e}"))?;
}).await Ok(())
})
.await
}); });
ready.await.unwrap(); ready.await.unwrap();
// On a current-thread runtime, an inline DB job prevents this send. // On a current-thread runtime, an inline DB job prevents this send.
+150
View File
@@ -0,0 +1,150 @@
use rusqlite::{Connection, params};
use serde::Serialize;
#[derive(Debug, Clone, Serialize)]
pub struct ResponseTemplate {
pub id: i64,
pub name: String,
pub body: String,
pub format: String,
pub created_at: String,
pub updated_at: String,
}
fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<ResponseTemplate> {
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<Vec<ResponseTemplate>, 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<Option<ResponseTemplate>, 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<ResponseTemplate, 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()),
};
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<Option<ResponseTemplate>, 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<bool, String> {
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", "<strong>Welcome</strong>", "html").unwrap();
assert_eq!(html.format, "html");
let updated = update(&conn, html.id, "Welcome", "<em>Hello</em>", "html")
.unwrap()
.unwrap();
assert_eq!(updated.body, "<em>Hello</em>");
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());
}
}
+46
View File
@@ -0,0 +1,46 @@
use rusqlite::Connection;
pub fn list(conn: &Connection) -> Result<Vec<String>, 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::<Result<Vec<_>, _>>()
.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());
}
}
+1 -1
View File
@@ -126,7 +126,7 @@ async fn https_get_address(
format!("{host}:{port}") format!("{host}:{port}")
}; };
let request = format!( let request = format!(
"GET {target} HTTP/1.1\r\nHost: {host_header}\r\nUser-Agent: RustBox/0.0.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())) timeout(duration, stream.write_all(request.as_bytes()))
.await .await
+1 -1
View File
@@ -192,7 +192,7 @@ pub async fn export_ics(
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
let mut body = String::from( let mut body = String::from(
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//RustBox//Calendar 0.0.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 { for event in &events {
append_ics_event(&mut body, event); append_ics_event(&mut body, event);
+18
View File
@@ -16,11 +16,13 @@ pub mod messages;
pub mod notification_preferences; pub mod notification_preferences;
pub mod pgp; pub mod pgp;
pub mod quota; pub mod quota;
pub mod response_templates;
pub mod search; pub mod search;
pub mod send; pub mod send;
pub mod sieve; pub mod sieve;
pub mod special_folders; pub mod special_folders;
pub mod tags; pub mod tags;
pub mod trusted_senders;
pub mod unsubscribe; pub mod unsubscribe;
use std::net::{IpAddr, Ipv4Addr, SocketAddr}; use std::net::{IpAddr, Ipv4Addr, SocketAddr};
@@ -247,6 +249,14 @@ pub fn create_router(
get(attachments::get_attachment_content), get(attachments::get_attachment_content),
) )
.route("/search", get(search::search_messages)) .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( .route(
"/search/saved", "/search/saved",
get(search::list_saved_searches).post(search::create_saved_search), get(search::list_saved_searches).post(search::create_saved_search),
@@ -399,6 +409,14 @@ pub fn create_router(
get(mail_preferences::get_mail_preferences) get(mail_preferences::get_mail_preferences)
.put(mail_preferences::update_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( .route(
"/folders/{folder}/preferences", "/folders/{folder}/preferences",
get(folder_preferences::get_folder_preferences) get(folder_preferences::get_folder_preferences)
+77
View File
@@ -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<SessionState>,
Extension(config): Extension<Arc<AppConfig>>,
) -> Result<Response, AppError> {
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<SessionState>,
Extension(config): Extension<Arc<AppConfig>>,
Json(data): Json<ResponseTemplateRequest>,
) -> Result<Response, AppError> {
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<SessionState>,
Extension(config): Extension<Arc<AppConfig>>,
Path(id): Path<i64>,
Json(data): Json<ResponseTemplateRequest>,
) -> Result<Response, AppError> {
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<SessionState>,
Extension(config): Extension<Arc<AppConfig>>,
Path(id): Path<i64>,
) -> Result<Response, AppError> {
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()))
}
}
+48 -2
View File
@@ -2,6 +2,7 @@ use std::sync::Arc;
use axum::response::{IntoResponse, Response}; use axum::response::{IntoResponse, Response};
use axum::{Extension, Json}; use axum::{Extension, Json};
use rusqlite::Connection;
use crate::auth::session::SessionState; use crate::auth::session::SessionState;
use crate::config::AppConfig; use crate::config::AppConfig;
@@ -9,13 +10,57 @@ use crate::db;
use crate::db::special_folders::SpecialFolders; use crate::db::special_folders::SpecialFolders;
use crate::error::AppError; use crate::error::AppError;
fn configured_folder_if_present(
conn: &Connection,
value: &Option<String>,
) -> Result<Option<String>, 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<SpecialFolders, String> {
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( pub async fn get_special_folders(
Extension(session): Extension<SessionState>, Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>, Extension(config): Extension<Arc<AppConfig>>,
) -> Result<Response, AppError> { ) -> Result<Response, AppError> {
let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash)
.map_err(|e| AppError::InternalError(format!("Failed to open database: {e}")))?; .map_err(|e| AppError::InternalError(format!("Failed to open database: {e}")))?;
let 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()) Ok(Json(value).into_response())
} }
@@ -26,7 +71,7 @@ pub async fn update_special_folders(
) -> Result<Response, AppError> { ) -> Result<Response, AppError> {
let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash)
.map_err(|e| AppError::InternalError(format!("Failed to open database: {e}")))?; .map_err(|e| AppError::InternalError(format!("Failed to open database: {e}")))?;
let 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:") if message.starts_with("Folder does not exist:")
|| message.starts_with("Invalid special folder role:") || message.starts_with("Invalid special folder role:")
{ {
@@ -35,5 +80,6 @@ pub async fn update_special_folders(
AppError::InternalError(message) AppError::InternalError(message)
} }
})?; })?;
let saved = effective_special_folders(&conn, &config).map_err(AppError::InternalError)?;
Ok(Json(saved).into_response()) Ok(Json(saved).into_response())
} }
+4 -2
View File
@@ -319,7 +319,8 @@ async fn root_serves_embedded_rustbox_ui() {
let body = response.into_body().collect().await.unwrap().to_bytes(); let body = response.into_body().collect().await.unwrap().to_bytes();
let html = String::from_utf8(body.to_vec()).unwrap(); let html = String::from_utf8(body.to_vec()).unwrap();
assert!(html.contains("RustBox")); assert!(html.contains("RustBox"));
assert!(html.contains("assets/app.js")); assert!(html.contains("assets/app."));
assert!(html.contains(".js"));
} }
#[tokio::test] #[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 body = response.into_body().collect().await.unwrap().to_bytes();
let html = String::from_utf8(body.to_vec()).unwrap(); let html = String::from_utf8(body.to_vec()).unwrap();
assert!(html.contains("data-base-path=\"/mail\"")); 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] #[tokio::test]
+62
View File
@@ -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<String, AppError> {
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<SessionState>,
Extension(config): Extension<Arc<AppConfig>>,
) -> Result<Response, AppError> {
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<SessionState>,
Extension(config): Extension<Arc<AppConfig>>,
Json(body): Json<TrustSenderRequest>,
) -> Result<Response, AppError> {
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<SessionState>,
Extension(config): Extension<Arc<AppConfig>>,
Path(email): Path<String>,
) -> Result<Response, AppError> {
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())
}
+1 -1
View File
@@ -229,7 +229,7 @@ async fn post_one_click_to_address(
format!("{host}:{port}") format!("{host}:{port}")
}; };
let request = format!( let request = format!(
"POST {path} HTTP/1.1\r\nHost: {host_header}\r\nUser-Agent: RustBox/0.0.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() ONE_CLICK_BODY.len()
); );
+1 -1
View File
@@ -29,7 +29,7 @@ impl fmt::Display for SieveError {
write!(f, "ManageSieve server rejected the command: {msg}") write!(f, "ManageSieve server rejected the command: {msg}")
} }
Self::Timeout => write!(f, "ManageSieve operation timed out"), Self::Timeout => write!(f, "ManageSieve operation timed out"),
Self::ScriptTooLarge => write!(f, "Sieve script exceeds the RustBox 0.0.1 size limit"), Self::ScriptTooLarge => write!(f, "Sieve script exceeds the RustBox 0.0.2 size limit"),
} }
} }
} }
+21 -3
View File
@@ -8,11 +8,12 @@ use crate::config::AppConfig;
const INDEX_HTML: &str = include_str!("web/index.html"); const INDEX_HTML: &str = include_str!("web/index.html");
const APP_CSS: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/rustbox-ui.css")); 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 ICONS: &[u8] = include_bytes!("web/icons.svg");
const I18N_EN: &[u8] = include_bytes!("web/i18n/en.json"); const I18N_EN: &[u8] = include_bytes!("web/i18n/en.json");
const I18N_PL: &[u8] = include_bytes!("web/i18n/pl.json"); const I18N_PL: &[u8] = include_bytes!("web/i18n/pl.json");
const OXI_BASE: &str = include_str!("../UPSTREAM_BASE"); const OXI_BASE: &str = include_str!("../UPSTREAM_BASE");
const ASSET_ID: &str = env!("RUSTBOX_ASSET_ID");
const FAVICON: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect x="9" y="14" width="46" height="36" rx="5" fill="none" stroke="#6f9c86" stroke-width="5"/><path d="m11 18 21 15 21-15" fill="none" stroke="#6f9c86" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/></svg>"##; const FAVICON: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect x="9" y="14" width="46" height="36" rx="5" fill="none" stroke="#6f9c86" stroke-width="5"/><path d="m11 18 21 15 21-15" fill="none" stroke="#6f9c86" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/></svg>"##;
@@ -45,9 +46,25 @@ pub async fn serve_ui(uri: Uri, Extension(config): Extension<Arc<AppConfig>>) ->
.trim_start_matches('/') .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 { match relative {
// Internal 0.0.1 snapshots change frequently while keeping the same version. // Keep unversioned aliases non-cacheable for compatibility with old bookmarks/proxies.
// Do not let a browser mix a new HTML/JS bundle with stale CSS/icons.
"assets/app.css" => bytes_response("text/css; charset=utf-8", "no-store", APP_CSS), "assets/app.css" => bytes_response("text/css; charset=utf-8", "no-store", APP_CSS),
"assets/app.js" => bytes_response("text/javascript; charset=utf-8", "no-store", APP_JS), "assets/app.js" => bytes_response("text/javascript; charset=utf-8", "no-store", APP_JS),
"assets/icons.svg" => bytes_response("image/svg+xml", "no-store", ICONS), "assets/icons.svg" => bytes_response("image/svg+xml", "no-store", ICONS),
@@ -76,6 +93,7 @@ pub async fn serve_ui(uri: Uri, Extension(config): Extension<Arc<AppConfig>>) ->
let html = INDEX_HTML let html = INDEX_HTML
.replace("__RUSTBOX_BASE_PATH__", base_path) .replace("__RUSTBOX_BASE_PATH__", base_path)
.replace("__RUSTBOX_ASSET_PREFIX__", asset_prefix) .replace("__RUSTBOX_ASSET_PREFIX__", asset_prefix)
.replace("__RUSTBOX_ASSET_ID__", ASSET_ID)
.replace("__RUSTBOX_VERSION__", env!("CARGO_PKG_VERSION")) .replace("__RUSTBOX_VERSION__", env!("CARGO_PKG_VERSION"))
.replace("__RUSTBOX_OXI_BASE__", OXI_BASE.trim()); .replace("__RUSTBOX_OXI_BASE__", OXI_BASE.trim());
( (
-7050
View File
File diff suppressed because it is too large. Load diff
+4
View File
@@ -233,6 +233,10 @@
display: none; display: none;
} }
.folder-drawer-scrim {
display: none;
}
.search-filter-button { .search-filter-button {
width: 24px; width: 24px;
height: 24px; height: 24px;
+98
View File
@@ -324,6 +324,73 @@
font-size: 12px; 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, .sender span,
.message-meta time { .message-meta time {
color: var(--muted); color: var(--muted);
@@ -1033,3 +1100,34 @@ html[data-toolbar-density="spacious"] .reader-toolbar {
width: 116px; 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;
}
+32 -6
View File
@@ -762,14 +762,30 @@
} }
@media (max-width: 560px) { @media (max-width: 560px) {
.compose-addresses {
.compose-addresses>label, gap: 7px;
.compose-options-row { max-height: min(44dvh, 390px);
grid-template-columns: 1fr; padding: 8px;
gap: 3px; }
.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 { .compose-options-row>span {
text-align: left; text-align: left;
padding-top: 0; padding-top: 0;
@@ -784,3 +800,13 @@
overflow: auto; overflow: auto;
} }
} }
.compose-response-control {
min-width: 118px;
max-width: 190px;
}
.compose-response-control select {
min-height: 28px;
padding-top: 2px;
padding-bottom: 2px;
}
+36
View File
@@ -2358,3 +2358,39 @@ kbd {
flex-direction: column 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;
}
+434
View File
@@ -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;
}
}
+362
View File
@@ -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;
}
}
+20 -1
View File
@@ -85,16 +85,35 @@ html[data-preview="bottom"] .reader-pane {
top: 0; top: 0;
bottom: 0; bottom: 0;
left: 0; left: 0;
width: 250px; width: min(84vw, 320px);
transform: translateX(-100%); transform: translateX(-100%);
transition: transform .15s ease; transition: transform .15s ease;
box-shadow: 12px 0 28px var(--shadow); 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 { .folder-pane.open {
transform: translateX(0); transform: translateX(0);
} }
.message-pane { .message-pane {
height: 100%; height: 100%;
border-right: 0; border-right: 0;
+2
View File
@@ -4,4 +4,6 @@
30-mail.css 30-mail.css
40-compose.css 40-compose.css
50-settings.css 50-settings.css
55-roundcube-skins.css
56-interface-styles.css
60-responsive.css 60-responsive.css
+1283 -320
View File
File diff suppressed because it is too large. Load diff
+153
View File
@@ -0,0 +1,153 @@
/* RustBox UI module: 00-bootstrap.js */
(() => {
"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: [],
trustedSenders: new Set(),
contactGroups: [],
contactPhotoUrl: null,
calendarCursor: new Date(),
calendarView: localStorage.getItem("rustbox.calendarView") || "month",
threadExpanded: localStorage.getItem("rustbox.threadExpanded") !== "false",
calendarEvents: [],
savedSearches: [],
responseTemplates: [],
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,
calendarReminderTimer: 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", interfaceSkin: "rustbox", 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 `<svg aria-hidden="true"><use href="${ICONS}#icon-${name}"></use></svg>`;
}
function escapeHtml(value) {
return String(value ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
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(/<svg\b[^>]*>\s*<use\b[^>]*(?:\/assets\/icons\.svg|assets\/icons\.svg)[^>]*><\/use>\s*<\/svg>/gi, "");
text = text.replace(/&lt;svg\b[\s\S]*?(?:\/assets\/icons\.svg|assets\/icons\.svg)[\s\S]*?&lt;\/svg&gt;/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 sanitizeAttachmentHtml(html) {
const doc = new DOMParser().parseFromString(sanitizeMessageHtml(html), "text/html");
doc.querySelectorAll("a[href]").forEach((node) => node.removeAttribute("href"));
doc.querySelectorAll("[src],[background],[poster]").forEach((node) => {
for (const name of ["src", "background", "poster"]) {
if (!node.hasAttribute(name)) continue;
const value = String(node.getAttribute(name) || "").trim();
if (!/^data:/i.test(value)) node.removeAttribute(name);
}
});
return doc.body.innerHTML;
}
function apiPath(path) {
return `${API}${path.startsWith("/") ? path : `/${path}`}`;
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
+333
View File
@@ -0,0 +1,333 @@
/* RustBox UI module: 10-core.js */
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));
}
+558
View File
@@ -0,0 +1,558 @@
/* RustBox UI module: 20-preferences-auth.js */
function resolveTheme(theme) {
if (theme !== "system") return theme;
return matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark";
}
const preferenceMap = {
theme: "theme", interfaceSkin: "interface_skin", 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();
const interfaceStyleProfiles = {
"roundcube-classic": {
label: "Roundcube Classic",
preferences: { density: "compact", preview: "right", scale: "normal", fontFamily: "system", fontSize: 12, rowHeight: 44, folderPaneWidth: 224, messagePaneWidth: 410, borderRadius: 0, contrast: "normal", iconSize: 16, toolbarDensity: "compact", sidebarItemHeight: 24, readerMaxWidth: 0, composerWidth: 900, selectionColor: "#cc3333", unreadFontWeight: 700, unreadBadgeStyle: "dot", compactHeaders: true, showBorders: true, scrollbarDensity: "thin", monochrome: false },
},
"roundcube-larry": {
label: "Roundcube Larry",
preferences: { density: "compact", preview: "right", scale: "normal", fontFamily: "system", fontSize: 13, rowHeight: 48, folderPaneWidth: 230, messagePaneWidth: 420, borderRadius: 3, contrast: "normal", iconSize: 17, toolbarDensity: "normal", sidebarItemHeight: 28, readerMaxWidth: 0, composerWidth: 920, selectionColor: "#c7e3ef", unreadFontWeight: 700, unreadBadgeStyle: "dot", compactHeaders: true, showBorders: true, scrollbarDensity: "thin", monochrome: false },
},
"roundcube-elastic": {
label: "Roundcube Elastic",
preferences: { density: "compact", preview: "right", scale: "normal", fontFamily: "system", fontSize: 14, rowHeight: 52, folderPaneWidth: 238, messagePaneWidth: 440, borderRadius: 4, contrast: "normal", iconSize: 18, toolbarDensity: "normal", sidebarItemHeight: 35, readerMaxWidth: 0, composerWidth: 940, selectionColor: "#ebf8ff", unreadFontWeight: 700, unreadBadgeStyle: "dot", compactHeaders: false, showBorders: true, scrollbarDensity: "thin", monochrome: false },
},
clean: {
label: "Clean",
preferences: { density: "comfortable", preview: "right", scale: "normal", fontFamily: "system", fontSize: 13, rowHeight: 58, folderPaneWidth: 236, messagePaneWidth: 430, borderRadius: 8, contrast: "soft", iconSize: 18, toolbarDensity: "normal", sidebarItemHeight: 32, readerMaxWidth: 980, composerWidth: 940, selectionColor: "#dce9ff", unreadFontWeight: 700, unreadBadgeStyle: "dot", compactHeaders: false, showBorders: false, scrollbarDensity: "thin", monochrome: false },
},
graphite: {
label: "Graphite",
preferences: { density: "compact", preview: "right", scale: "normal", fontFamily: "system", fontSize: 13, rowHeight: 50, folderPaneWidth: 220, messagePaneWidth: 400, borderRadius: 2, contrast: "normal", iconSize: 17, toolbarDensity: "compact", sidebarItemHeight: 27, readerMaxWidth: 0, composerWidth: 880, selectionColor: "#32373c", unreadFontWeight: 720, unreadBadgeStyle: "solid", compactHeaders: true, showBorders: true, scrollbarDensity: "thin", monochrome: false },
},
"high-contrast": {
label: "High contrast",
preferences: { density: "comfortable", preview: "right", scale: "normal", fontFamily: "system", fontSize: 14, rowHeight: 60, folderPaneWidth: 240, messagePaneWidth: 440, borderRadius: 2, contrast: "high", iconSize: 20, toolbarDensity: "normal", sidebarItemHeight: 34, readerMaxWidth: 980, composerWidth: 960, selectionColor: "#ffe500", unreadFontWeight: 800, unreadBadgeStyle: "solid", compactHeaders: false, showBorders: true, scrollbarDensity: "normal", monochrome: false },
},
};
function activeInterfaceStyle(prefs = state.prefs) {
return interfaceStyleProfiles[prefs.interfaceSkin] || null;
}
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;
const fixedStyle = activeInterfaceStyle(p);
const visual = fixedStyle ? { ...p, ...fixedStyle.preferences } : p;
root.dataset.theme = resolveTheme(p.theme);
root.dataset.interfaceSkin = p.interfaceSkin || "rustbox";
root.dataset.density = visual.density;
root.dataset.preview = visual.preview;
root.dataset.scale = visual.scale;
root.dataset.colorPreset = p.colorPreset;
root.dataset.fontFamily = visual.fontFamily;
root.dataset.contrast = visual.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 = visual.toolbarDensity;
root.dataset.unreadBadge = visual.unreadBadgeStyle;
root.dataset.compactHeaders = String(visual.compactHeaders);
root.dataset.showBorders = String(visual.showBorders);
root.dataset.scrollbarDensity = visual.scrollbarDensity;
root.dataset.monochrome = String(visual.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 })[visual.scale] || 1;
const densityFactor = visual.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(visual.fontSize || 13) * scaleFactor * 100) / 100}px`);
root.style.setProperty("--row-height", `${Math.max(42, Math.round(Number(visual.rowHeight || 58) * scaleFactor))}px`);
root.style.setProperty("--folder-pane-width", `${Math.max(170, Math.round(Number(visual.folderPaneWidth || 228) * scaleFactor))}px`);
root.style.setProperty("--message-pane-width", `${Math.max(300, Math.round(Number(visual.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(visual.iconSize || 18) * scaleFactor))}px`);
const toolbarFactor = ({ compact: 0.88, normal: 1, spacious: 1.14 })[visual.toolbarDensity] || 1;
root.style.setProperty("--toolbar-density-factor", String(toolbarFactor));
root.style.setProperty("--sidebar-item-height", `${Math.max(24, Math.round(Number(visual.sidebarItemHeight || 29) * scaleFactor))}px`);
root.style.setProperty("--reader-max-width", Number(visual.readerMaxWidth) > 0 ? `${Number(visual.readerMaxWidth)}px` : "none");
root.style.setProperty("--composer-width", `${Math.max(640, Number(visual.composerWidth || 900))}px`);
root.style.setProperty("--compose-font-family", composeFontCss(p.composeFontFamily));
const lockedRoundcubeSkin = Boolean(fixedStyle);
if (lockedRoundcubeSkin) {
["--selected", "--folder-bg", "--message-bg", "--reader-bg"].forEach((name) => root.style.removeProperty(name));
} else {
root.style.setProperty("--selected", visual.selectionColor || "#28352e");
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("--unread-font-weight", String(Number(visual.unreadFontWeight || 730)));
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", `${visual.borderRadius}px`);
root.style.setProperty("--radius-panel", `${Math.min(visual.borderRadius + 1, 13)}px`);
const values = {
"#pref-theme": p.theme, "#pref-interface-skin": p.interfaceSkin, "#pref-density": visual.density, "#pref-preview": visual.preview, "#pref-scale": visual.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": visual.fontFamily, "#pref-font-size": visual.fontSize, "#pref-row-height": visual.rowHeight,
"#pref-folder-width": visual.folderPaneWidth, "#pref-message-width": visual.messagePaneWidth, "#pref-radius": visual.borderRadius,
"#pref-contrast": visual.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": visual.iconSize, "#pref-toolbar-density": visual.toolbarDensity, "#pref-sidebar-height": visual.sidebarItemHeight, "#pref-reader-width": String(visual.readerMaxWidth), "#pref-composer-width": visual.composerWidth,
"#pref-selection-color": visual.selectionColor, "#pref-unread-weight": visual.unreadFontWeight, "#pref-unread-badge": visual.unreadBadgeStyle, "#pref-scrollbar-density": visual.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": visual.compactHeaders, "#pref-show-borders": visual.showBorders, "#pref-monochrome": visual.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": `${visual.fontSize} px`, "#pref-row-height-value": `${visual.rowHeight} px`, "#pref-folder-width-value": `${visual.folderPaneWidth} px`, "#pref-message-width-value": `${visual.messagePaneWidth} px`, "#pref-radius-value": `${visual.borderRadius} px`, "#pref-icon-size-value": `${visual.iconSize} px`, "#pref-sidebar-height-value": `${visual.sidebarItemHeight} px`, "#pref-composer-width-value": `${visual.composerWidth} px`, "#pref-selection-color-value": visual.selectionColor, "#pref-unread-weight-value": String(visual.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; });
const customizationLocked = Boolean(fixedStyle);
[
"#pref-color-preset", "#pref-accent", "#pref-background", "#pref-panel", "#pref-text-color",
"#pref-density", "#pref-preview", "#pref-scale", "#pref-font-family", "#pref-font-size",
"#pref-row-height", "#pref-folder-width", "#pref-message-width", "#pref-radius", "#pref-contrast",
"#pref-icon-size", "#pref-toolbar-density", "#pref-sidebar-height", "#pref-reader-width", "#pref-composer-width",
"#pref-selection-color", "#pref-unread-weight", "#pref-unread-badge", "#pref-scrollbar-density",
"#pref-compact-headers", "#pref-show-borders", "#pref-monochrome",
"#pref-folder-bg-enabled", "#pref-folder-bg", "#pref-message-bg-enabled", "#pref-message-bg", "#pref-reader-bg-enabled", "#pref-reader-bg",
].forEach((selector) => { const node = $(selector); if (node) node.disabled = customizationLocked; });
const skinNote = $("#pref-interface-skin-note");
if (skinNote) skinNote.textContent = fixedStyle
? `${fixedStyle.label} is a fixed interface style. Theme still switches light/dark; choose RustBox (custom) to edit colors, spacing and geometry.`
: "RustBox is fully customizable: colors, spacing, panes, typography and advanced interface controls are available below.";
}
function normalizeServerPreferences(data) {
if (!data) return;
state.prefs = {
...state.prefs,
theme: data.theme || "system", interfaceSkin: data.interface_skin || "rustbox", 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", interface_skin: "rustbox", 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"); }
}
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() {
setFolderDrawerOpen(false);
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();
await loadTrustedSenders();
startCalendarReminderWatcher();
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 loadSpecialFolders();
await loadTags();
await loadNotificationPreferences();
await loadTrustedSenders();
startCalendarReminderWatcher();
connectRealtime();
} catch (error) {
errorBox.textContent = error.message;
errorBox.classList.remove("hidden");
} finally {
setBusy(submit, false);
}
}
async function logout() {
disconnectRealtime();
if (state.calendarReminderTimer) { clearInterval(state.calendarReminderTimer); state.calendarReminderTimer = null; }
try { await request("/auth/logout", { method: "POST", body: "{}" }); } catch (_) { }
state.accountId = null;
state.accounts = [];
localStorage.removeItem("rustbox.activeAccount");
showLogin();
}
+453
View File
@@ -0,0 +1,453 @@
/* RustBox UI module: 30-folders-tags.js */
function folderIconName(folder) {
const attrs = (folder.attributes || []).join(" ").toLowerCase();
const name = String(folder.name || "").toLowerCase();
if (name === "inbox" || attrs.includes("\\inbox")) return "inbox";
if (attrs.includes("\\sent") || name.includes("sent")) return "send";
if (attrs.includes("\\drafts") || name.includes("draft")) return "file-text";
if (attrs.includes("\\archive") || name.includes("archive")) return "archive";
if (attrs.includes("\\trash") || name.includes("trash") || name.includes("deleted")) return "trash";
if (attrs.includes("\\junk") || name.includes("spam") || name.includes("junk")) return "shield-alert";
return "folder";
}
function orderedFolders() {
return [...state.folders].sort((a, b) => {
if (a.name.toUpperCase() === "INBOX") return -1;
if (b.name.toUpperCase() === "INBOX") return 1;
return a.name.localeCompare(b.name);
});
}
function folderDepth(folder) {
const delimiter = folder.delimiter || "/";
if (!delimiter || !folder.name.includes(delimiter)) return 0;
return Math.min(8, folder.name.split(delimiter).length - 1);
}
function folderLeafName(folder) {
const delimiter = folder.delimiter || "/";
if (!delimiter || !folder.name.includes(delimiter)) return folder.name;
return folder.name.split(delimiter).filter(Boolean).at(-1) || folder.name;
}
function folderHasChildren(folder, folders = state.folders) {
const delimiter = folder.delimiter || "/";
if (!delimiter) return false;
const prefix = `${folder.name}${delimiter}`;
return folders.some((candidate) => candidate.name !== folder.name && candidate.name.startsWith(prefix));
}
function folderAncestors(folder) {
const delimiter = folder.delimiter || "/";
if (!delimiter || !folder.name.includes(delimiter)) return [];
const parts = folder.name.split(delimiter);
const result = [];
for (let index = 1; index < parts.length; index += 1) result.push(parts.slice(0, index).join(delimiter));
return result;
}
function collapsedFolderStorageKey() {
return `rustbox.collapsedFolders.${state.accountId || "guest"}`;
}
function loadCollapsedFolders() {
try {
const raw = JSON.parse(localStorage.getItem(collapsedFolderStorageKey()) || "[]");
state.collapsedFolders = new Set(Array.isArray(raw) ? raw.filter((value) => typeof value === "string") : []);
} catch (_) {
state.collapsedFolders = new Set();
}
}
function saveCollapsedFolders() {
localStorage.setItem(collapsedFolderStorageKey(), JSON.stringify([...state.collapsedFolders]));
}
function folderIsHiddenByCollapsedAncestor(folder) {
return folderAncestors(folder).some((name) => state.collapsedFolders.has(name));
}
function toggleFolderBranch(folder) {
if (state.collapsedFolders.has(folder.name)) state.collapsedFolders.delete(folder.name);
else state.collapsedFolders.add(folder.name);
saveCollapsedFolders();
renderFolders();
}
function setFolderDrawerOpen(open) {
const shouldOpen = Boolean(open) && innerWidth <= 820;
$("#folder-pane")?.classList.toggle("open", shouldOpen);
$("#folder-drawer-scrim")?.classList.toggle("open", shouldOpen);
$("#mobile-folders")?.setAttribute("aria-expanded", String(shouldOpen));
}
function toggleFolderDrawer() {
setFolderDrawerOpen(!$("#folder-pane")?.classList.contains("open"));
}
function renderFolders() {
const list = $("#folder-list");
list.replaceChildren();
const folders = orderedFolders();
for (const folder of folders) {
if (folderIsHiddenByCollapsedAncestor(folder)) continue;
const row = el("div", "folder-row");
row.style.setProperty("--folder-depth", String(folderDepth(folder)));
const hasChildren = folderHasChildren(folder, folders);
const expander = el("button", `folder-expander ${hasChildren ? "" : "placeholder"}`);
expander.type = "button";
expander.tabIndex = hasChildren ? 0 : -1;
expander.setAttribute("aria-label", state.collapsedFolders.has(folder.name) ? `Expand ${folder.name}` : `Collapse ${folder.name}`);
expander.setAttribute("aria-expanded", String(hasChildren && !state.collapsedFolders.has(folder.name)));
if (hasChildren) {
expander.innerHTML = svgIcon("chevron-right");
expander.classList.toggle("expanded", !state.collapsedFolders.has(folder.name));
expander.addEventListener("click", (event) => { event.stopPropagation(); toggleFolderBranch(folder); });
}
const button = el("button", `folder-button ${folder.name === state.folder ? "active" : ""}`);
button.type = "button";
button.innerHTML = `${svgIcon(folderIconName(folder))}<span class="folder-name" title="${escapeHtml(folder.name)}">${escapeHtml(folderLeafName(folder))}</span><span class="folder-count ${folder.unread_count ? "unread" : ""}">${folder.unread_count || folder.total_count || ""}</span>`;
button.addEventListener("contextmenu", (event) => { event.preventDefault(); openFolderContextMenu(event.clientX, event.clientY, folder); });
button.addEventListener("dragover", (event) => {
if (!event.dataTransfer?.types?.includes("application/x-rustbox-message")) return;
event.preventDefault();
event.dataTransfer.dropEffect = "move";
button.classList.add("drop-target");
});
button.addEventListener("dragleave", () => button.classList.remove("drop-target"));
button.addEventListener("drop", async (event) => {
event.preventDefault();
button.classList.remove("drop-target");
try {
const payload = JSON.parse(event.dataTransfer.getData("application/x-rustbox-message"));
if (!payload?.folder || !payload?.uid || payload.folder === folder.name) return;
await moveMessageByDrag(payload.folder, Number(payload.uid), folder.name);
} catch (error) { toast(error.message || "Could not move message", "error"); }
});
button.addEventListener("click", async () => {
saveMessageListState();
state.folder = folder.name;
state.selected = null;
state.selectedMessages.clear();
clearActiveSearchContext();
state.currentDetail = null;
renderFolders();
closeReader();
setFolderDrawerOpen(false);
await loadMessages(true, { restoreState: true });
});
row.append(expander, button);
list.append(row);
}
renderMoveOptions();
}
function updateUnreadAppBadge() {
const unread = state.folders.reduce((sum, folder) => sum + Math.max(0, Number(folder.unread_count || 0)), 0);
document.title = unread > 0 ? `(${unread}) RustBox` : "RustBox";
try {
if (unread > 0 && typeof navigator.setAppBadge === "function") navigator.setAppBadge(unread);
else if (unread === 0 && typeof navigator.clearAppBadge === "function") navigator.clearAppBadge();
} catch (_) { }
}
async function loadFolders(restoreView = false) {
$("#connection-label").textContent = "Connecting";
try {
const data = await request("/folders", { method: "GET" });
state.folders = data.folders || [];
updateUnreadAppBadge();
loadCollapsedFolders();
const inbox = state.folders.find((folder) => folder.name.toUpperCase() === "INBOX");
if (restoreView) {
const remembered = localStorage.getItem(lastFolderStorageKey());
if (remembered && state.folders.some((folder) => folder.name === remembered)) state.folder = remembered;
}
if (!state.folders.some((folder) => folder.name === state.folder)) {
state.folder = inbox?.name || state.folders[0]?.name || "INBOX";
}
renderFolders();
$("#connection-label").textContent = "Connected";
if (restoreView) {
const restoredSearch = readActiveSearchContext();
if (restoredSearch) {
state.searchContext = restoredSearch;
const restoredParams = new URLSearchParams(restoredSearch.params);
$("#search-input").value = restoredParams.get("q") || "";
await runSearch(restoredParams, restoredSearch.label || "Search", { restoreScrollTop: restoredSearch.scrollTop });
return;
}
}
if (state.searchContext && !restoreView) await runSearch(new URLSearchParams(state.searchContext.params), state.searchContext.label, { restoreScrollTop: state.searchContext.scrollTop });
else await loadMessages(true, { restoreState: restoreView });
} catch (error) {
$("#connection-label").textContent = "Connection error";
if (error.status === 401) return showLogin();
toast(error.message, "error");
$("#message-list").innerHTML = `<div class="empty-list">${escapeHtml(error.message)}</div>`;
}
}
function renderMoveOptions() {
const selects = [
["#move-select", "Move to…"],
["#copy-select", "Copy to…"],
["#bulk-move-select", "Move to…"],
["#bulk-copy-select", "Copy to…"],
];
for (const [selector, placeholder] of selects) {
const select = $(selector);
if (!select) continue;
select.replaceChildren(new Option(placeholder, ""));
for (const folder of orderedFolders()) {
if ((selector === "#move-select" || selector === "#copy-select") && folder.name === state.currentDetail?.folder) continue;
select.add(new Option(folder.name, folder.name));
}
}
}
function messageKey(message) {
return `${message.folder || state.folder}\u0000${message.uid}`;
}
function activeSearchHighlightTerms() {
if (!state.searchContext?.params) return [];
const params = new URLSearchParams(state.searchContext.params);
const terms = [];
for (const key of ["from", "to", "subject"]) {
const value = (params.get(key) || "").trim();
if (value) terms.push(value);
}
const query = (params.get("q") || "").trim();
const tokens = query.match(/(?:[^\s"]+|"[^"]*")+/g) || [];
for (const token of tokens) {
const cleaned = token.replace(/^"|"$/g, "");
const colon = cleaned.indexOf(":");
if (colon > 0) {
const op = cleaned.slice(0, colon).toLowerCase();
const value = cleaned.slice(colon + 1).replace(/^"|"$/g, "").trim();
if (["from", "to", "subject"].includes(op) && value) terms.push(value);
} else if (cleaned.length >= 2) {
terms.push(cleaned);
}
}
return [...new Set(terms.map((term) => term.trim()).filter((term) => term.length >= 2))]
.sort((a, b) => b.length - a.length)
.slice(0, 12);
}
function renderHighlightedText(node, value, terms) {
const text = String(value || "");
node.replaceChildren();
if (!terms.length || !text) {
node.textContent = text;
return;
}
const lower = text.toLocaleLowerCase();
let offset = 0;
while (offset < text.length) {
let matchIndex = -1;
let matchTerm = "";
for (const term of terms) {
const index = lower.indexOf(term.toLocaleLowerCase(), offset);
if (index >= 0 && (matchIndex < 0 || index < matchIndex || (index === matchIndex && term.length > matchTerm.length))) {
matchIndex = index;
matchTerm = term;
}
}
if (matchIndex < 0) {
node.append(document.createTextNode(text.slice(offset)));
break;
}
if (matchIndex > offset) node.append(document.createTextNode(text.slice(offset, matchIndex)));
const mark = el("mark", "search-highlight", text.slice(matchIndex, matchIndex + matchTerm.length));
node.append(mark);
offset = matchIndex + matchTerm.length;
}
}
function selectedMessages() {
const source = state.searchAllResults || state.messages;
return source.filter((message) => state.selectedMessages.has(messageKey(message)));
}
function renderBulkToolbar() {
const selected = selectedMessages();
const toolbar = $("#bulk-toolbar");
toolbar.classList.toggle("hidden", !state.selectionMode);
$("#bulk-count").textContent = `${selected.length} selected`;
const selectionUniverse = state.searchAllResults || state.messages;
const allLoaded = selectionUniverse.length > 0 && selectionUniverse.every((message) => state.selectedMessages.has(messageKey(message)));
$("#bulk-select-all").checked = allLoaded;
$("#bulk-select-all").indeterminate = selected.length > 0 && !allLoaded;
toolbar.querySelectorAll("button, select").forEach((control) => { if (control.id !== "selection-done" && control.id !== "bulk-select-search") control.disabled = selected.length === 0; });
const selectSearch = $("#bulk-select-search");
if (selectSearch) {
selectSearch.classList.toggle("hidden", !state.searchContext || Boolean(state.searchAllResults));
selectSearch.disabled = !state.searchContext;
}
const junkButton = $("#bulk-junk");
if (junkButton) junkButton.textContent = selected.length > 0 && selected.every((message) => isJunkFolder(message.folder || state.folder)) ? tr("not_spam", "Not spam") : tr("spam", "Spam");
const toggle = $("#select-mode-btn");
if (toggle) {
toggle.classList.toggle("active", state.selectionMode);
toggle.setAttribute("aria-pressed", String(state.selectionMode));
toggle.title = state.selectionMode ? "Exit selection mode" : "Select messages";
toggle.setAttribute("aria-label", toggle.title);
}
}
function setSelectionMode(enabled) {
state.selectionMode = Boolean(enabled);
if (!state.selectionMode) {
state.selectedMessages.clear();
state.searchAllResults = null;
}
renderMessages();
}
function setMessageSelected(message, selected) {
if (!state.selectionMode) state.selectionMode = true;
const key = messageKey(message);
selected ? state.selectedMessages.add(key) : state.selectedMessages.delete(key);
renderMessages();
}
function clearBulkSelection() {
state.selectedMessages.clear();
state.searchAllResults = null;
renderMessages();
}
async function selectAllSearchResults() {
if (!state.searchContext) return;
const button = $("#bulk-select-search");
setBusy(button, true, "Selecting…");
try {
const params = new URLSearchParams(state.searchContext.params);
params.set("all", "true");
params.delete("offset");
const data = await request(`/search?${params.toString()}`, { method: "GET" });
state.searchAllResults = data.results || [];
for (const message of state.searchAllResults) state.selectedMessages.add(messageKey(message));
state.total = state.searchAllResults.length;
renderMessages();
toast(`Selected all ${state.searchAllResults.length} search result${state.searchAllResults.length === 1 ? "" : "s"}`, "success");
} catch (error) {
toast(error.message, "error");
} finally {
setBusy(button, false);
}
}
function renderTagOptions() {
const select = $("#bulk-tag-select");
if (!select) return;
select.replaceChildren(new Option("Add label…", ""));
for (const tag of state.tags) select.add(new Option(tag.name, tag.id));
}
function renderTags() {
const list = $("#tag-list");
if (!list) return;
list.replaceChildren();
for (const tag of state.tags) {
const button = el("button", "tag-item");
button.type = "button";
const dot = el("span", "tag-dot"); dot.style.setProperty("--tag-color", tag.color || "#6b7280");
button.append(dot, el("span", "", tag.name), el("span", "tag-count", String(tag.message_count || 0)));
button.addEventListener("click", () => loadTagMessages(tag));
button.addEventListener("contextmenu", (event) => { event.preventDefault(); openTagDialog(tag); });
list.append(button);
}
renderTagOptions();
}
async function loadTags() {
try {
const data = await request("/tags", { method: "GET" });
state.tags = data.tags || [];
renderTags();
} catch (error) { toast(error.message, "error"); }
}
async function loadTagMessages(tag) {
try {
const data = await request(`/tags/${encodeURIComponent(tag.id)}/messages?page=0&per_page=250`, { method: "GET" });
state.messages = data.messages || [];
state.total = data.total_count || state.messages.length;
state.selectedMessages.clear();
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"); }
}
+719
View File
@@ -0,0 +1,719 @@
/* RustBox UI module: 40-messages-reader.js */
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 = `<div class="empty-list">Loading messages…</div>`;
}
$("#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 = `<div class="empty-list">${escapeHtml(error.message)}</div>`;
$("#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 = `<div class="empty-list">${state.searchContext ? "No matching messages." : "No messages in this folder."}</div>`;
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 = `<span class="message-state" aria-hidden="true"></span><div class="message-main"><span class="message-from"></span><time class="message-date">${escapeHtml(formatDate(message.date))}</time></div><div class="message-subject-line"><span class="message-subject"></span><span class="message-snippet"></span></div><span class="message-icons"></span>`;
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);
}
if (detail.from_address) detail._senderTrusted = state.trustedSenders.has(String(detail.from_address).trim().toLowerCase());
state.currentDetail = detail;
renderMessageDetail(detail);
renderMoveOptions();
scheduleMarkRead(detail);
} catch (error) {
$("#reader-subject").textContent = "Unable to open message";
$("#message-body").innerHTML = `<div class="empty-list">${escapeHtml(error.message)}</div>`;
}
}
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 = `<span>${escapeHtml(item.from_name || item.from_address || "Unknown")} — ${escapeHtml(item.subject || "(no subject)")}</span><time>${escapeHtml(formatDate(item.date))}</time>`;
button.addEventListener("click", () => openMessage(item.folder, item.uid));
list.append(button);
}
}
function addressLabel(entry) {
if (!entry?.address) return "";
return entry.name ? `${entry.name} <${entry.address}>` : entry.address;
}
function renderReaderRecipients(detail) {
const host = $("#reader-to");
host.replaceChildren();
const groups = [
["to", detail.to_addresses || []],
["cc", detail.cc_addresses || []],
];
let added = 0;
for (const [prefix, entries] of groups) {
for (const entry of entries) {
if (!entry?.address) continue;
const row = el("div", "recipient-line");
row.append(el("span", "", `${prefix} ${addressLabel(entry)}`));
const add = el("button", "address-contact-button");
add.type = "button";
add.title = `Add ${entry.address} to contacts`;
add.setAttribute("aria-label", add.title);
add.innerHTML = svgIcon("user-round");
add.addEventListener("click", (event) => { event.stopPropagation(); addAddressToContacts(entry.address, entry.name || ""); });
row.append(add);
host.append(row);
added += 1;
}
}
if (!added) host.append(el("div", "recipient-line", "to me"));
}
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");
renderReaderRecipients(detail);
$("#reader-add-sender-contact").classList.toggle("hidden", !detail.from_address);
$("#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")}<span>${escapeHtml(attachment.filename || "attachment")} · ${Math.max(1, Math.ceil((attachment.size || 0) / 1024))} KB</span>`;
button.addEventListener("click", () => attachmentCanPreview(attachment) ? openAttachment(detail.folder, detail.uid, attachment) : 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")}<span>Download all as ZIP</span>`;
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")}<span>${escapeHtml(attachment.filename || "attachment")} · ${Math.max(1, Math.ceil((attachment.size || 0) / 1024))} KB</span>`;
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 !== "never" && detail?._senderTrusted === true) || (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 = `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src ${imageSources}; style-src 'unsafe-inline'; font-src data:">`;
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 = `<!doctype html><html><head>${csp}<meta name="color-scheme" content="light"><style>body{margin:0;padding:22px;color:#202622;background:#fff;font:14px/1.6 Inter,system-ui,-apple-system,'Segoe UI',sans-serif}img{max-width:100%;height:auto}a{color:#356a56}pre{white-space:pre-wrap}${quoteStyle}</style></head><body>${safeHtml}</body></html>`;
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();
}
function attachmentCanPreview(attachment) {
const mime = attachmentMime(attachment);
const filename = String(attachment?.filename || "").toLowerCase();
return mime.startsWith("image/")
|| mime === "application/pdf"
|| mime.startsWith("text/")
|| mime.startsWith("video/")
|| /\.(pdf|png|jpe?g|gif|webp|txt|log|md|csv|html?|xhtml|mp4|webm|ogv|mov)$/i.test(filename);
}
async function openAttachment(folder, uid, attachment) {
try {
const blob = await fetchAttachmentBlob(folder, uid, attachment);
const mime = attachmentMime(attachment, blob);
const filename = String(attachment?.filename || "").toLowerCase();
const isHtml = mime.includes("html") || /\.(html?|xhtml)$/i.test(filename);
const isImage = mime.startsWith("image/") || /\.(png|jpe?g|gif|webp)$/i.test(filename);
const isVideo = mime.startsWith("video/") || /\.(mp4|webm|ogv|mov)$/i.test(filename);
const safe = attachmentCanPreview({ ...attachment, content_type: mime });
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 (isImage) {
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" || filename.endsWith(".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 (isVideo) {
objectUrl = URL.createObjectURL(blob);
const video = document.createElement("video");
video.src = objectUrl;
video.controls = true;
video.playsInline = true;
video.preload = "metadata";
body.append(video);
} else if (isHtml) {
if (blob.size > 5 * 1024 * 1024) return toast("HTML attachment is too large for inline preview", "warning");
const frame = document.createElement("iframe");
frame.setAttribute("sandbox", "");
frame.referrerPolicy = "no-referrer";
const safeHtml = sanitizeAttachmentHtml(await blob.text());
const csp = `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src data:; style-src 'unsafe-inline'; font-src data:">`;
frame.srcdoc = `<!doctype html><html><head>${csp}<meta name="color-scheme" content="light"><style>body{margin:0;padding:20px;color:#202622;background:#fff;font:14px/1.55 system-ui,sans-serif}img{max-width:100%;height:auto}pre{white-space:pre-wrap}</style></head><body>${safeHtml}</body></html>`;
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");
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+294
View File
@@ -0,0 +1,294 @@
/* RustBox UI module: 70-contacts.js */
async function openContacts() {
$("#contacts-dialog").showModal();
await loadContactGroups();
await loadContacts();
}
async function loadContactGroups() {
try {
const data = await request("/contact-groups", { method: "GET" });
state.contactGroups = data.groups || [];
renderContactGroupFilter();
renderContactGroupMembership($("#contact-id").value || null, []);
} catch (error) { toast(error.message, "error"); }
}
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 = `<strong>${escapeHtml(contact.name || contact.email)}</strong><small>${escapeHtml(contact.email)}${contact.company ? ` · ${escapeHtml(contact.company)}` : ""}${contact.phone ? ` · ${escapeHtml(contact.phone)}` : ""}</small>`;
button.addEventListener("click", () => editContact(contact));
list.append(button);
}
}
function 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;
}
+269
View File
@@ -0,0 +1,269 @@
/* RustBox UI module: 71-calendar.js */
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 = `<span>${escapeHtml(time)}</span><strong>${escapeHtml(event.title)}</strong>`;
} else {
button.innerHTML = `<time>${escapeHtml(formatDate(event.start_time, true))}</time><div><strong>${escapeHtml(event.title)}</strong><small>${escapeHtml(event.location || "")}</small></div><span>${escapeHtml(event.status || "")}</span>`;
}
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"); }
}
+557
View File
@@ -0,0 +1,557 @@
/* RustBox UI module: 72-openpgp-identities.js */
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 = `<strong>${escapeHtml(key.userId || key.email || key.keyId)}</strong><small>${escapeHtml(key.email || "No email")} · ${key.hasSecret ? "private + public" : "public"} · ${escapeHtml(key.source || "import")}</small>`;
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 = `<strong>${escapeHtml(identity.display_name || identity.email)}</strong><small>${escapeHtml(identity.email)}${identity.is_default ? " · default" : ""}</small>`;
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, `<img src="${escapeHtml(src)}" alt="${alt}" style="max-width:320px;height:auto">`);
} 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();
}
+342
View File
@@ -0,0 +1,342 @@
/* RustBox UI module: 80-settings-realtime.js */
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));
}
function updateNotificationPermissionStatus() {
const status = $("#notify-permission-status");
if (!status) return;
status.textContent = !("Notification" in window) ? "Browser permission: unsupported" : `Browser permission: ${Notification.permission}`;
}
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"]);
updateNotificationPermissionStatus();
} 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 {
if ($("#notify-enabled").checked && "Notification" in window && Notification.permission === "default") await requestNotificationPermission();
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 };
updateNotificationPermissionStatus();
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();
updateNotificationPermissionStatus();
toast(`Notification permission: ${result}`, result === "granted" ? "success" : "warning");
return result;
}
async function testNotification() {
if (!("Notification" in window)) return toast("Notifications are not supported by this browser", "error");
let permission = Notification.permission;
if (permission === "default") permission = await requestNotificationPermission();
if (permission !== "granted") return toast("Browser notifications are not allowed", "warning");
const notification = new Notification("RustBox notification test", { body: "Browser notifications are working.", tag: "rustbox-test" });
notification.onclick = () => { window.focus(); notification.close(); };
if ($("#notify-sound")?.checked) playNotificationSound();
toast("Test notification sent", "success");
}
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 calendarReminderStorageKey(event) {
return `rustbox.calendarReminder.${state.accountId || "account"}.${event.id}.${event.start_time}`;
}
function fireCalendarReminder(event) {
const title = event.title || "Calendar reminder";
const location = event.location ? ` · ${event.location}` : "";
const when = formatDate(event.start_time, true);
toast(`${title} — ${when}${location}`, "info", 12000);
if (!dndActive() && state.notificationPrefs?.enabled && "Notification" in window && Notification.permission === "granted") {
const notification = new Notification(title, { body: `${when}${location}`, tag: `rustbox-calendar-${event.id}-${event.start_time}`, renotify: true });
notification.onclick = () => { window.focus(); notification.close(); openCalendar(); };
}
if (!dndActive() && state.notificationPrefs?.sound) playNotificationSound();
}
async function checkCalendarReminders() {
if (!state.accountId) return;
const now = Date.now();
const start = new Date(now - 2 * 24 * 60 * 60 * 1000);
const end = new Date(now + 2 * 24 * 60 * 60 * 1000);
try {
const data = await request(`/calendar/events?start=${encodeURIComponent(start.toISOString())}&end=${encodeURIComponent(end.toISOString())}`, { method: "GET" });
for (const event of data.events || []) {
if (event.reminder_minutes == null || String(event.status || "").toLowerCase() === "cancelled") continue;
const startMs = new Date(event.start_time).getTime();
if (!Number.isFinite(startMs)) continue;
const trigger = startMs - Math.max(0, Number(event.reminder_minutes) || 0) * 60000;
if (trigger > now || now - trigger > 10 * 60 * 1000) continue;
const key = calendarReminderStorageKey(event);
if (localStorage.getItem(key)) continue;
localStorage.setItem(key, String(now));
fireCalendarReminder(event);
}
} catch (_) { }
}
function startCalendarReminderWatcher() {
if (state.calendarReminderTimer) clearInterval(state.calendarReminderTimer);
void checkCalendarReminders();
state.calendarReminderTimer = setInterval(() => { void checkCalendarReminders(); }, 30000);
}
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 `<span class="capability-badge ${supported ? "supported" : "unsupported"}">${escapeHtml(label)}<strong>${supported ? "yes" : "no"}</strong></span>`;
}
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;
}
}
File diff suppressed because it is too large. Load diff
+454
View File
@@ -0,0 +1,454 @@
/* RustBox UI module: 95-bindings-boot.js */
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", toggleFolderDrawer);
$("#folder-drawer-close").addEventListener("click", () => setFolderDrawerOpen(false));
$("#folder-drawer-scrim").addEventListener("click", () => setFolderDrawerOpen(false));
$("#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 === "composing") loadResponseTemplates(); 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-interface-skin").addEventListener("change", (event) => savePreference("interfaceSkin", 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); });
});
$("#response-template-new").addEventListener("click", () => editResponseTemplate());
$("#compose-response-template").addEventListener("change", (event) => { $("#compose-response-insert").disabled = !event.target.value; });
$("#compose-response-insert").addEventListener("click", insertSavedResponse);
$("#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);
$("#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);
$("#notify-test").addEventListener("click", testNotification);
$("#notify-enabled").addEventListener("change", (event) => { if (event.target.checked && "Notification" in window && Notification.permission === "default") 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); });
$("#remote-image-trust").addEventListener("click", trustCurrentSender);
$("#reader-add-sender-contact").addEventListener("click", (event) => { event.stopPropagation(); addSenderToContacts(); });
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) {
if ($("#folder-pane")?.classList.contains("open")) setFolderDrawerOpen(false);
else closeReader();
}
});
matchMedia("(prefers-color-scheme: light)").addEventListener("change", () => { if (state.prefs.theme === "system") applyPreferences(); });
window.addEventListener("resize", () => { if (innerWidth > 820) setFolderDrawerOpen(false); });
}
async function boot() {
applyPreferences();
bindEvents();
await loadAuthPolicy();
const authenticated = await loadAccounts();
if (authenticated) { await loadPreferences(); await loadMailPreferences(); await loadOpenPgp(); await loadIdentities(); await loadResponseTemplates(); await loadFolders(true); await loadSpecialFolders(); await loadTags(); await loadNotificationPreferences(); await loadTrustedSenders(); startCalendarReminderWatcher(); connectRealtime(); } else { await loadTranslations(state.prefs.language); }
}
boot();
}) ();
+13
View File
@@ -0,0 +1,13 @@
00-bootstrap.js
10-core.js
20-preferences-auth.js
30-folders-tags.js
40-messages-reader.js
50-compose.js
60-mail-actions-search.js
70-contacts.js
71-calendar.js
72-openpgp-identities.js
80-settings-realtime.js
90-sieve.js
95-bindings-boot.js
+120
View File
@@ -0,0 +1,120 @@
from pathlib import Path
import json
import sqlite3
from html.parser import HTMLParser
import tinycss2
ROOT = Path(__file__).resolve().parents[1]
class IdCollector(HTMLParser):
def __init__(self):
super().__init__()
self.ids = []
def handle_starttag(self, _tag, attrs):
attrs = dict(attrs)
if "id" in attrs:
self.ids.append(attrs["id"])
def js_bundle_source():
js_dir = ROOT / "src/web/js"
names = [line.strip() for line in (js_dir / "manifest.txt").read_text().splitlines() if line.strip() and not line.lstrip().startswith("#")]
assert len(names) >= 10
assert len(names) == len(set(names))
return names, "\n".join((js_dir / name).read_text() for name in names)
def test_schema_and_response_templates():
conn = sqlite3.connect(":memory:")
migrations = sorted(ROOT.joinpath("migrations").glob("V*.sql"))
for migration in migrations:
conn.executescript(migration.read_text())
assert int(migrations[-1].name[1:4]) == 45
display_columns = {row[1] for row in conn.execute("pragma table_info(display_preferences)")}
response_columns = {row[1] for row in conn.execute("pragma table_info(response_templates)")}
assert "interface_skin" in display_columns
assert "format" in response_columns
conn.execute("insert into response_templates(name,body,format) values (?,?,?)", ("Text", "Hello", "text"))
conn.execute("insert into response_templates(name,body,format) values (?,?,?)", ("HTML", "<b>Hello</b>", "html"))
assert conn.execute("select count(*) from response_templates").fetchone()[0] == 2
def test_html_contract():
for relative in ["src/web/index.html", "ui/index.html"]:
source = ROOT.joinpath(relative).read_text()
parser = IdCollector()
parser.feed(source)
duplicates = {value for value in parser.ids if parser.ids.count(value) > 1}
assert not duplicates
assert "folder-drawer-header" in source
assert "#icon-arrow-left" in source
assert "pref-interface-skin" in source
assert "compose-response-template" in source
assert "data-appearance-preset" not in source
for value in ["rustbox", "roundcube-classic", "roundcube-larry", "roundcube-elastic", "clean", "graphite", "high-contrast"]:
assert f'value="{value}"' in source
def test_roundcube_palette_contract():
classic_elastic = (ROOT / "src/web/css/55-roundcube-skins.css").read_text().lower()
extra_styles = (ROOT / "src/web/css/56-interface-styles.css").read_text().lower()
for css in [classic_elastic, extra_styles]:
errors = [item for item in tinycss2.parse_stylesheet(css, skip_comments=False, skip_whitespace=False) if item.type == "error"]
assert not errors
for token in ["#f6f6f6", "#f2f2f2", "#cc3333", "#929292", "#37beff", "#2f3a3f", "#21292c", "#c5d1d3", "#4d6066", "#374549"]:
assert token in classic_elastic
for selector in [
'roundcube-classic"][data-theme="light',
'roundcube-classic"][data-theme="dark',
'roundcube-elastic"][data-theme="light',
'roundcube-elastic"][data-theme="dark',
]:
assert selector in classic_elastic
for selector in ["roundcube-larry", "clean", "graphite", "high-contrast"]:
assert f'data-interface-skin="{selector}"' in extra_styles
manifest = (ROOT / "src/web/css/manifest.txt").read_text()
assert "55-roundcube-skins.css" in manifest
assert "56-interface-styles.css" in manifest
def test_modular_js_contract():
assert not (ROOT / "src/web/app.js").exists()
names, app = js_bundle_source()
assert names[0] == "00-bootstrap.js"
assert names[-1] == "95-bindings-boot.js"
assert "interfaceStyleProfiles" in app
assert '"roundcube-classic"' in app
assert '"roundcube-larry"' in app
assert '"roundcube-elastic"' in app
assert "loadResponseTemplates" in app
assert "insertSavedResponse" in app
assert "appearancePresets" not in app
assert "applyAppearancePreset" not in app
assert "src/web/js/manifest.txt" in (ROOT / "README.md").read_text()
build = (ROOT / "build.rs").read_text()
assert 'root.join("src/web/js")' in build
assert 'out.join("rustbox-ui.js")' in build
assert 'include_bytes!(concat!(env!("OUT_DIR"), "/rustbox-ui.js"))' in (ROOT / "src/ui.rs").read_text()
def test_backend_skin_contract_and_version():
db_source = (ROOT / "src/db/display_preferences.rs").read_text()
for value in ["rustbox", "roundcube-classic", "roundcube-larry", "roundcube-elastic", "clean", "graphite", "high-contrast"]:
assert f'"{value}"' in db_source
cargo = (ROOT / "Cargo.toml").read_text()
assert 'version = "0.0.2"' in cargo
assert "V044__interface_skin.sql" in (ROOT / "src/db/pool.rs").read_text()
assert "V045__response_template_format.sql" in (ROOT / "src/db/pool.rs").read_text()
for path in ROOT.glob("src/web/i18n/*.json"):
json.loads(path.read_text())
if __name__ == "__main__":
test_schema_and_response_templates()
test_html_contract()
test_roundcube_palette_contract()
test_modular_js_contract()
test_backend_skin_contract_and_version()
print("roundcube-skin-contract: OK")
+12
View File
@@ -0,0 +1,12 @@
<!doctype html><html lang="en" data-theme="light" data-interface-skin="roundcube-elastic" data-density="compact" data-preview="right" data-scale="normal" data-color-preset="sage" data-font-family="system" data-contrast="normal" data-show-borders="true" data-toolbar-density="normal" data-unread-badge="dot" data-scrollbar-density="thin">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><link rel="stylesheet" href="../ui/app.css"><style>:root{--folder-pane-width:238px;--message-pane-width:430px;--row-height:52px;--sidebar-item-height:35px;--ui-size:14px;--control-height:30px;--appbar-height:44px;--icon-size:18px;--button-pad-x:10px;--button-pad-y:5px;--field-pad-x:8px;--field-pad-y:5px} .fixture-icon{width:18px;height:18px}</style></head>
<body><div class="app-view">
<header class="appbar"><div class="app-brand"><span class="brand-icon">✉</span><strong>RustBox</strong></div><div class="search-field"><span>⌕</span><input value="Search mail"><span></span><span>☷</span></div><div class="appbar-actions"><button class="icon-button">⚙</button><div class="account-summary"><span>linuxiarz@linuxiarz.pl</span><span>Connected</span></div></div></header>
<div class="mail-shell"><button class="folder-drawer-scrim open"></button><aside class="folder-pane open">
<div class="folder-drawer-header mobile-only"><button class="icon-button folder-drawer-close" aria-label="Back to messages"><svg viewBox="0 0 24 24"><path d="m15 18-6-6 6-6"/></svg></button><strong>Folders</strong></div>
<button class="button primary compose-button">✎ <span>Compose</span></button><div class="section-heading"><span>Folders</span><span>+</span></div>
<nav class="folder-list"><button class="folder-button active"><span>▣</span><span class="folder-name">INBOX</span><span class="folder-count unread">1424</span></button><button class="folder-button"><span>□</span><span class="folder-name">Archive</span><span></span></button><button class="folder-button"><span>□</span><span class="folder-name">Drafts</span><span></span></button><button class="folder-button"><span>□</span><span class="folder-name">Sent</span><span></span></button><button class="folder-button"><span>□</span><span class="folder-name">Spam</span><span></span></button><button class="folder-button"><span>□</span><span class="folder-name">Trash</span><span></span></button></nav>
<div class="section-heading"><span>Labels</span><span>+</span></div><div class="folder-pane-footer"><span>IMAP</span><span>RustBox</span></div></aside>
<section class="message-pane"><div class="pane-toolbar"><div class="pane-title"><div><strong>Inbox</strong><span>1,424 messages</span></div></div><div class="toolbar-actions"><button class="icon-button">↻</button></div></div><div class="message-list"><button class="message-row unread"><span class="message-state"></span><span class="message-main"><strong class="message-from">Roundcube Team</strong><span class="message-date">16:42</span></span><span class="message-subject-line"><span class="message-subject">Welcome to your inbox</span></span></button><button class="message-row active"><span class="message-state"></span><span class="message-main"><strong class="message-from">Example sender</strong><span class="message-date">15:17</span></span><span class="message-subject-line"><span class="message-subject">Selected message</span></span></button><button class="message-row"><span class="message-state"></span><span class="message-main"><strong class="message-from">Newsletter</strong><span class="message-date">Yesterday</span></span><span class="message-subject-line"><span class="message-subject">A regular message row</span></span></button></div></section>
<section class="reader-pane"><div class="reader-toolbar"><button class="button quiet">Reply</button><button class="button quiet">Forward</button></div><article class="message-content"><header class="message-header"><h1>Selected message</h1><div class="message-meta"><div class="avatar">E</div><div class="sender"><strong>Example sender</strong><span>to linuxiarz</span></div></div></header><div class="message-body"><p>This fixture uses the real RustBox CSS bundle and component classes.</p><p><a href="#">Roundcube accent link</a></p><button class="button primary">Primary action</button></div></article></section>
</div></div><script>const q=new URLSearchParams(location.search);document.documentElement.dataset.interfaceSkin=q.get('skin')||'roundcube-elastic';document.documentElement.dataset.theme=q.get('theme')||'light';</script></body></html>
-1373
View File
File diff suppressed because it is too large. Load diff
-7048
View File
File diff suppressed because it is too large. Load diff
-56
View File
@@ -1,56 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="icon-mail" viewBox="0 0 24 24"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="m3 7 9 6 9-6"/></symbol>
<symbol id="icon-mail-open" viewBox="0 0 24 24"><path d="M3 10.5 12 4l9 6.5V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><path d="m3 11 9 6 9-6"/></symbol>
<symbol id="icon-search" viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"/><path d="m20 20-4-4"/></symbol>
<symbol id="icon-settings" viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.9l.1.1-2.8 2.8-.1-.1a1.7 1.7 0 0 0-1.9-.3 1.7 1.7 0 0 0-1 1.6V21h-4v-.1a1.7 1.7 0 0 0-1-1.6 1.7 1.7 0 0 0-1.9.3l-.1.1L4.2 17l.1-.1a1.7 1.7 0 0 0 .3-1.9A1.7 1.7 0 0 0 3 14H3v-4h.1a1.7 1.7 0 0 0 1.6-1 1.7 1.7 0 0 0-.3-1.9L4.2 7 7 4.2l.1.1a1.7 1.7 0 0 0 1.9.3 1.7 1.7 0 0 0 1-1.6V3h4v.1a1.7 1.7 0 0 0 1 1.6 1.7 1.7 0 0 0 1.9-.3l.1-.1L19.8 7l-.1.1a1.7 1.7 0 0 0-.3 1.9 1.7 1.7 0 0 0 1.6 1h.1v4H21a1.7 1.7 0 0 0-1.6 1Z"/></symbol>
<symbol id="icon-log-out" viewBox="0 0 24 24"><path d="M10 17l5-5-5-5"/><path d="M15 12H3"/><path d="M21 19V5a2 2 0 0 0-2-2h-6"/></symbol>
<symbol id="icon-square-pen" viewBox="0 0 24 24"><path d="M12 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.1 2.1 0 0 1 3 3L12 15l-4 1 1-4Z"/></symbol>
<symbol id="icon-folder-plus" viewBox="0 0 24 24"><path d="M3 6h6l2 2h10v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"/><path d="M12 11v6M9 14h6"/></symbol>
<symbol id="icon-folder" viewBox="0 0 24 24"><path d="M3 6h6l2 2h10v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"/></symbol>
<symbol id="icon-inbox" viewBox="0 0 24 24"><path d="M4 4h16l2 10v6H2v-6Z"/><path d="M2 14h5l2 3h6l2-3h5"/></symbol>
<symbol id="icon-send" viewBox="0 0 24 24"><path d="m22 2-7 20-4-9-9-4Z"/><path d="M22 2 11 13"/></symbol>
<symbol id="icon-file-text" viewBox="0 0 24 24"><path d="M6 2h9l5 5v15H6a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2Z"/><path d="M14 2v6h6M8 13h8M8 17h6"/></symbol>
<symbol id="icon-archive" viewBox="0 0 24 24"><path d="M3 5h18v4H3Z"/><path d="M5 9v11h14V9M9 13h6"/></symbol>
<symbol id="icon-trash" viewBox="0 0 24 24"><path d="M4 7h16M9 7V4h6v3M7 7l1 14h8l1-14M10 11v6M14 11v6"/></symbol>
<symbol id="icon-shield-alert" viewBox="0 0 24 24"><path d="M12 3 4 6v6c0 5 3.4 8 8 9 4.6-1 8-4 8-9V6Z"/><path d="M12 8v5M12 17h.01"/></symbol>
<symbol id="icon-refresh-cw" viewBox="0 0 24 24"><path d="M20 7v5h-5"/><path d="M4 17v-5h5"/><path d="M6.1 8a7 7 0 0 1 11.2-1L20 12M4 12l2.7 5a7 7 0 0 0 11.2-1"/></symbol>
<symbol id="icon-menu" viewBox="0 0 24 24"><path d="M4 6h16M4 12h16M4 18h16"/></symbol>
<symbol id="icon-arrow-left" viewBox="0 0 24 24"><path d="m15 18-6-6 6-6"/></symbol>
<symbol id="icon-reply" viewBox="0 0 24 24"><path d="m9 17-6-5 6-5v3c7 0 10 3 12 7-3-2-6-3-12-2Z"/></symbol>
<symbol id="icon-reply-all" viewBox="0 0 24 24"><path d="m7 17-5-5 5-5v3c6 0 9 3 11 7-3-2-5-3-11-2Z"/><path d="m13 7 5 5-3 3"/></symbol>
<symbol id="icon-forward" viewBox="0 0 24 24"><path d="m15 17 6-5-6-5v3c-7 0-10 3-12 7 3-2 6-3 12-2Z"/></symbol>
<symbol id="icon-star" viewBox="0 0 24 24"><path d="m12 3 2.8 5.7 6.2.9-4.5 4.4 1.1 6.2L12 17.3l-5.6 2.9 1.1-6.2L3 9.6l6.2-.9Z"/></symbol>
<symbol id="icon-paperclip" viewBox="0 0 24 24"><path d="m20.5 11.5-8.8 8.8a6 6 0 0 1-8.5-8.5L13.5 1.5a4 4 0 0 1 5.7 5.7L8.8 17.6a2 2 0 1 1-2.8-2.8l9.5-9.5"/></symbol>
<symbol id="icon-x" viewBox="0 0 24 24"><path d="M6 6l12 12M18 6 6 18"/></symbol>
<symbol id="icon-bold" viewBox="0 0 24 24"><path d="M8 5h6a4 4 0 0 1 0 8H8Z"/><path d="M8 13h7a4 4 0 0 1 0 8H8Z"/></symbol>
<symbol id="icon-italic" viewBox="0 0 24 24"><path d="M10 5h8M6 19h8M14 5l-4 14"/></symbol>
<symbol id="icon-underline" viewBox="0 0 24 24"><path d="M7 4v7a5 5 0 0 0 10 0V4M5 21h14"/></symbol>
<symbol id="icon-list" viewBox="0 0 24 24"><path d="M8 6h13M8 12h13M8 18h13"/><path d="M3 6h.01M3 12h.01M3 18h.01"/></symbol>
<symbol id="icon-list-ordered" viewBox="0 0 24 24"><path d="M9 6h12M9 12h12M9 18h12"/><path d="M3 5h2v3M3 11h2l-2 3h2M3 17h2v4H3"/></symbol>
<symbol id="icon-link" viewBox="0 0 24 24"><path d="M10 13a5 5 0 0 0 7.1.1l2-2a5 5 0 0 0-7.1-7.1l-1.1 1.1"/><path d="M14 11a5 5 0 0 0-7.1-.1l-2 2A5 5 0 0 0 12 20l1.1-1.1"/></symbol>
<symbol id="icon-remove-format" viewBox="0 0 24 24"><path d="M4 7V4h16v3M9 20h6M12 4v16"/><path d="m17 13 4 4M21 13l-4 4"/></symbol>
<symbol id="icon-chevron-right" viewBox="0 0 24 24"><path d="m9 18 6-6-6-6"/></symbol>
<symbol id="icon-user-round" viewBox="0 0 24 24"><path d="M18 20a6 6 0 0 0-12 0"/><circle cx="12" cy="10" r="4"/></symbol>
<symbol id="icon-calendar-days" viewBox="0 0 24 24"><path d="M8 2v4M16 2v4M3 10h18"/><rect x="3" y="4" width="18" height="18" rx="2"/></symbol>
<symbol id="icon-image" viewBox="0 0 24 24"><rect x="3" y="5" width="18" height="14" rx="2"/><circle cx="8.5" cy="10" r="1.5"/><path d="m21 15-5-5L5 19"/></symbol>
<symbol id="icon-sliders" viewBox="0 0 24 24"><path d="M4 6h10M18 6h2M4 12h2M10 12h10M4 18h7M15 18h5"/><circle cx="16" cy="6" r="2"/><circle cx="8" cy="12" r="2"/><circle cx="13" cy="18" r="2"/></symbol>
<symbol id="icon-plus" viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"/></symbol>
<symbol id="icon-strikethrough" viewBox="0 0 24 24"><path d="M16 4H9a4 4 0 0 0-3.7 2.5M8 20h7a4 4 0 0 0 3.8-2.8M4 12h16"/></symbol>
<symbol id="icon-quote" viewBox="0 0 24 24"><path d="M9 11H5a4 4 0 0 1 4-4v10H5M19 11h-4a4 4 0 0 1 4-4v10h-4"/></symbol>
<symbol id="icon-align-left" viewBox="0 0 24 24"><path d="M4 6h16M4 10h10M4 14h16M4 18h10"/></symbol>
<symbol id="icon-align-center" viewBox="0 0 24 24"><path d="M4 6h16M7 10h10M4 14h16M7 18h10"/></symbol>
<symbol id="icon-align-right" viewBox="0 0 24 24"><path d="M4 6h16M10 10h10M4 14h16M10 18h10"/></symbol>
<symbol id="icon-undo" viewBox="0 0 24 24"><path d="m9 7-5 5 5 5"/><path d="M20 17a8 8 0 0 0-8-8H4"/></symbol>
<symbol id="icon-redo" viewBox="0 0 24 24"><path d="m15 7 5 5-5 5"/><path d="M4 17a8 8 0 0 1 8-8h8"/></symbol>
<symbol id="icon-check-circle" viewBox="0 0 24 24"><path d="M22 11.1V12a10 10 0 1 1-5.9-9.1"/><path d="m9 11 3 3L22 4"/></symbol>
<symbol id="icon-circle-alert" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M12 8v4"/><path d="M12 16h.01"/></symbol>
<symbol id="icon-info" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></symbol>
<symbol id="icon-printer" viewBox="0 0 24 24"><path d="M6 9V2h12v7"/><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><rect x="6" y="14" width="12" height="8"/></symbol>
<symbol id="icon-filter" viewBox="0 0 24 24"><path d="M4 5h16"/><path d="M7 12h10"/><path d="M10 19h4"/></symbol>
<symbol id="icon-download" viewBox="0 0 24 24"><path d="M12 3v12"/><path d="m7 10 5 5 5-5"/><path d="M5 21h14"/></symbol>
<symbol id="icon-external-link" viewBox="0 0 24 24"><path d="M15 3h6v6"/><path d="m10 14 11-11"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/></symbol>
<symbol id="icon-package" viewBox="0 0 24 24"><path d="m12 3 9 5-9 5-9-5Z"/><path d="m3 8 9 5 9-5v8l-9 5-9-5Z"/><path d="M12 13v8"/></symbol>
</svg>

Before

Width:  |  Height:  |  Size: 7.0 KiB

-698
View File
@@ -1,698 +0,0 @@
<!doctype html>
<html lang="en" data-base-path="__RUSTBOX_BASE_PATH__" data-theme="dark" data-density="compact" data-preview="right">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<meta name="color-scheme" content="dark light">
<meta name="description" content="RustBox webmail">
<title>RustBox</title>
<link rel="icon" href="__RUSTBOX_ASSET_PREFIX__/favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="__RUSTBOX_ASSET_PREFIX__/assets/app.css">
</head>
<body>
<div id="boot" class="boot-screen" aria-live="polite">
<svg class="boot-icon" aria-hidden="true"><use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-mail"></use></svg>
<strong>RustBox</strong>
</div>
<section id="login-view" class="login-view hidden" aria-label="Sign in">
<div class="login-panel">
<header class="login-brand">
<svg class="brand-icon" aria-hidden="true"><use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-mail"></use></svg>
<div><strong>RustBox</strong><span>Webmail</span></div>
</header>
<form id="login-form" autocomplete="on" class="login-form">
<label><span>Email</span><input id="login-email" type="email" autocomplete="username" required autofocus></label>
<label><span>Password</span><input id="login-password" type="password" autocomplete="current-password" required></label>
<label id="login-remember-row" class="checkbox-row"><input id="login-remember" type="checkbox"><span id="login-remember-label">Keep me signed in</span></label>
<div id="login-error" class="form-error hidden" role="alert"></div>
<button class="button primary wide" type="submit">Sign in</button>
</form>
<footer>RustBox __RUSTBOX_VERSION__</footer>
</div>
</section>
<main id="app-view" class="app-view hidden">
<div id="offline-banner" class="offline-banner hidden" role="status" aria-live="polite"></div>
<header class="appbar">
<div class="app-brand">
<svg class="brand-icon" aria-hidden="true"><use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-mail"></use></svg>
<strong>RustBox</strong>
</div>
<form id="search-form" class="search-field" role="search">
<svg aria-hidden="true"><use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-search"></use></svg>
<input id="search-input" autocomplete="off" placeholder="Search mail" data-i18n-placeholder="search_mail">
<button id="advanced-search-btn" class="search-filter-button" type="button" title="Advanced search" aria-label="Advanced search"><svg><use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-sliders"></use></svg></button>
<kbd>/</kbd>
</form>
<div class="appbar-actions">
<button id="contacts-btn" class="icon-button" type="button" title="Contacts" aria-label="Contacts"><svg><use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-user-round"></use></svg></button>
<button id="calendar-btn" class="icon-button" type="button" title="Calendar" aria-label="Calendar"><svg><use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-calendar-days"></use></svg></button>
<button id="settings-btn" class="icon-button" type="button" title="Settings" aria-label="Settings"><svg><use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-settings"></use></svg></button>
<button id="logout-btn" class="icon-button" type="button" title="Sign out" aria-label="Sign out"><svg><use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-log-out"></use></svg></button>
<div class="account-summary"><span id="account-email">—</span><span id="connection-label">Connecting</span></div>
</div>
</header>
<div class="mail-shell">
<aside id="folder-pane" class="folder-pane">
<button id="compose-btn" class="button primary compose-button" type="button">
<svg><use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-square-pen"></use></svg><span data-i18n="compose">Compose</span>
</button>
<div class="section-heading"><span>Folders</span><button id="folder-manage-btn" class="icon-button small" type="button" title="Manage folders" aria-label="Manage folders"><svg><use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-folder-plus"></use></svg></button></div>
<nav id="folder-list" class="folder-list" aria-label="Mail folders"></nav>
<div class="section-heading label-heading"><span>Labels</span><button id="tag-add-btn" class="icon-button small" type="button" title="New label" aria-label="New label"><svg><use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-plus"></use></svg></button></div>
<nav id="tag-list" class="folder-list tag-list" aria-label="Message labels"></nav>
<div class="folder-pane-footer"><span id="mail-server-label">IMAP</span><span>RustBox __RUSTBOX_VERSION__</span></div>
<div id="folder-resizer" class="pane-resizer" role="separator" aria-orientation="vertical" aria-label="Resize folder pane"></div>
</aside>
<section class="message-pane">
<div class="pane-toolbar">
<div class="pane-title"><button id="mobile-folders" class="icon-button mobile-only" type="button" aria-label="Folders"><svg><use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-menu"></use></svg></button><div><strong id="folder-title">Inbox</strong><span id="message-count">0 messages</span></div></div>
<div class="toolbar-actions">
<label class="list-sort-control"><span class="sr-only">Sort messages</span><select id="message-sort" title="Sort messages"><option value="date_desc">Newest</option><option value="date_asc">Oldest</option><option value="sender_asc">Sender A–Z</option><option value="sender_desc">Sender Z–A</option><option value="subject_asc">Subject A–Z</option><option value="subject_desc">Subject Z–A</option><option value="size_asc">Smallest</option><option value="size_desc">Largest</option></select></label>
<button id="select-mode-btn" class="icon-button" type="button" title="Select messages" aria-label="Select messages" aria-pressed="false"><svg><use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-check-circle"></use></svg></button>
<button id="refresh-btn" class="icon-button" type="button" title="Refresh" aria-label="Refresh"><svg><use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-refresh-cw"></use></svg></button>
</div>
</div>
<div id="bulk-toolbar" class="bulk-toolbar hidden" role="toolbar" aria-label="Actions for selected messages" aria-live="polite">
<label class="bulk-select"><input id="bulk-select-all" type="checkbox"><span id="bulk-count">0 selected</span></label>
<button id="bulk-select-search" class="button quiet hidden" type="button" data-i18n="select_all_results">Select all results</button>
<button id="bulk-read" class="button quiet" type="button">Mark read</button>
<button id="bulk-unread" class="button quiet" type="button">Mark unread</button>
<button id="bulk-flag" class="button quiet" type="button">Flag</button>
<button id="bulk-unflag" class="button quiet" type="button">Unflag</button>
<button id="bulk-archive" class="button quiet" type="button">Archive</button>
<button id="bulk-junk" class="button quiet" type="button" data-i18n="spam">Spam</button>
<label class="move-control"><span class="sr-only">Move selected</span><select id="bulk-move-select"><option value="">Move to…</option></select></label>
<label class="move-control"><span class="sr-only">Copy selected</span><select id="bulk-copy-select"><option value="">Copy to…</option></select></label>
<label class="move-control"><span class="sr-only">Label selected</span><select id="bulk-tag-select"><option value="">Add label…</option></select></label>
<span class="toolbar-spacer"></span>
<button id="bulk-delete" class="button quiet danger" type="button">Delete</button>
<button id="selection-done" class="button quiet" type="button">Done</button>
</div>
<div id="message-list" class="message-list" aria-live="polite"></div>
<button id="load-more" class="button quiet load-more hidden" type="button">Load more</button>
<div id="message-resizer" class="pane-resizer" role="separator" aria-orientation="vertical" aria-label="Resize message list"></div>
</section>
<section id="reader" class="reader-pane">
<div id="reader-empty" class="reader-empty">
<svg aria-hidden="true"><use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-mail-open"></use></svg>
<strong>No message selected</strong><span>Select a message from the list.</span>
</div>
<article id="reader-content" class="reader-content hidden">
<div class="reader-toolbar">
<button id="reader-back" class="icon-button mobile-only" type="button" aria-label="Back"><svg><use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-arrow-left"></use></svg></button>
<button id="reader-prev" class="icon-button" type="button" title="Previous message" aria-label="Previous message"><svg><use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-arrow-left"></use></svg></button>
<button id="reader-next" class="icon-button" type="button" title="Next message" aria-label="Next message"><svg><use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-chevron-right"></use></svg></button>
<button id="reply-btn" class="button quiet" type="button"><svg><use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-reply"></use></svg><span>Reply</span></button>
<button id="reply-all-btn" class="button quiet" type="button"><svg><use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-reply-all"></use></svg><span>Reply all</span></button>
<button id="forward-btn" class="button quiet" type="button"><svg><use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-forward"></use></svg><span>Forward</span></button>
<button id="headers-btn" class="button quiet" type="button">Headers</button><button id="source-btn" class="button quiet" type="button" data-i18n="source">Source</button>
<button id="quick-filter-btn" class="icon-button" type="button" title="Create filter from sender" aria-label="Create filter from sender" data-i18n-title="create_filter_sender" data-i18n-aria-label="create_filter_sender"><svg><use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-filter"></use></svg></button>
<button id="print-btn" class="icon-button" type="button" title="Print message" aria-label="Print message" data-i18n-title="print_message" data-i18n-aria-label="print_message"><svg><use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-printer"></use></svg></button>
<button id="archive-btn" class="icon-button" type="button" title="Archive" aria-label="Archive"><svg><use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-archive"></use></svg></button>
<button id="spam-btn" class="icon-button" type="button" title="Mark as spam" aria-label="Mark as spam" data-i18n-title="mark_spam" data-i18n-aria-label="mark_spam"><svg><use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-shield-alert"></use></svg></button>
<button id="flag-btn" class="icon-button" type="button" title="Flag" aria-label="Flag"><svg><use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-star"></use></svg></button>
<button id="unread-btn" class="icon-button" type="button" title="Mark read or unread" aria-label="Mark read or unread"><svg><use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-mail"></use></svg></button>
<label class="move-control"><span class="sr-only">Move to folder</span><select id="move-select"><option value="">Move to…</option></select></label>
<label class="move-control"><span class="sr-only">Copy to folder</span><select id="copy-select"><option value="">Copy to…</option></select></label>
<span class="toolbar-spacer"></span>
<button id="delete-btn" class="icon-button danger" type="button" title="Delete" aria-label="Delete"><svg><use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-trash"></use></svg></button>
</div>
<header class="message-header">
<h1 id="reader-subject"></h1>
<div class="message-meta"><div class="avatar" id="reader-avatar">?</div><div class="sender"><strong id="reader-from"></strong><span id="reader-to"></span></div><time id="reader-date"></time></div>
</header>
<section id="thread-section" class="thread-section hidden"><div class="section-heading"><span id="thread-title">Thread</span><button id="thread-toggle" class="icon-button small" type="button" aria-label="Toggle thread"><svg><use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-chevron-right"></use></svg></button></div><div id="thread-list" class="thread-list"></div></section>
<div id="attachment-list" class="attachment-list hidden"></div>
<div id="remote-image-bar" class="remote-image-bar hidden"><span>Remote images are blocked.</span><button id="remote-image-load" class="button quiet" type="button">Load images</button></div>
<div id="pgp-reader-bar" class="pgp-reader-bar hidden" role="status" aria-live="polite"><div><strong id="pgp-reader-title">OpenPGP</strong><span id="pgp-reader-status" class="muted"></span></div><div class="inline-actions"><button id="pgp-reader-process" class="button quiet small" type="button">Verify / decrypt</button><button id="pgp-reader-discover" class="button quiet small hidden" type="button">Find signer key</button></div></div>
<div id="message-body" class="message-body"></div>
</article>
</section>
</div>
</main>
<dialog id="compose-dialog" class="dialog compose-dialog">
<form id="compose-form" class="dialog-card compose-card" method="dialog">
<header class="dialog-header"><strong id="compose-title">New message</strong><div class="compose-header-actions"><button type="button" id="compose-recover" class="button quiet small" title="Recover a saved draft">Recover draft</button><button type="button" id="compose-fullscreen" class="icon-button compose-fullscreen-toggle" aria-label="Toggle full screen composer" aria-pressed="false" title="Full screen">⛶</button><button type="button" class="icon-button close-dialog" aria-label="Close"><svg><use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-x"></use></svg></button></div></header>
<div class="compose-addresses">
<label><span>From</span><select id="compose-from"><option value="">Account default</option></select></label>
<label><span>To</span><input id="compose-to" required autocomplete="off"></label>
<label><span>Cc</span><input id="compose-cc" autocomplete="off"></label>
<label><span>Bcc</span><input id="compose-bcc" autocomplete="off"></label>
<label><span>Subject</span><input id="compose-subject" autocomplete="off"></label>
<div class="compose-options-row"><span>Options</span><div class="compose-options"><label><span>Priority</span><select id="compose-priority"><option value="normal">Normal</option><option value="high">High</option><option value="low">Low</option></select></label><label id="compose-pgp-option" class="hidden"><span>OpenPGP</span><select id="compose-pgp-mode"><option value="none">Off</option><option value="sign">Sign</option><option value="encrypt">Encrypt</option><option value="sign-encrypt">Sign + encrypt</option></select></label><label class="compose-mdn-option"><input id="compose-mdn" type="checkbox"><span>Request read receipt</span></label></div></div>
<details id="compose-pgp-panel" class="compose-pgp-panel hidden"><summary><span class="compose-pgp-summary-title">OpenPGP</span><span id="compose-pgp-status" class="muted compose-pgp-summary-status">OpenPGP disabled</span><span class="compose-pgp-summary-action">Details</span></summary><div class="compose-pgp-details"><label class="checkbox-row"><input id="compose-pgp-inline" type="checkbox"><span>Inline OpenPGP (text only; PGP/MIME recommended)</span></label><label class="checkbox-row" id="compose-pgp-attach-sender-row"><input id="compose-pgp-attach-key" type="checkbox"><span>Attach my public OpenPGP key</span></label><label class="checkbox-row" id="compose-pgp-attach-recipient-row"><input id="compose-pgp-attach-recipient-keys" type="checkbox"><span>Attach public keys of To/Cc recipients</span></label><small id="compose-pgp-attach-recipient-help" class="muted compose-pgp-wide">Bcc recipient keys are never attached, so hidden recipients are not disclosed.</small><label id="compose-pgp-passphrase-row" class="compose-pgp-passphrase hidden"><span>Private-key passphrase</span><input id="compose-pgp-passphrase" type="password" autocomplete="off" maxlength="1024"></label><div class="inline-actions"><button id="compose-pgp-check" class="button quiet small" type="button">Check recipient keys</button><button id="compose-pgp-discover" class="button quiet small" type="button">Find missing keys</button></div></div></details>
</div>
<div class="editor-toolbar" aria-label="Formatting">
<button type="button" data-command="bold" title="Bold"><svg><use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-bold"></use></svg></button>
<button type="button" data-command="italic" title="Italic"><svg><use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-italic"></use></svg></button>
<button type="button" data-command="underline" title="Underline"><svg><use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-underline"></use></svg></button>
<button type="button" data-command="strikeThrough" title="Strikethrough"><svg><use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-strikethrough"></use></svg></button>
<span class="toolbar-separator"></span>
<label class="editor-select"><span class="sr-only">Font</span><select id="editor-font" title="Font"><option value="">Font</option><option value="Arial">Arial</option><option value="Georgia">Georgia</option><option value="Times New Roman">Times</option><option value="Courier New">Courier</option><option value="Verdana">Verdana</option></select></label>
<label class="editor-select"><span class="sr-only">Text size</span><select id="editor-size" title="Text size"><option value="">Size</option><option value="2">Small</option><option value="3">Normal</option><option value="4">Large</option><option value="5">Larger</option></select></label>
<label class="editor-color" title="Text color"><span class="sr-only">Text color</span><input id="editor-color" type="color" value="#222222"></label>
<span class="toolbar-separator"></span>
<button type="button" data-command="insertUnorderedList" title="Bullet list"><svg><use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-list"></use></svg></button>
<button type="button" data-command="insertOrderedList" title="Numbered list"><svg><use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-list-ordered"></use></svg></button>
<button type="button" data-command="formatBlock" data-command-value="blockquote" title="Quote"><svg><use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-quote"></use></svg></button>
<button type="button" data-command="justifyLeft" title="Align left"><svg><use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-align-left"></use></svg></button>
<button type="button" data-command="justifyCenter" title="Align center"><svg><use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-align-center"></use></svg></button>
<button type="button" data-command="justifyRight" title="Align right"><svg><use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-align-right"></use></svg></button>
<button type="button" id="editor-link" title="Insert link"><svg><use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-link"></use></svg></button>
<button type="button" id="editor-image" title="Insert inline image"><svg><use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-image"></use></svg></button><input id="compose-images" type="file" accept="image/png,image/jpeg,image/gif,image/webp" hidden>
<button type="button" id="editor-image-edit" title="Edit selected image" disabled><svg><use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-sliders"></use></svg></button>
<button type="button" data-command="removeFormat" title="Clear formatting"><svg><use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-remove-format"></use></svg></button>
<button type="button" data-command="undo" title="Undo"><svg><use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-undo"></use></svg></button>
<button type="button" data-command="redo" title="Redo"><svg><use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-redo"></use></svg></button>
<button type="button" id="compose-attach" title="Attach files"><svg><use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-paperclip"></use></svg></button><input id="compose-files" type="file" multiple hidden>
<button type="button" id="compose-markdown-preview" class="hidden" title="Preview Markdown"><svg><use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-file-text"></use></svg></button>
<label class="compose-mode-control"><span class="sr-only">Compose format</span><select id="compose-mode"><option value="html">HTML</option><option value="text">Plain text</option><option value="markdown">Markdown</option></select></label>
</div>
<div id="compose-editor" class="compose-editor" contenteditable="true" role="textbox" aria-multiline="true"></div><div id="compose-upload-progress" class="compose-upload-progress hidden" role="status" aria-live="polite"><div class="compose-upload-track" aria-hidden="true"><span id="compose-upload-bar"></span></div><span id="compose-upload-label">Uploading…</span></div><div id="compose-attachments" class="compose-attachments hidden"></div>
<div id="compose-error" class="form-error hidden"></div>
<footer class="dialog-actions"><span id="compose-status" class="muted"></span><button type="button" class="button quiet close-dialog">Cancel</button><button id="send-btn" type="submit" class="button primary"><svg><use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-send"></use></svg><span>Send</span></button></footer>
</form>
</dialog>
<dialog id="markdown-preview-dialog" class="dialog markdown-preview-dialog">
<div class="dialog-card markdown-preview-card">
<header class="dialog-header"><strong>Markdown preview</strong><button type="button" class="icon-button close-dialog" aria-label="Close"><svg><use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-x"></use></svg></button></header>
<article id="markdown-preview-content" class="markdown-preview-content"></article>
</div>
</dialog>
<dialog id="image-edit-dialog" class="dialog image-edit-dialog">
<form id="image-edit-form" class="dialog-card image-edit-card">
<header class="dialog-header"><strong>Edit inline image</strong><button type="button" class="icon-button close-dialog" aria-label="Close"><svg><use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-x"></use></svg></button></header>
<div class="image-edit-layout">
<div class="image-edit-preview"><canvas id="image-edit-preview" width="640" height="360" aria-label="Image crop preview"></canvas></div>
<div class="image-edit-controls">
<label><span>Display width</span><div class="image-edit-range-line"><input id="image-edit-width" type="range" min="10" max="100" step="5" value="100"><output id="image-edit-width-value">100%</output></div></label>
<label><span>Position</span><select id="image-edit-align"><option value="inline">Inline</option><option value="left">Left</option><option value="center">Center</option><option value="right">Right</option></select></label>
<fieldset id="image-crop-controls" class="image-crop-controls">
<legend>Crop / cut</legend>
<label><span>Top</span><div class="image-edit-range-line"><input id="image-crop-top" type="range" min="0" max="45" step="1" value="0"><output id="image-crop-top-value">0%</output></div></label>
<label><span>Right</span><div class="image-edit-range-line"><input id="image-crop-right" type="range" min="0" max="45" step="1" value="0"><output id="image-crop-right-value">0%</output></div></label>
<label><span>Bottom</span><div class="image-edit-range-line"><input id="image-crop-bottom" type="range" min="0" max="45" step="1" value="0"><output id="image-crop-bottom-value">0%</output></div></label>
<label><span>Left</span><div class="image-edit-range-line"><input id="image-crop-left" type="range" min="0" max="45" step="1" value="0"><output id="image-crop-left-value">0%</output></div></label>
</fieldset>
<p id="image-edit-help" class="image-edit-help">Resizing changes only the displayed size. Cropping creates a new CID image and replaces the previous inline attachment.</p>
</div>
</div>
<footer class="dialog-actions"><button id="image-edit-reset" type="button" class="button quiet">Reset crop</button><button type="button" class="button quiet close-dialog">Cancel</button><button id="image-edit-apply" type="submit" class="button primary">Apply</button></footer>
</form>
</dialog>
<dialog id="folder-dialog" class="dialog">
<div class="dialog-card folder-card">
<header class="dialog-header"><strong>Folders</strong><button type="button" class="icon-button close-dialog" aria-label="Close"><svg><use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-x"></use></svg></button></header>
<section class="settings-section"><label><span>Create folder</span><div class="inline-field"><input id="new-folder-name" placeholder="Folder name"><button id="create-folder-btn" type="button" class="button primary">Create</button></div></label></section>
<section class="settings-section"><label><span>Current folder</span><input id="current-folder-name" readonly></label><div class="inline-actions"><button id="subscribe-folder-btn" type="button" class="button quiet">Subscribe / Unsubscribe</button><button id="rename-folder-btn" type="button" class="button quiet">Rename</button><button id="delete-folder-btn" type="button" class="button quiet danger">Delete</button></div></section>
</div>
</dialog>
<dialog id="settings-dialog" class="dialog settings-dialog">
<div class="dialog-card settings-card">
<header class="dialog-header"><strong>Settings</strong><button type="button" class="icon-button close-dialog" aria-label="Close"><svg><use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-x"></use></svg></button></header>
<div class="settings-layout">
<nav class="settings-nav"><button class="settings-tab active" data-tab="appearance" type="button" data-i18n="appearance">Appearance</button><button class="settings-tab" data-tab="mailbox" type="button">Mailbox</button><button class="settings-tab" data-tab="composing" type="button">Composing</button><button class="settings-tab" data-tab="folders" type="button">Folders</button><button class="settings-tab" data-tab="identities" type="button">Identities & signatures</button><button class="settings-tab" data-tab="notifications" type="button">Notifications</button><button class="settings-tab" data-tab="openpgp" type="button">OpenPGP</button><button class="settings-tab" data-tab="sieve" type="button" data-i18n="filters">Filters</button><button class="settings-tab" data-tab="diagnostics" type="button">Diagnostics</button><button class="settings-tab" data-tab="account" type="button">Account</button><button class="settings-tab" data-tab="about" type="button" data-i18n="about">About</button></nav>
<div class="settings-content">
<section class="settings-panel" data-panel="appearance">
<div class="settings-panel-heading"><div><h2 data-i18n="appearance">Appearance</h2><p class="muted">Personalize RustBox without changing mail behavior.</p></div><button id="pref-reset" class="button quiet" type="button" data-i18n="reset_appearance">Reset appearance</button></div>
<div class="appearance-presets" aria-label="Interface presets">
<button type="button" class="appearance-preset" data-appearance-preset="compact" data-preset-label="Compact"><strong>Compact</strong><span>More mail on screen, narrow panes and smaller controls.</span></button>
<button type="button" class="appearance-preset" data-appearance-preset="balanced" data-preset-label="Balanced"><strong>Balanced</strong><span>Default proportions for everyday desktop use.</span></button>
<button type="button" class="appearance-preset" data-appearance-preset="comfortable" data-preset-label="Comfortable"><strong>Comfortable</strong><span>Larger controls and more breathing room.</span></button>
<button type="button" class="appearance-preset" data-appearance-preset="reading" data-preset-label="Reading"><strong>Reading</strong><span>Larger text with a wide, bottom preview.</span></button>
</div>
<div class="settings-grid appearance-grid">
<label><span data-i18n="language">Language</span><select id="pref-language"><option value="en">English</option><option value="pl">Polski</option></select></label>
<label><span data-i18n="theme">Theme</span><select id="pref-theme"><option value="dark">Dark</option><option value="light">Light</option><option value="system">System</option></select></label>
<label><span data-i18n="color_preset">Color preset</span><select id="pref-color-preset"><option value="sage">Sage</option><option value="blue">Blue</option><option value="teal">Teal</option><option value="cyan">Cyan</option><option value="indigo">Indigo</option><option value="slate">Slate</option><option value="graphite">Graphite</option><option value="amber">Amber</option><option value="orange">Orange</option><option value="violet">Violet</option><option value="rose">Rose</option><option value="red">Red</option><option value="custom">Custom</option></select></label>
<label><span data-i18n="accent_color">Accent color</span><div class="color-input"><input id="pref-accent" type="color" value="#6f9c86"><output id="pref-accent-value">#6f9c86</output></div></label>
<label><span>Custom background</span><div class="color-input"><input id="pref-background" type="color" value="#121513"><output id="pref-background-value">#121513</output></div></label>
<label><span>Custom panel</span><div class="color-input"><input id="pref-panel" type="color" value="#181c19"><output id="pref-panel-value">#181c19</output></div></label>
<label><span>Custom text</span><div class="color-input"><input id="pref-text-color" type="color" value="#edf0ec"><output id="pref-text-color-value">#edf0ec</output></div></label>
<label><span data-i18n="density">Density</span><select id="pref-density"><option value="compact">Compact</option><option value="comfortable">Comfortable</option></select></label>
<label><span data-i18n="preview_pane">Preview pane</span><select id="pref-preview"><option value="right">Right</option><option value="bottom">Bottom</option><option value="off">Off</option></select></label>
<label><span data-i18n="ui_size">UI size</span><select id="pref-scale"><option value="small">Small</option><option value="normal">Normal</option><option value="large">Large</option></select></label>
<label><span data-i18n="font_family">Font family</span><select id="pref-font-family"><option value="system">System UI</option><option value="inter">Inter</option><option value="humanist">Humanist</option><option value="mono">Monospace</option></select></label>
<label><span><span data-i18n="font_size">Font size</span> <output id="pref-font-size-value">13 px</output></span><input id="pref-font-size" type="range" min="11" max="18" step="1" value="13"></label>
<label><span><span data-i18n="row_height">Message row height</span> <output id="pref-row-height-value">58 px</output></span><input id="pref-row-height" type="range" min="42" max="88" step="2" value="58"></label>
<label><span><span data-i18n="folder_width">Folder pane width</span> <output id="pref-folder-width-value">228 px</output></span><input id="pref-folder-width" type="range" min="180" max="360" step="4" value="228"></label>
<label><span><span data-i18n="message_width">Message pane width</span> <output id="pref-message-width-value">420 px</output></span><input id="pref-message-width" type="range" min="300" max="720" step="10" value="420"></label>
<label><span><span data-i18n="corner_radius">Corner radius</span> <output id="pref-radius-value">4 px</output></span><input id="pref-radius" type="range" min="0" max="12" step="1" value="4"></label>
<label><span data-i18n="contrast">Contrast</span><select id="pref-contrast"><option value="soft">Soft</option><option value="normal">Normal</option><option value="high">High</option></select></label>
<label><span>Date format</span><select id="pref-date-format"><option value="locale">Locale</option><option value="iso">YYYY-MM-DD</option><option value="eu">DD/MM/YYYY</option><option value="us">MM/DD/YYYY</option></select></label>
<label><span>Time format</span><select id="pref-time-format"><option value="locale">Locale</option><option value="24h">24-hour</option><option value="12h">12-hour</option></select></label>
</div>
<div class="appearance-toggles">
<label class="checkbox-row"><input id="pref-avatars" type="checkbox"><span data-i18n="show_avatars">Show avatars</span></label>
<label class="checkbox-row"><input id="pref-attachment-icons" type="checkbox"><span data-i18n="show_attachment_icons">Show attachment icons</span></label>
<label class="checkbox-row"><input id="pref-flags" type="checkbox"><span data-i18n="show_flags">Show flags</span></label>
<label class="checkbox-row"><input id="pref-reduce-motion" type="checkbox"><span data-i18n="reduce_motion">Reduce motion</span></label>
</div>
<details class="appearance-advanced">
<summary>Advanced interface styling</summary>
<div class="settings-grid appearance-grid">
<label><span>Icon size <output id="pref-icon-size-value">18 px</output></span><input id="pref-icon-size" type="range" min="14" max="28" step="1" value="18"></label>
<label><span>Toolbar density</span><select id="pref-toolbar-density"><option value="compact">Compact</option><option value="normal">Normal</option><option value="spacious">Spacious</option></select></label>
<label><span>Sidebar item height <output id="pref-sidebar-height-value">29 px</output></span><input id="pref-sidebar-height" type="range" min="24" max="48" step="1" value="29"></label>
<label><span>Reader max width</span><select id="pref-reader-width"><option value="0">Fluid</option><option value="680">680 px</option><option value="820">820 px</option><option value="980">980 px</option><option value="1200">1200 px</option></select></label>
<label><span>Composer width <output id="pref-composer-width-value">900 px</output></span><input id="pref-composer-width" type="range" min="640" max="1400" step="20" value="900"></label>
<label><span>Selection color</span><div class="color-input"><input id="pref-selection-color" type="color" value="#28352e"><output id="pref-selection-color-value">#28352e</output></div></label>
<label><span>Unread weight <output id="pref-unread-weight-value">730</output></span><input id="pref-unread-weight" type="range" min="500" max="800" step="10" value="730"></label>
<label><span>Unread marker</span><select id="pref-unread-badge"><option value="dot">Dot</option><option value="outline">Outline</option><option value="solid">Solid</option></select></label>
<label><span>Scrollbar</span><select id="pref-scrollbar-density"><option value="thin">Thin</option><option value="normal">Normal</option><option value="hidden">Hidden</option></select></label>
</div>
<div class="appearance-toggles">
<label class="checkbox-row"><input id="pref-compact-headers" type="checkbox"><span>Compact message headers</span></label>
<label class="checkbox-row"><input id="pref-show-borders" type="checkbox"><span>Show panel borders</span></label>
<label class="checkbox-row"><input id="pref-monochrome" type="checkbox"><span>Monochrome interface</span></label>
</div>
<div class="pane-background-grid">
<label class="pane-background-control"><span><input id="pref-folder-bg-enabled" type="checkbox"> Folder background override</span><input id="pref-folder-bg" type="color" value="#101310"></label>
<label class="pane-background-control"><span><input id="pref-message-bg-enabled" type="checkbox"> Message-list background override</span><input id="pref-message-bg" type="color" value="#181c19"></label>
<label class="pane-background-control"><span><input id="pref-reader-bg-enabled" type="checkbox"> Reader background override</span><input id="pref-reader-bg" type="color" value="#181c19"></label>
</div>
</details>
</section>
<section class="settings-panel hidden" data-panel="mailbox">
<div class="settings-panel-heading"><div><h2>Mailbox</h2><p class="muted">Control message-list and reader behavior for this account.</p></div></div>
<div class="settings-grid">
<label><span>Messages per page</span><select id="mail-pref-per-page"><option value="20">20</option><option value="50">50</option><option value="100">100</option><option value="200">200</option></select></label>
<label><span>Mark as read</span><select id="mail-pref-read-delay"><option value="0">Immediately</option><option value="750">After 0.75 s</option><option value="1500">After 1.5 s</option><option value="3000">After 3 s</option><option value="5000">After 5 s</option></select></label>
<label><span>Remote images</span><select id="mail-pref-remote-images"><option value="never">Never</option><option value="contacts">Contacts only</option><option value="ask">Ask for each message</option><option value="always">Always load</option></select></label>
<label><span>Message body</span><select id="mail-pref-preferred-body"><option value="auto">Auto</option><option value="html">Prefer HTML</option><option value="text">Plain text</option></select></label>
<label><span>Threads</span><select id="mail-pref-thread-mode"><option value="threaded">Threaded</option><option value="flat">Flat</option></select></label>
<label><span>Default sort</span><select id="mail-pref-sort"><option value="date_desc">Newest first</option><option value="date_asc">Oldest first</option><option value="sender_asc">Sender A–Z</option><option value="sender_desc">Sender Z–A</option><option value="subject_asc">Subject A–Z</option><option value="subject_desc">Subject Z–A</option><option value="size_asc">Smallest first</option><option value="size_desc">Largest first</option></select></label>
</div>
<div class="appearance-toggles"><label class="checkbox-row"><input id="mail-pref-collapse-quotes" type="checkbox"><span>Collapse quoted text where possible</span></label><label class="checkbox-row"><input id="mail-pref-show-snippets" type="checkbox"><span>Show message snippets in the list</span></label><label class="checkbox-row"><input id="mail-pref-flagged-top" type="checkbox"><span>Keep flagged messages on top by default</span></label><label class="checkbox-row"><input id="mail-pref-sync-keywords" type="checkbox"><span>Synchronize RustBox labels as IMAP keywords</span></label></div><p class="muted">Keyword synchronization is used only in folders whose IMAP server advertises persistent custom flags. Local labels remain available everywhere.</p>
<div class="inline-actions"><button id="mail-pref-save" class="button primary" type="button">Save mailbox settings</button><button id="mail-pref-reconcile-labels" class="button quiet" type="button">Reconcile IMAP labels now</button><span id="mail-pref-reconcile-status" class="muted"></span></div>
</section>
<section class="settings-panel hidden" data-panel="composing">
<div class="settings-panel-heading"><div><h2>Composing</h2><p class="muted">Defaults for new messages and draft behavior.</p></div></div>
<div class="settings-grid">
<label><span>Default format</span><select id="compose-pref-format"><option value="html">HTML</option><option value="text">Plain text</option><option value="markdown">Markdown</option></select></label>
<label><span>Autosave interval</span><select id="compose-pref-autosave"><option value="1000">1 second</option><option value="3000">3 seconds</option><option value="5000">5 seconds</option><option value="10000">10 seconds</option><option value="20000">20 seconds</option><option value="60000">60 seconds</option></select></label>
<label><span>Default priority</span><select id="compose-pref-priority"><option value="normal">Normal</option><option value="high">High</option><option value="low">Low</option></select></label>
<label><span>Default message font</span><select id="compose-pref-font"><option value="system">System sans</option><option value="humanist">Humanist sans</option><option value="serif">Serif</option><option value="mono">Monospace</option></select></label>
<label><span>Reply position</span><select id="compose-pref-reply-position"><option value="above">Write above quote</option><option value="below">Write below quote</option></select></label>
<label><span>Quote style</span><select id="compose-pref-quote-style"><option value="standard">Standard attribution</option><option value="compact">Compact attribution</option><option value="none">Do not include quote</option></select></label>
<label><span>Forward button</span><select id="compose-pref-forward-mode"><option value="inline">Forward inline</option><option value="attachment">Forward as .eml attachment</option></select></label>
</div>
<div class="appearance-toggles">
<label class="checkbox-row"><input id="compose-pref-mdn" type="checkbox"><span>Request read receipt by default</span></label>
<label class="checkbox-row"><input id="compose-pref-attachment-reminder" type="checkbox"><span>Warn when message text suggests a missing attachment</span></label>
<label class="checkbox-row"><input id="compose-pref-spellcheck" type="checkbox"><span>Use browser spellcheck while composing</span></label>
</div>
<p class="muted">Markdown is stored as source text and sent as a safe rendered HTML alternative with a plain-text fallback.</p>
</section>
<section class="settings-panel hidden" data-panel="folders">
<div class="settings-panel-heading"><div><h2>Folders</h2><p class="muted">Choose special IMAP folders used by RustBox. Values are stored per account.</p></div><button id="special-folders-auto" class="button quiet" type="button">Auto-detect</button></div>
<div class="settings-grid">
<label><span>Sent</span><select id="special-sent"></select></label>
<label><span>Drafts</span><select id="special-drafts"></select></label>
<label><span>Trash</span><select id="special-trash"></select></label>
<label><span>Default spam destination</span><select id="spam-default-folder"></select><small class="muted">Shared by all accounts in this browser when that folder exists.</small></label>
<label><span>Spam destination override (this account)</span><select id="special-junk"></select><small class="muted">Leave automatic to use the default above, then IMAP Junk/Spam auto-detection.</small></label>
<label><span>Archive</span><select id="special-archive"></select></label>
</div>
<p class="muted">Sent, Trash, Spam/Junk and Archive are used directly by mail actions. The spam destination resolves in this order: account override → browser default → IMAP Junk/Spam auto-detection.</p>
<div id="quota-summary" class="quota-summary"><strong>Mailbox storage</strong><span>Loading quota…</span><div class="quota-track"><span id="quota-bar"></span></div></div>
<div class="inline-actions"><button id="special-folders-save" class="button primary" type="button">Save folder settings</button></div>
</section>
<section class="settings-panel hidden" data-panel="identities">
<div class="settings-panel-heading"><div><h2>Identities & signatures</h2><p class="muted">Sender identities and account-wide signatures. Editing opens in a dedicated window so the form never collides with Settings.</p></div><button id="identity-new" class="button primary" type="button">New identity</button></div>
<div class="identity-overview">
<div class="identity-overview-hint"><strong>Configured identities</strong><span class="muted">Select an identity to edit its sender data, text/HTML footer, image and OpenPGP defaults.</span></div>
<div id="identity-list" class="identity-list"></div>
</div>
</section>
<section class="settings-panel hidden" data-panel="openpgp">
<div class="settings-panel-heading"><div><h2>OpenPGP</h2><p class="muted">Manage per-user keys, trust and discovery. Private keys never leave the server unless you explicitly export one.</p></div><button id="pgp-refresh" class="button quiet" type="button">Refresh</button></div>
<div class="settings-section">
<h3>Policy & discovery</h3>
<div class="appearance-toggles">
<label class="checkbox-row"><input id="pgp-pref-wkd" type="checkbox"><span>Discover keys with WKD</span></label>
<label class="checkbox-row"><input id="pgp-pref-hkp" type="checkbox"><span>Fallback to configured HTTPS key server</span></label>
<label class="checkbox-row"><input id="pgp-pref-trusted" type="checkbox"><span>Encrypt only to trusted keys</span></label>
</div>
<div class="inline-actions"><button id="pgp-pref-save" class="button primary" type="button">Save OpenPGP policy</button></div>
</div>
<div class="pgp-settings-grid">
<div class="settings-section">
<h3>Generate key</h3>
<label><span>Name</span><input id="pgp-generate-name" autocomplete="name"></label>
<label><span>Email</span><input id="pgp-generate-email" type="email" autocomplete="email"></label>
<label><span>Passphrase</span><input id="pgp-generate-passphrase" type="password" autocomplete="new-password" maxlength="1024"><small class="muted">Not stored by RustBox.</small></label>
<div class="inline-actions"><button id="pgp-generate" class="button primary" type="button">Generate key</button></div>
</div>
<div class="settings-section">
<h3>Import / discover</h3>
<label><span>Armored public/private key</span><textarea id="pgp-import-data" rows="6" spellcheck="false" placeholder="-----BEGIN PGP ... KEY BLOCK-----"></textarea></label>
<label><span>Initial trust</span><select id="pgp-import-trust"><option value="unknown">Unknown</option><option value="verified">Verified</option><option value="trusted">Trusted</option><option value="untrusted">Untrusted</option></select></label>
<div class="inline-actions"><button id="pgp-import" class="button quiet" type="button">Import</button><button id="pgp-import-file" class="button quiet" type="button">Import file</button><input id="pgp-import-file-input" type="file" accept=".asc,.pgp,application/pgp-keys,text/plain" hidden></div>
<label><span>Discover by email</span><input id="pgp-discover-email" type="email" autocomplete="email"></label>
<div class="inline-actions"><button id="pgp-discover" class="button quiet" type="button">Discover key</button></div>
</div>
</div>
<div class="settings-section"><div class="settings-panel-heading compact"><div><h3>Keyring</h3><p id="pgp-key-summary" class="muted">No keys loaded</p></div></div><div id="pgp-key-list" class="pgp-key-list"></div></div>
</section>
<section class="settings-panel hidden" data-panel="notifications">
<h2>Notifications</h2>
<div class="settings-section notification-settings">
<label class="checkbox-row"><input id="notify-enabled" type="checkbox"><span>Desktop notifications</span></label>
<label class="checkbox-row"><input id="notify-sound" type="checkbox"><span>Sound for new mail</span></label>
<label class="checkbox-row"><input id="notify-dnd-enabled" type="checkbox"><span>Do Not Disturb</span></label>
<div class="settings-grid"><label><span>DND from</span><input id="notify-dnd-start" type="time" value="22:00"></label><label><span>DND until</span><input id="notify-dnd-end" type="time" value="07:00"></label></div>
<div><span class="field-label">Folders</span><div id="notify-folders" class="notification-folder-list"></div></div>
<div class="inline-actions"><button id="notify-permission" class="button quiet" type="button">Browser permission</button><button id="notify-save" class="button primary" type="button">Save</button></div>
</div>
</section>
<section class="settings-panel hidden" data-panel="sieve">
<div class="sieve-header"><div><h2>ManageSieve filters</h2><p id="sieve-status" class="muted">Not checked</p></div><div class="inline-actions"><button id="sieve-backup" class="button quiet" type="button">Backup</button><button id="sieve-restore" class="button quiet" type="button">Restore</button><input id="sieve-restore-file" type="file" accept="application/json,.json" hidden><button id="sieve-refresh" class="button quiet" type="button">Refresh</button></div></div>
<div class="sieve-layout">
<div><div class="field-label">Scripts</div><div id="sieve-scripts" class="sieve-scripts"></div><button id="sieve-new" class="button quiet wide" type="button">New script</button></div>
<div class="sieve-editor-wrap">
<label><span>Script name</span><input id="sieve-name" placeholder="rustbox"></label>
<section class="sieve-rules-overview">
<div class="sieve-section-heading"><div><strong>Rules in this script</strong><small class="muted">Existing <code># rule:[name]</code> filters and RustBox rules are parsed automatically.</small></div><span id="sieve-rule-count" class="sieve-count-badge">0</span></div>
<div id="sieve-managed-rules" class="sieve-managed-rules"><div class="muted">No rules found in this script</div></div>
</section>
<div class="sieve-rule-launch"><button id="sieve-rule-new" class="button primary" type="button">Create filter</button><span class="muted">Rules are edited in a separate visual window with a live “what will happen” preview.</span></div>
<details class="sieve-builder sieve-vacation">
<summary>Autoresponder / vacation</summary>
<div class="sieve-builder-grid">
<label><span>Subject</span><input id="sieve-vacation-subject" value="Out of office"></label>
<label><span>Reply interval (days)</span><input id="sieve-vacation-days" type="number" min="1" max="30" value="7"></label>
<label><span>Active from</span><input id="sieve-vacation-start" type="date"></label>
<label><span>Active until</span><input id="sieve-vacation-end" type="date"></label>
<label class="sieve-builder-wide"><span>Message</span><textarea id="sieve-vacation-message" rows="4" placeholder="I am currently away..."></textarea></label>
<label class="checkbox-row"><input id="sieve-vacation-skip-lists" type="checkbox" checked><span>Skip mailing lists</span></label>
<label class="checkbox-row"><input id="sieve-vacation-skip-auto" type="checkbox" checked><span>Skip automated messages</span></label>
</div>
<div class="inline-actions"><button id="sieve-vacation-append" class="button quiet" type="button">Append autoresponder</button><span id="sieve-vacation-capability" class="muted"></span></div>
</details>
<details class="sieve-raw-source"><summary>Advanced Sieve source</summary><p class="muted">Use this only for syntax the visual editor does not understand. Parsed rules stay editable above.</p><textarea id="sieve-editor" class="code-editor" spellcheck="false"></textarea></details>
<div id="sieve-result" class="muted sieve-result"></div>
<div class="inline-actions"><button id="sieve-check" class="button quiet" type="button">Validate</button><button id="sieve-save" class="button primary" type="button">Save</button><button id="sieve-activate" class="button quiet" type="button">Activate</button><button id="sieve-deactivate" class="button quiet" type="button">Deactivate all</button><button id="sieve-delete" class="button quiet danger" type="button">Delete</button></div>
</div>
</div>
</section>
<section class="settings-panel hidden" data-panel="diagnostics">
<div class="settings-panel-heading"><div><h2>Diagnostics</h2><p class="muted">Live capability checks for the current account. Credentials and server host names are not returned to the browser.</p></div><button id="diagnostics-refresh" class="button quiet" type="button">Refresh</button></div>
<div class="diagnostics-grid">
<section class="diagnostic-card"><div class="section-heading"><span>IMAP</span><div class="inline-actions"><strong id="diagnostic-imap-status">Not checked</strong><button id="diagnostic-imap-reconnect" class="button quiet" type="button">Reconnect</button></div></div><div id="diagnostic-imap-summary" class="capability-grid"></div><div id="diagnostic-imap-caps" class="capability-list"></div></section>
<section class="diagnostic-card"><div class="section-heading"><span>SMTP</span><strong id="diagnostic-smtp-status">Not checked</strong></div><div id="diagnostic-smtp-summary" class="capability-list"></div></section>
<section class="diagnostic-card"><div class="section-heading"><span>ManageSieve</span><strong id="diagnostic-sieve-status">Not checked</strong></div><div id="diagnostic-sieve-summary" class="capability-list"></div></section>
<section class="diagnostic-card"><div class="section-heading"><span>Realtime</span><strong id="diagnostic-ws-status">Disconnected</strong></div><p class="muted">IMAP IDLE events are delivered to the browser through the RustBox WebSocket connection.</p></section>
<section class="diagnostic-card"><div class="section-heading"><span>Synchronization cache</span><strong id="diagnostic-sync-status">Not checked</strong></div><div id="diagnostic-sync-summary" class="capability-list"></div></section>
<section class="diagnostic-card"><div class="section-heading"><span>Search index</span><strong id="diagnostic-search-status">Not checked</strong></div><div id="diagnostic-search-summary" class="capability-list"></div><div class="inline-actions"><button id="diagnostic-search-rebuild" class="button quiet" type="button">Rebuild index</button></div></section>
</div>
</section>
<section class="settings-panel hidden" data-panel="account">
<div class="settings-panel-heading"><div><h2>Account</h2><p class="muted">Accounts signed in to this RustBox browser session.</p></div><button id="account-add" class="button quiet" type="button">Add account</button></div>
<div id="account-settings-list" class="account-settings-list"></div>
<div class="settings-panel-heading account-session-heading"><div><h3>Active sessions</h3><p class="muted">Live RustBox sessions for this account, including other browsers while the server is running.</p></div><button id="account-revoke-others" class="button quiet danger" type="button">Revoke other sessions</button></div>
<div id="account-session-list" class="account-settings-list"></div>
</section>
<section class="settings-panel hidden" data-panel="about">
<h2>RustBox __RUSTBOX_VERSION__</h2>
<p>Webmail based on the Oxi codebase, with a RustBox-owned interface compiled into the Rust executable.</p>
<dl class="about-list"><dt>Upstream</dt><dd>c0h1b4/oxi</dd><dt>Oxi base</dt><dd><code>__RUSTBOX_OXI_BASE__</code></dd><dt>Frontend</dt><dd>HTML, CSS and vanilla JavaScript embedded at Cargo build time</dd><dt>Author</dt><dd>@linuxiarz.pl</dd></dl>
<div class="inline-actions"><button id="shortcuts-open" class="button quiet" type="button">Keyboard shortcuts</button></div>
</section>
</div>
</div>
</div>
</dialog>
<dialog id="identity-dialog" class="dialog identity-dialog">
<form id="identity-form" class="dialog-card identity-card">
<header class="dialog-header"><strong id="identity-dialog-title">Identity & signature</strong><button type="button" class="icon-button close-dialog" aria-label="Close"><svg><use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-x"></use></svg></button></header>
<div class="identity-form-body">
<input id="identity-id" type="hidden">
<section class="identity-modal-section">
<div class="identity-modal-heading"><strong>Sender identity</strong><span class="muted">Address and headers used when this identity is selected in the composer.</span></div>
<div class="identity-fields-grid">
<label><span>Display name</span><input id="identity-name" autocomplete="name"></label>
<label><span>Email</span><input id="identity-email" type="email" required autocomplete="email"></label>
<label><span>Reply-To</span><input id="identity-reply-to" type="email" autocomplete="email"></label>
<label><span>Automatic Bcc</span><input id="identity-bcc" placeholder="archive@example.com"></label>
<label class="identity-field-wide"><span>Organization</span><input id="identity-organization"></label>
</div>
</section>
<section class="identity-modal-section">
<div class="identity-modal-heading"><strong>Signature / footer</strong><span class="muted">Maintain separate text and HTML versions. Imported images are embedded into outgoing mail as inline CID images.</span></div>
<div class="identity-signature-grid">
<label><span>Plain-text signature / footer</span><textarea id="identity-signature-text" rows="8" placeholder="Text-only signature used in plain-text messages"></textarea></label>
<label class="identity-signature-field"><span>HTML signature / footer</span><div class="signature-editor-shell"><div class="signature-editor-toolbar" aria-label="Signature formatting"><button type="button" data-signature-command="bold" title="Bold"><svg><use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-bold"></use></svg></button><button type="button" data-signature-command="italic" title="Italic"><svg><use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-italic"></use></svg></button><button type="button" data-signature-command="underline" title="Underline"><svg><use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-underline"></use></svg></button><button type="button" id="identity-signature-link" title="Insert link"><svg><use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-link"></use></svg></button><button type="button" id="identity-signature-image-btn" title="Import image"><svg><use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-image"></use></svg></button><button type="button" data-signature-command="removeFormat" title="Clear formatting"><svg><use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-remove-format"></use></svg></button></div><div id="identity-signature" class="signature-editor" contenteditable="true" role="textbox" aria-multiline="true"></div></div><input id="identity-signature-image" type="file" accept="image/png,image/jpeg,image/gif,image/webp" hidden><small class="muted signature-help">You can also paste an image directly into this editor.</small></label>
</div>
<div class="identity-options-grid">
<label><span>Signature position</span><select id="identity-signature-position"><option value="above">Above quoted text</option><option value="below">Below quoted text</option></select></label>
<label id="identity-pgp-key-row"><span>OpenPGP key</span><select id="identity-pgp-key"><option value="">Automatic / none</option></select></label>
</div>
</section>
<section class="identity-modal-section identity-modal-options">
<div class="identity-modal-heading"><strong>Defaults</strong><span class="muted">Choose where this signature is inserted and, when OpenPGP is configured, its default behavior.</span></div>
<div class="identity-toggle-grid">
<label class="checkbox-row"><input id="identity-signature-new" type="checkbox"><span>Signature on new messages</span></label>
<label class="checkbox-row"><input id="identity-signature-reply" type="checkbox"><span>Signature on replies</span></label>
<label class="checkbox-row"><input id="identity-signature-forward" type="checkbox"><span>Signature on forwards</span></label>
<label class="checkbox-row"><input id="identity-default" type="checkbox"><span>Default identity</span></label>
<label class="checkbox-row identity-pgp-default"><input id="identity-pgp-sign" type="checkbox"><span>Sign by default with OpenPGP</span></label>
<label class="checkbox-row identity-pgp-default"><input id="identity-pgp-encrypt" type="checkbox"><span>Encrypt by default with OpenPGP</span></label>
<label class="checkbox-row identity-pgp-default"><input id="identity-pgp-inline" type="checkbox"><span>Use inline OpenPGP by default</span></label>
</div>
</section>
</div>
<footer class="dialog-actions identity-dialog-actions"><button id="identity-delete" type="button" class="button quiet danger">Delete</button><button type="button" class="button quiet close-dialog">Cancel</button><button type="submit" class="button primary">Save identity</button></footer>
</form>
</dialog>
<dialog id="sieve-rule-dialog" class="dialog sieve-rule-dialog">
<div class="dialog-card sieve-rule-card">
<header class="dialog-header"><div><strong id="sieve-rule-dialog-title">Create filter</strong><span id="sieve-rule-dialog-subtitle" class="muted sieve-rule-dialog-subtitle">Build the rule visually and verify the preview before applying it.</span></div><button type="button" class="icon-button close-dialog" aria-label="Close"><svg><use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-x"></use></svg></button></header>
<div class="sieve-rule-dialog-body">
<div class="sieve-builder sieve-builder-modal">
<div class="sieve-builder-body">
<section class="sieve-builder-section">
<div class="sieve-section-heading"><div><strong>Rule</strong><small class="muted">Give it a readable name and decide whether it is active.</small></div></div>
<div class="sieve-builder-grid sieve-builder-grid-rule">
<label><span>Rule name</span><input id="sieve-builder-name" placeholder="allegro"></label>
<label class="checkbox-row"><input id="sieve-builder-enabled" type="checkbox" checked><span>Rule enabled</span></label>
<label class="checkbox-row"><input id="sieve-builder-stop" type="checkbox" checked><span>Stop after this rule</span></label>
</div>
</section>
<section class="sieve-builder-section">
<div class="sieve-section-heading"><div><strong>When</strong><small class="muted">Choose what the incoming message must match.</small></div></div>
<div class="sieve-builder-grid">
<label><span>Condition</span><select id="sieve-builder-condition"><option value="from">From address</option><option value="from-domain">From domain</option><option value="to">To / Cc address</option><option value="header-from">From header</option><option value="header-to">To header</option><option value="subject">Subject</option><option value="list-id">List-ID</option><option value="header">Custom header</option></select></label>
<label><span>Match</span><select id="sieve-builder-match"><option value="contains">contains</option><option value="is">is exactly</option><option value="matches">wildcard matches</option></select></label>
<label id="sieve-builder-header-wrap" class="hidden"><span>Header name</span><input id="sieve-builder-header" placeholder="X-Spam-Status"></label>
<label><span>Value</span><input id="sieve-builder-value" placeholder="example.com"></label>
<label class="checkbox-row"><input id="sieve-builder-negate" type="checkbox"><span>NOT / negate</span></label>
<label><span>Join next condition</span><select id="sieve-builder-logic"><option value="allof">AND</option><option value="anyof">OR</option></select></label>
<label><span>Second condition</span><select id="sieve-builder-condition-2"><option value="">None</option><option value="from">From address</option><option value="from-domain">From domain</option><option value="to">To / Cc address</option><option value="header-from">From header</option><option value="header-to">To header</option><option value="subject">Subject</option><option value="list-id">List-ID</option><option value="header">Custom header</option></select></label>
<label><span>Match</span><select id="sieve-builder-match-2"><option value="contains">contains</option><option value="is">is exactly</option><option value="matches">wildcard matches</option></select></label>
<label id="sieve-builder-header-wrap-2" class="hidden"><span>Header name</span><input id="sieve-builder-header-2" placeholder="X-Spam-Status"></label>
<label><span>Value</span><input id="sieve-builder-value-2" placeholder="optional"></label>
<label class="checkbox-row"><input id="sieve-builder-negate-2" type="checkbox"><span>NOT / negate</span></label>
<label><span>Join third condition</span><select id="sieve-builder-logic-3"><option value="allof">AND</option><option value="anyof">OR</option></select></label>
<label><span>Third condition</span><select id="sieve-builder-condition-3"><option value="">None</option><option value="from">From address</option><option value="from-domain">From domain</option><option value="to">To / Cc address</option><option value="header-from">From header</option><option value="header-to">To header</option><option value="subject">Subject</option><option value="list-id">List-ID</option><option value="header">Custom header</option></select></label>
<label><span>Match</span><select id="sieve-builder-match-3"><option value="contains">contains</option><option value="is">is exactly</option><option value="matches">wildcard matches</option></select></label>
<label id="sieve-builder-header-wrap-3" class="hidden"><span>Header name</span><input id="sieve-builder-header-3" placeholder="X-Spam-Status"></label>
<label><span>Value</span><input id="sieve-builder-value-3" placeholder="optional"></label>
<label class="checkbox-row"><input id="sieve-builder-negate-3" type="checkbox"><span>NOT / negate</span></label>
<div id="sieve-builder-extra-conditions" class="sieve-builder-dynamic-list sieve-builder-wide"></div>
<div class="inline-actions sieve-builder-wide"><button id="sieve-builder-add-condition" class="button quiet small" type="button">+ Add condition</button></div>
</div>
</section>
<section class="sieve-builder-section">
<div class="sieve-section-heading"><div><strong>Then</strong><small class="muted">Actions run in the order shown below.</small></div></div>
<div class="sieve-builder-grid">
<label><span>Action</span><select id="sieve-builder-action"><option value="move">Move to folder</option><option value="copy">Copy to folder</option><option value="redirect">Redirect</option><option value="redirect-copy">Redirect and keep copy</option><option value="discard">Discard</option><option value="mark-read">Mark read</option><option value="add-flag">Add IMAP flag</option><option value="set-flags">Set IMAP flags</option><option value="keep">Keep</option></select></label>
<label id="sieve-builder-target-wrap"><span id="sieve-builder-target-label">Folder</span><input id="sieve-builder-target" list="sieve-folder-options" placeholder="Archive"><datalist id="sieve-folder-options"></datalist></label>
<label><span>Second action</span><select id="sieve-builder-action-2"><option value="">None</option><option value="move">Move to folder</option><option value="copy">Copy to folder</option><option value="redirect">Redirect</option><option value="redirect-copy">Redirect and keep copy</option><option value="discard">Discard</option><option value="mark-read">Mark read</option><option value="add-flag">Add IMAP flag</option><option value="set-flags">Set IMAP flags</option><option value="keep">Keep</option></select></label>
<label id="sieve-builder-target-wrap-2" class="hidden"><span id="sieve-builder-target-label-2">Folder</span><input id="sieve-builder-target-2" list="sieve-folder-options" placeholder="Archive"></label>
<div id="sieve-builder-extra-actions" class="sieve-builder-dynamic-list sieve-builder-wide"></div>
<div class="inline-actions sieve-builder-wide"><button id="sieve-builder-add-action" class="button quiet small" type="button">+ Add action</button></div>
</div>
</section>
<section class="sieve-rule-preview" aria-live="polite">
<div class="sieve-section-heading"><div><strong>What will happen</strong><small class="muted">Live explanation of the rule before you save it.</small></div><span id="sieve-builder-preview-state" class="sieve-status-pill">Ready</span></div>
<div id="sieve-builder-preview-text" class="sieve-rule-preview-text">Complete the rule to see its effect.</div>
<pre id="sieve-builder-preview-code" class="sieve-rule-preview-code"></pre>
</section>
<div class="sieve-builder-footer">
<div class="inline-actions"><button id="sieve-builder-append" class="button primary" type="button">Add rule</button><button id="sieve-builder-replace" class="button quiet" type="button" disabled>Update selected rule</button><button id="sieve-builder-clear" class="button quiet" type="button">Clear</button></div>
<span id="sieve-builder-capability" class="muted"></span>
</div>
</div>
</div>
</div>
</div>
</dialog>
<dialog id="shortcuts-dialog" class="dialog">
<div class="dialog-card shortcuts-card">
<header class="dialog-header"><strong>Keyboard shortcuts</strong><button type="button" class="icon-button close-dialog" aria-label="Close"><svg><use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-x"></use></svg></button></header>
<div class="shortcuts-grid">
<section><h3>Global</h3><dl><dt><kbd>/</kbd></dt><dd>Focus search</dd><dt><kbd>C</kbd> / <kbd>Ctrl</kbd> + <kbd>N</kbd></dt><dd>New message</dd><dt><kbd>V</kbd></dt><dd>Toggle selection mode</dd><dt><kbd>?</kbd></dt><dd>Show shortcuts</dd></dl></section>
<section><h3>Message</h3><dl><dt><kbd>J</kbd> / <kbd>K</kbd></dt><dd>Next / previous message</dd><dt><kbd>X</kbd></dt><dd>Select / unselect current message</dd><dt><kbd>R</kbd></dt><dd>Reply</dd><dt><kbd>A</kbd></dt><dd>Reply all</dd><dt><kbd>F</kbd></dt><dd>Forward</dd><dt><kbd>E</kbd></dt><dd>Archive</dd><dt><kbd>M</kbd></dt><dd>Read / unread</dd><dt><kbd>S</kbd></dt><dd>Flag / unflag</dd><dt><kbd>!</kbd></dt><dd>Spam / not spam</dd><dt><kbd>Delete</kbd></dt><dd>Delete / move to Trash</dd></dl></section>
</div>
<footer class="dialog-actions"><button type="button" class="button primary close-dialog">Close</button></footer>
</div>
</dialog>
<dialog id="tag-dialog" class="dialog">
<div class="dialog-card tag-card">
<header class="dialog-header"><strong>Label</strong><button type="button" class="icon-button close-dialog" aria-label="Close"><svg><use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-x"></use></svg></button></header>
<form id="tag-form" class="tag-form">
<input id="tag-id" type="hidden">
<label><span>Name</span><input id="tag-name" maxlength="60" required></label>
<label><span>Color</span><input id="tag-color" type="color" value="#6b7280"></label>
<footer class="dialog-actions"><button id="tag-delete" type="button" class="button quiet danger">Delete</button><span class="toolbar-spacer"></span><button type="button" class="button quiet close-dialog">Cancel</button><button type="submit" class="button primary">Save</button></footer>
</form>
</div>
</dialog>
<dialog id="advanced-search-dialog" class="dialog">
<div class="dialog-card advanced-search-card">
<header class="dialog-header"><strong>Advanced search</strong><button type="button" class="icon-button close-dialog" aria-label="Close"><svg><use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-x"></use></svg></button></header>
<form id="advanced-search-form" class="advanced-search-form">
<div class="settings-grid advanced-search-grid">
<label><span>Contains</span><input id="search-text" autocomplete="off"></label>
<label><span>Folder</span><select id="search-folder"><option value="">All folders</option></select></label>
<label><span>From</span><input id="search-from" autocomplete="off"></label>
<label><span>To</span><input id="search-to" autocomplete="off"></label>
<label class="wide-field"><span>Subject</span><input id="search-subject" autocomplete="off"></label>
<label><span>Date from</span><input id="search-date-from" type="date"></label>
<label><span>Date to</span><input id="search-date-to" type="date"></label>
<label><span>Read status</span><select id="search-read"><option value="">Any</option><option value="unread">Unread</option><option value="read">Read</option></select></label>
<label><span>Flag</span><select id="search-flagged"><option value="">Any</option><option value="true">Flagged</option><option value="false">Not flagged</option></select></label>
<label><span>Attachments</span><select id="search-attachment"><option value="">Any</option><option value="true">Has attachment</option><option value="false">No attachment</option></select></label>
<label><span>Sort</span><select id="search-sort"><option value="date_desc">Newest first</option><option value="date_asc">Oldest first</option></select></label>
</div>
<div class="saved-search-section">
<div class="section-heading"><span>Saved searches</span></div>
<div id="saved-search-list" class="saved-search-list"></div>
</div>
<footer class="dialog-actions advanced-search-actions">
<button id="search-reset-btn" type="button" class="button quiet">Reset</button>
<button id="search-save-btn" type="button" class="button quiet">Save search</button>
<span class="toolbar-spacer"></span>
<button type="button" class="button quiet close-dialog">Cancel</button>
<button type="submit" class="button primary">Search</button>
</footer>
</form>
</div>
</dialog>
<dialog id="contacts-dialog" class="dialog">
<div class="dialog-card contacts-card">
<header class="dialog-header"><strong>Contacts</strong><button type="button" class="icon-button close-dialog" aria-label="Close"><svg><use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-x"></use></svg></button></header>
<div class="contacts-layout">
<div class="contacts-sidebar">
<div class="contact-group-toolbar"><select id="contact-group-filter"><option value="">All contacts</option></select><button id="contact-group-new" class="button quiet" type="button">New group</button><button id="contact-group-rename" class="button quiet" type="button">Rename</button><button id="contact-group-delete" class="button quiet danger" type="button">Delete</button></div>
<div class="inline-field"><input id="contact-search" placeholder="Search contacts"><button id="contact-new" class="button quiet" type="button">New</button></div>
<div class="contact-transfer"><button id="contact-import" class="button quiet" type="button">Import vCard</button><input id="contact-import-file" type="file" accept=".vcf,text/vcard,text/x-vcard" hidden><button id="contact-export" class="button quiet" type="button">Export vCard</button><button id="contact-duplicates" class="button quiet" type="button">Find duplicates</button></div>
<div id="contact-list" class="contact-list"></div>
</div>
<form id="contact-form" class="contact-form">
<input id="contact-id" type="hidden">
<div class="contact-photo-editor">
<div id="contact-photo-placeholder" class="contact-photo-placeholder">Photo</div>
<img id="contact-photo-preview" class="contact-photo-preview hidden" alt="Contact photo">
<div class="inline-actions">
<button id="contact-photo-upload" type="button" class="button quiet" disabled>Upload photo</button>
<button id="contact-photo-delete" type="button" class="button quiet danger" disabled>Remove photo</button>
<input id="contact-photo-file" type="file" accept="image/png,image/jpeg,image/webp" hidden>
</div>
</div>
<label><span>Name</span><input id="contact-name"></label>
<label><span>Email</span><input id="contact-email" type="email" required></label>
<label><span>Other emails</span><textarea id="contact-emails" rows="2" placeholder="One address per line"></textarea></label>
<label><span>Phone</span><input id="contact-phone" type="tel"></label>
<label><span>Other phones</span><textarea id="contact-phones" rows="2" placeholder="One number per line"></textarea></label>
<label><span>Company</span><input id="contact-company"></label>
<label><span>Job title</span><input id="contact-job-title"></label>
<label><span>Website</span><input id="contact-website" type="url" placeholder="https://"></label>
<label><span>Birthday</span><input id="contact-birthday" type="date"></label>
<label><span>Address</span><textarea id="contact-address" rows="3"></textarea></label>
<label><span>Other addresses</span><textarea id="contact-addresses" rows="3" placeholder="One address per line"></textarea></label>
<label><span>Notes</span><textarea id="contact-notes" rows="6"></textarea></label>
<label class="checkbox-row"><input id="contact-favorite" type="checkbox"><span>Favorite</span></label>
<div><span class="field-label">Groups</span><div id="contact-group-membership" class="contact-group-membership"></div></div>
<div class="inline-actions"><button type="submit" class="button primary">Save</button><button id="contact-compose" type="button" class="button quiet">Compose</button><button id="contact-delete" type="button" class="button quiet danger">Delete</button></div>
</form>
</div>
</div>
</dialog>
<dialog id="contact-duplicates-dialog" class="dialog">
<div class="dialog-card duplicates-card">
<header class="dialog-header"><strong>Duplicate contacts</strong><button type="button" class="icon-button close-dialog" aria-label="Close"><svg><use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-x"></use></svg></button></header>
<p class="muted">Duplicates are detected when two contacts share a primary or alternate e-mail address. Choose which record to keep.</p>
<div id="contact-duplicate-list" class="duplicate-contact-list"></div>
<footer class="dialog-actions"><button type="button" class="button primary close-dialog">Close</button></footer>
</div>
</dialog>
<dialog id="calendar-dialog" class="dialog">
<div class="dialog-card calendar-card">
<header class="dialog-header"><strong>Calendar</strong><div class="inline-actions"><button id="calendar-today" type="button" class="button quiet">Today</button><button type="button" class="icon-button close-dialog" aria-label="Close"><svg><use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-x"></use></svg></button></div></header>
<div class="calendar-toolbar"><button id="calendar-prev" type="button" class="button quiet">Previous</button><strong id="calendar-range"></strong><button id="calendar-next" type="button" class="button quiet">Next</button><select id="calendar-view" aria-label="Calendar view"><option value="month">Month</option><option value="week">Week</option><option value="day">Day</option><option value="agenda">Agenda</option></select><button id="calendar-import" type="button" class="button quiet">Import ICS</button><input id="calendar-import-file" type="file" accept=".ics,text/calendar" hidden><button id="calendar-export" type="button" class="button quiet">Export ICS</button><button id="calendar-new" type="button" class="button primary">New event</button></div>
<div id="calendar-events" class="calendar-events"></div>
<form id="calendar-form" class="calendar-form hidden">
<input id="calendar-event-id" type="hidden">
<label><span>Title</span><input id="calendar-title" required></label>
<label class="checkbox-row"><input id="calendar-all-day" type="checkbox"><span>All day</span></label>
<div class="settings-grid"><label><span>Start</span><input id="calendar-start" type="datetime-local" required></label><label><span>End</span><input id="calendar-end" type="datetime-local" required></label></div>
<div class="settings-grid"><label><span>Recurrence</span><select id="calendar-recurrence"><option value="">Does not repeat</option><option value="FREQ=DAILY">Daily</option><option value="FREQ=WEEKLY">Weekly</option><option value="FREQ=MONTHLY">Monthly</option><option value="FREQ=YEARLY">Yearly</option><option value="custom">Custom RRULE</option></select></label><label id="calendar-rrule-wrap" class="hidden"><span>RRULE</span><input id="calendar-rrule" placeholder="FREQ=WEEKLY;INTERVAL=2"></label></div>
<div class="settings-grid"><label><span>Status</span><select id="calendar-status"><option value="confirmed">Confirmed</option><option value="tentative">Tentative</option><option value="cancelled">Cancelled</option></select></label><label><span>Reminder</span><select id="calendar-reminder"><option value="">None</option><option value="0">At start</option><option value="5">5 minutes</option><option value="15">15 minutes</option><option value="30">30 minutes</option><option value="60">1 hour</option><option value="1440">1 day</option></select></label></div>
<div class="settings-grid"><label><span>Organizer</span><input id="calendar-organizer" type="email"></label><label><span>Color</span><input id="calendar-color" type="color" value="#6b7280"></label></div>
<label><span>Attendees</span><input id="calendar-attendees" placeholder="alice@example.com, bob@example.com"></label>
<label><span>Location</span><input id="calendar-location"></label>
<label><span>Meeting link</span><input id="calendar-meeting-url" type="url" placeholder="https://"></label>
<label><span>Description</span><textarea id="calendar-description" rows="4"></textarea></label>
<div class="inline-actions"><button type="submit" class="button primary">Save</button><button id="calendar-delete" type="button" class="button quiet danger">Delete</button><button id="calendar-cancel" type="button" class="button quiet">Cancel</button></div>
</form>
</div>
</dialog>
<dialog id="headers-dialog" class="dialog">
<div class="dialog-card headers-card">
<header class="dialog-header"><strong>Message headers</strong><button type="button" class="icon-button close-dialog" aria-label="Close"><svg><use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-x"></use></svg></button></header>
<div id="headers-summary" class="headers-summary"></div><pre id="headers-content" class="headers-content"></pre>
<footer class="dialog-actions"><button id="copy-headers-btn" type="button" class="button quiet">Copy</button><button type="button" class="button primary close-dialog">Close</button></footer>
</div>
</dialog>
<dialog id="add-account-dialog" class="dialog">
<div class="dialog-card add-account-card">
<header class="dialog-header"><strong>Add account</strong><button type="button" class="icon-button close-dialog" aria-label="Close"><svg><use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-x"></use></svg></button></header>
<form id="add-account-form" class="login-form">
<label><span>Email</span><input id="add-account-email" type="email" autocomplete="username" required></label>
<label><span>Password</span><input id="add-account-password" type="password" autocomplete="current-password" required></label>
<label id="add-account-remember-row" class="checkbox-row"><input id="add-account-remember" type="checkbox"><span id="add-account-remember-label">Keep me signed in</span></label>
<div id="add-account-error" class="form-error hidden" role="alert"></div>
<footer class="dialog-actions"><button type="button" class="button quiet close-dialog">Cancel</button><button class="button primary" type="submit">Add account</button></footer>
</form>
</div>
</dialog>
<div id="context-menu" class="context-menu hidden" role="menu"></div>
<div id="toast-stack" class="toast-stack" aria-live="polite"></div>
<script src="__RUSTBOX_ASSET_PREFIX__/assets/app.js" defer></script>
</body>
</html>