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

This commit is contained in:
Mateusz Gruszczyński committed 2026-10-30 00:12:09 +01:00
1 parent a238ff5e23
commit 93762fe9ef
65 files changed
+5419 -2791

No files matched your search

+24
View File
@@ -1,4 +1,28 @@
# RustBox changelog # RustBox changelog
### 0.0.3
- Fixed reader toolbar dropdowns for Message details and Move/copy by moving popovers out of the horizontally clipped toolbar layer.
- Added Fastmail-, Apple Mail-, Yahoo Mail- and HEY-inspired styles plus Nord, and an optional per-account unlock for UI scaling on predefined styles.
- Added separate mail-size controls: `max_attachment_size_mb = 10` per uploaded file and `max_message_size_mb = 50` for the complete final RFC822/MIME message. The final MIME bytes are checked before SMTP submission, including base64, multipart and OpenPGP overhead; the composer shows an estimated running total.
- Moved the Diagnostics → IMAP reconnect action below capability details so it no longer collides with the status label.
- Added the RustBox repository link to Settings → About.
- Defaulted omitted mail endpoints to localhost:993 (IMAP) and localhost:587 (SMTP); TLS verification is skipped automatically only for loopback endpoints unless `tls_verify` is explicitly configured.
- Swapped the compact composer footer groups so the HTML/Text/Markdown selector is on the left and Cancel/Send on the right.
- Grouped message details and move/copy actions into compact reader-toolbar menus.
- Improved Polish translations for composer, message toolbar and per-message context menu.
- Moved composer format selection to the opposite side of Send/Cancel in the compact composer footer.
- Added server-wide `default_interface_skin` (`rustbox`, `gmail`, `proton`, `zoho`, `outlook`, etc.) for login and accounts using **Global default**.
- Fixed mobile modal/settings overflow, transparent dialog strips, calendar toolbar wrapping and narrow-layout sizing.
- Fixed visual Sieve rule checkbox alignment and stacked its rule controls safely on small screens.
- Added tap/keyboard expansion for full recipient addresses in the message reader.
- Added persistent IMAP quota progress above the sidebar IMAP/version footer.
- Added administrator-configurable `default_theme = "system" | "dark" | "light"` for the pre-login/login UI.
- Added Gmail-, Proton-, Zoho- and Outlook-inspired interface profiles plus a Minimal profile.
- Expanded Polish translations across settings, dialogs, calendar, contacts, filters and common accessibility/title attributes.
- Bumped the application/source release to `0.0.3`.
### 0.0.2 ### 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. - 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.
Generated
+1 -1
View File
@@ -2957,7 +2957,7 @@ dependencies = [
[[package]] [[package]]
name = "rustbox" name = "rustbox"
version = "0.0.2" version = "0.0.3"
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.2" version = "0.0.3"
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.2 is derived from the Oxi webmail project: RustBox 0.0.3 is derived from the Oxi webmail project:
- Upstream: `c0h1b4/oxi` - Upstream: `c0h1b4/oxi`
- Base snapshot: `7f1507c486b78cf33e54beb701dd460d3bd2bd5d` - Base snapshot: `7f1507c486b78cf33e54beb701dd460d3bd2bd5d`
+17 -9
View File
@@ -1,4 +1,4 @@
# RustBox 0.0.2 # RustBox 0.0.3
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`.
@@ -42,11 +42,17 @@ Mail endpoints are configured by the administrator, not on the login screen. Use
Edit `config.dev.toml` before the first run: Edit `config.dev.toml` before the first run:
```toml ```toml
imap_host = "mail.example.com" default_theme = "system" # system | dark | light
imap_port = 993 default_interface_skin = "rustbox" # rustbox | gmail | proton | zoho | outlook | fastmail | apple-mail | yahoo | hey | nord | ...
smtp_host = "mail.example.com" # Mail hosts may be omitted: IMAP defaults to localhost:993 and SMTP to localhost:587.
smtp_port = 587 # imap_host = "localhost"
# smtp_host = "localhost"
tls_enabled = true tls_enabled = true
# tls_verify = true # omitted: false only for localhost/loopback, true for remote hosts
# Upload limit for one raw attachment.
max_attachment_size_mb = 10
# Hard limit for the complete rendered RFC822/MIME message submitted to the MTA.
max_message_size_mb = 50
# Optional; defaults to imap_host. # Optional; defaults to imap_host.
# sieve_host = "mail.example.com" # sieve_host = "mail.example.com"
@@ -61,9 +67,11 @@ 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 the mail host settings are omitted, RustBox uses `localhost` with IMAP `993` and SMTP `587`. TLS is enabled by default. Certificate and hostname verification are skipped automatically only for localhost/loopback unless `tls_verify` is set explicitly.
## Current 0.0.2 UI snapshot Mail-size limits are intentionally separate. `max_attachment_size_mb` applies to each raw uploaded file, while `max_message_size_mb` is checked against the final RFC822/MIME byte stream immediately before SMTP submission. The latter therefore includes message bodies, headers, MIME boundaries, base64 expansion, inline CID parts and OpenPGP transformations. The composer displays only an estimate; the server-side final-MIME check is authoritative.
## Current 0.0.3 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 +109,7 @@ RustBox uses a compact three-pane mail layout: folders, message list and reader.
IMAP IDLE events are delivered to the browser over the embedded WebSocket endpoint for realtime refresh and optional desktop notifications. Notification preferences include sound, folder selection and Do Not Disturb hours. IMAP IDLE events are delivered to the browser over the embedded WebSocket endpoint for realtime refresh and optional desktop notifications. Notification preferences include sound, folder selection and Do Not Disturb hours.
Appearance preferences are persisted per account and include light/dark/system theme, density, preview position, UI scale, multiple color presets, custom accent/background/panel/text colors, font, row height, pane widths, radius, contrast and visibility toggles. The mailbox toolbar also exposes persisted date/sender/subject/size sorting, and remote images can be restricted to saved contacts. 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. Administrators can set the global light/dark mode with `default_theme = "system" | "dark" | "light"` and the global interface style with `default_interface_skin = "rustbox" | "gmail" | "proton" | "zoho" | "outlook" | ...` in TOML. The global interface style is used on the login screen and by accounts set to **Global default**. Built-in interface styles include RustBox, Roundcube variants, Clean, Graphite, High contrast, Gmail/Proton/Zoho/Outlook/Fastmail/Apple Mail/Yahoo/HEY-inspired profiles, Nord and Minimal. Predefined styles can optionally unlock the per-account UI scale control. The mailbox toolbar also exposes persisted date/sender/subject/size sorting, and remote images can be restricted to saved contacts.
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. 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.
@@ -133,7 +141,7 @@ Initialize Git after extracting the release archive:
```bash ```bash
git init git init
git add . git add .
git commit -m "RustBox 0.0.2 release" git commit -m "RustBox 0.0.3 release"
./scripts/setup-upstream.sh ./scripts/setup-upstream.sh
``` ```
+14 -3
View File
@@ -1,4 +1,4 @@
RustBox 0.0.2 source release RustBox 0.0.3 source release
Runtime/source model: Runtime/source model:
- one root Cargo package - one root Cargo package
@@ -19,9 +19,19 @@ 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.2 release additions: Current 0.0.3 release additions:
- Polish composer, reader toolbar and per-message context-menu translation pass
- grouped reader message details menu for headers/source and transfer menu for move/copy
- compact composer format selector moved to the lower-right opposite Send/Cancel
- mobile layout reliability pass for settings, source/header/calendar dialogs and narrow toolbars
- fixed Sieve visual-editor checkbox alignment and mobile single-column rule controls
- tap/keyboard expansion for full sender/recipient address details in the reader
- persistent IMAP quota progress above the sidebar IMAP/version footer
- global TOML `default_theme` for light/dark mode and `default_interface_skin` for login + account-wide default interface styling
- Gmail-, Proton-, Zoho- and Outlook-inspired interface profiles plus Minimal
- expanded Polish localization, including scoped dialog/settings text and common attributes
- JavaScript source split into functional `src/web/js/` modules and assembled by Cargo build.rs - 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 - Interface style selector expanded to RustBox custom, Roundcube Classic/Larry/Elastic, Clean, Graphite, High contrast, Gmail-, Proton-, Zoho-, Outlook-inspired and Minimal
- legacy Compact/Balanced/Comfortable/Reading appearance cards removed - 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
@@ -37,6 +47,7 @@ Current 0.0.2 release additions:
- 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
- separate 10 MiB per-file attachment limit and 50 MiB final RFC822/MIME message limit with pre-SMTP exact byte validation and composer size estimate
- recoverable drafts with browser emergency snapshots and throttled IMAP Drafts snapshots - recoverable drafts with browser emergency snapshots and throttled IMAP Drafts snapshots
- outgoing priority and MDN/read-receipt draft/send options with pre-SMTP mailbox validation - outgoing priority and MDN/read-receipt draft/send options with pre-SMTP mailbox validation
- expanded Sieve visual rules: nested three-condition grouping, two actions, managed reordering, backup/restore, active overwrite guard and optional date-scheduled vacation - expanded Sieve visual rules: nested three-condition grouping, two actions, managed reordering, backup/restore, active overwrite guard and optional date-scheduled vacation
+14 -7
View File
@@ -2,17 +2,23 @@
host = "127.0.0.1" host = "127.0.0.1"
port = 3001 port = 3001
environment = "development" environment = "development"
# Global light/dark theme: system | dark | light
default_theme = "system"
# Global interface style used by the login screen and by accounts set to
# "Global default". Presets include gmail, proton, zoho, outlook, fastmail,
# apple-mail, yahoo, hey, nord, minimal and Roundcube variants.
default_interface_skin = "rustbox"
data_dir = ".dev/data" data_dir = ".dev/data"
session_timeout_hours = 24 session_timeout_hours = 24
# Mail server configuration is administrator-owned. The login screen only asks # Mail server configuration is administrator-owned. If omitted, development
# for the user's email address and password. # also defaults to localhost:993 (IMAP) and localhost:587 (SMTP).
# Set these values before starting RustBox. # Certificate verification is disabled automatically only for loopback hosts.
imap_host = ""
imap_port = 993
smtp_host = ""
smtp_port = 587
tls_enabled = true tls_enabled = true
# tls_verify = false
# Per-file raw upload limit and final RFC822/MIME MTA limit (MiB).
max_attachment_size_mb = 10
max_message_size_mb = 50
# ManageSieve. Leave sieve_host unset to use imap_host. # ManageSieve. Leave sieve_host unset to use imap_host.
sieve_port = 4190 sieve_port = 4190
@@ -21,3 +27,4 @@ sieve_allow_insecure_auth = false
sieve_timeout_secs = 10 sieve_timeout_secs = 10
trusted_proxies = "127.0.0.1,::1" trusted_proxies = "127.0.0.1,::1"
+22 -6
View File
@@ -1,18 +1,34 @@
# RustBox 0.0.2 # RustBox 0.0.3
host = "127.0.0.1" host = "127.0.0.1"
port = 3001 port = 3001
environment = "production" environment = "production"
# Global light/dark theme: system | dark | light
default_theme = "system"
# Global interface style used by the login screen and by accounts set to
# "Global default". Presets include gmail, proton, zoho, outlook, fastmail,
# apple-mail, yahoo, hey, nord, minimal and Roundcube variants.
default_interface_skin = "rustbox"
data_dir = "/var/lib/rustbox" data_dir = "/var/lib/rustbox"
session_timeout_hours = 24 session_timeout_hours = 24
remember_me_enabled = true remember_me_enabled = true
remember_me_days = 30 remember_me_days = 30
# Required mail profile. Users only enter email + password on the login page. # Mail profile. When hosts/ports are omitted RustBox defaults to:
imap_host = "mail.example.com" # IMAP localhost:993 and SMTP localhost:587 with TLS enabled.
imap_port = 993 # SMTP host falls back to the IMAP host.
smtp_host = "mail.example.com" # imap_host = "localhost"
smtp_port = 587 # imap_port = 993
# smtp_host = "localhost"
# smtp_port = 587
tls_enabled = true tls_enabled = true
# Certificate/hostname verification. If omitted, RustBox skips verification only
# for localhost/127.0.0.1/::1 and verifies certificates for remote hosts.
# tls_verify = true
# Maximum size of one raw uploaded attachment (MiB).
max_attachment_size_mb = 10
# Maximum size of the complete RFC822/MIME message submitted to the MTA (MiB).
max_message_size_mb = 50
# Optional reverse-proxy subpath, e.g. "/mail". # Optional reverse-proxy subpath, e.g. "/mail".
# base_path = "/mail" # base_path = "/mail"
+2 -2
View File
@@ -1,6 +1,6 @@
# RustBox 0.0.2 - implementation status # RustBox 0.0.3 - implementation status
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. This file tracks the internal `0.0.3` 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
+2 -2
View File
@@ -1,4 +1,4 @@
# RustBox 0.0.2 — remaining roadmap gaps # RustBox 0.0.3 — 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.2. - [ ] Background/service-worker notifications while the page is inactive, if retained for 0.0.3.
- [ ] 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.
+14
View File
@@ -0,0 +1,14 @@
# RustBox 0.0.3
RustBox 0.0.3 is a mobile UI and appearance-focused maintenance release.
## Highlights
- Mobile settings and dialogs no longer grow wider than the viewport; source, header and calendar cards fill their dialog without transparent side strips.
- Calendar controls wrap into a touch-friendly grid on narrow screens.
- The visual Sieve editor keeps checkboxes next to their labels and switches complex rule rows to one column on mobile.
- Message recipient details can be expanded by tap or keyboard to reveal full addresses.
- IMAP storage quota is visible in the main folder sidebar above the IMAP/version footer.
- `default_theme = "system" | "dark" | "light"` controls the global pre-login/login theme from TOML.
- New interface profiles: Gmail inspired, Proton inspired, Zoho inspired, Outlook inspired and Minimal.
- Polish localization now covers substantially more settings, dialogs, filters, calendar and contact UI.
+1
View File
@@ -0,0 +1 @@
ALTER TABLE display_preferences ADD COLUMN preset_scale_unlocked INTEGER NOT NULL DEFAULT 0;
+8 -5
View File
@@ -22,11 +22,12 @@ pub async fn validate_imap_credentials(
host: &str, host: &str,
port: u16, port: u16,
tls: bool, tls: bool,
tls_verify: bool,
email: &str, email: &str,
password: &str, password: &str,
) -> AuthResult { ) -> AuthResult {
if tls { if tls {
validate_tls(host, port, email, password).await validate_tls(host, port, tls_verify, email, password).await
} else { } else {
validate_plain(host, port, email, password).await validate_plain(host, port, email, password).await
} }
@@ -34,7 +35,7 @@ pub async fn validate_imap_credentials(
/// TLS path: connect via `tokio::net::TcpStream`, upgrade with /// TLS path: connect via `tokio::net::TcpStream`, upgrade with
/// `async_native_tls::TlsConnector`, then wrap in `async_imap::Client`. /// `async_native_tls::TlsConnector`, then wrap in `async_imap::Client`.
async fn validate_tls(host: &str, port: u16, email: &str, password: &str) -> AuthResult { async fn validate_tls(host: &str, port: u16, tls_verify: bool, email: &str, password: &str) -> AuthResult {
let addr = format!("{host}:{port}"); let addr = format!("{host}:{port}");
let tcp_stream = match tokio::net::TcpStream::connect(&addr).await { let tcp_stream = match tokio::net::TcpStream::connect(&addr).await {
@@ -42,7 +43,9 @@ async fn validate_tls(host: &str, port: u16, email: &str, password: &str) -> Aut
Err(e) => return AuthResult::ServerUnreachable(e.to_string()), Err(e) => return AuthResult::ServerUnreachable(e.to_string()),
}; };
let tls_connector = async_native_tls::TlsConnector::new(); let tls_connector = async_native_tls::TlsConnector::new()
.danger_accept_invalid_certs(!tls_verify)
.danger_accept_invalid_hostnames(!tls_verify);
let tls_stream = match tls_connector.connect(host, tcp_stream).await { let tls_stream = match tls_connector.connect(host, tcp_stream).await {
Ok(s) => s, Ok(s) => s,
Err(e) => return AuthResult::ServerUnreachable(e.to_string()), Err(e) => return AuthResult::ServerUnreachable(e.to_string()),
@@ -120,7 +123,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn unreachable_server_returns_server_unreachable() { async fn unreachable_server_returns_server_unreachable() {
let result = let result =
validate_imap_credentials("127.0.0.1", 19999, false, "user@test.com", "pass").await; validate_imap_credentials("127.0.0.1", 19999, false, false, "user@test.com", "pass").await;
assert!( assert!(
matches!(result, AuthResult::ServerUnreachable(_)), matches!(result, AuthResult::ServerUnreachable(_)),
@@ -132,7 +135,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn unreachable_server_tls_returns_server_unreachable() { async fn unreachable_server_tls_returns_server_unreachable() {
let result = let result =
validate_imap_credentials("127.0.0.1", 19999, true, "user@test.com", "pass").await; validate_imap_credentials("127.0.0.1", 19999, true, false, "user@test.com", "pass").await;
assert!( assert!(
matches!(result, AuthResult::ServerUnreachable(_)), matches!(result, AuthResult::ServerUnreachable(_)),
+24
View File
@@ -23,11 +23,13 @@ pub struct AccountSession {
pub imap_host: String, pub imap_host: String,
pub imap_port: u16, pub imap_port: u16,
pub imap_tls: bool, pub imap_tls: bool,
pub imap_tls_verify: bool,
pub smtp_host: String, pub smtp_host: String,
#[allow(dead_code)] #[allow(dead_code)]
pub smtp_port: u16, pub smtp_port: u16,
#[allow(dead_code)] #[allow(dead_code)]
pub smtp_tls: bool, pub smtp_tls: bool,
pub smtp_tls_verify: bool,
pub last_accessed: Instant, pub last_accessed: Instant,
pub timeout_override: Option<Duration>, pub timeout_override: Option<Duration>,
pub created_at_unix: u64, pub created_at_unix: u64,
@@ -49,6 +51,7 @@ pub struct SessionStore {
account_to_session: Arc<DashMap<AccountId, SessionId>>, account_to_session: Arc<DashMap<AccountId, SessionId>>,
account_to_browser: Arc<DashMap<AccountId, BrowserId>>, account_to_browser: Arc<DashMap<AccountId, BrowserId>>,
timeout: Duration, timeout: Duration,
tls_verify_override: Option<bool>,
} }
impl SessionStore { impl SessionStore {
@@ -60,15 +63,28 @@ impl SessionStore {
} }
pub fn new(timeout: Duration) -> Self { pub fn new(timeout: Duration) -> Self {
Self::new_with_tls_verify(timeout, None)
}
pub fn new_with_tls_verify(timeout: Duration, tls_verify_override: Option<bool>) -> Self {
Self { Self {
sessions: Arc::new(DashMap::new()), sessions: Arc::new(DashMap::new()),
browsers: Arc::new(DashMap::new()), browsers: Arc::new(DashMap::new()),
account_to_session: Arc::new(DashMap::new()), account_to_session: Arc::new(DashMap::new()),
account_to_browser: Arc::new(DashMap::new()), account_to_browser: Arc::new(DashMap::new()),
timeout, timeout,
tls_verify_override,
} }
} }
fn tls_verify_for_host(&self, host: &str) -> bool {
if let Some(value) = self.tls_verify_override {
return value;
}
let normalized = host.trim().trim_matches(|ch| ch == '[' || ch == ']').to_ascii_lowercase();
!matches!(normalized.as_str(), "localhost" | "127.0.0.1" | "::1")
}
pub fn generate_token() -> SessionId { pub fn generate_token() -> SessionId {
let mut bytes = [0u8; 32]; let mut bytes = [0u8; 32];
rand::rng().fill_bytes(&mut bytes); rand::rng().fill_bytes(&mut bytes);
@@ -126,6 +142,8 @@ impl SessionStore {
let account_id = Self::generate_id(); let account_id = Self::generate_id();
let now_unix = Self::unix_now(); let now_unix = Self::unix_now();
let imap_tls_verify = self.tls_verify_for_host(&imap_host);
let smtp_tls_verify = self.tls_verify_for_host(&smtp_host);
let session = AccountSession { let session = AccountSession {
account_id: account_id.clone(), account_id: account_id.clone(),
email, email,
@@ -134,9 +152,11 @@ impl SessionStore {
imap_host, imap_host,
imap_port, imap_port,
imap_tls, imap_tls,
imap_tls_verify,
smtp_host, smtp_host,
smtp_port, smtp_port,
smtp_tls, smtp_tls,
smtp_tls_verify,
last_accessed: Instant::now(), last_accessed: Instant::now(),
timeout_override, timeout_override,
created_at_unix: now_unix, created_at_unix: now_unix,
@@ -333,9 +353,11 @@ impl SessionStore {
imap_host: String::new(), imap_host: String::new(),
imap_port: 993, imap_port: 993,
imap_tls: true, imap_tls: true,
imap_tls_verify: true,
smtp_host: String::new(), smtp_host: String::new(),
smtp_port: 587, smtp_port: 587,
smtp_tls: true, smtp_tls: true,
smtp_tls_verify: true,
last_accessed: Instant::now(), last_accessed: Instant::now(),
timeout_override, timeout_override,
created_at_unix: now_unix, created_at_unix: now_unix,
@@ -505,9 +527,11 @@ mod tests {
imap_host: "imap.test.com".to_string(), imap_host: "imap.test.com".to_string(),
imap_port: 993, imap_port: 993,
imap_tls: true, imap_tls: true,
imap_tls_verify: true,
smtp_host: "smtp.test.com".to_string(), smtp_host: "smtp.test.com".to_string(),
smtp_port: 587, smtp_port: 587,
smtp_tls: true, smtp_tls: true,
smtp_tls_verify: true,
last_accessed: Instant::now(), last_accessed: Instant::now(),
timeout_override: None, timeout_override: None,
created_at_unix: SessionStore::unix_now(), created_at_unix: SessionStore::unix_now(),
+150 -4
View File
@@ -24,8 +24,8 @@ pub struct AppConfig {
#[serde(default = "default_port")] #[serde(default = "default_port")]
pub port: u16, pub port: u16,
/// IMAP server hostname. Required at startup. /// IMAP server hostname. Defaults to localhost when omitted.
#[serde(default)] #[serde(default = "default_imap_host")]
pub imap_host: Option<String>, pub imap_host: Option<String>,
/// IMAP server port. /// IMAP server port.
@@ -44,6 +44,20 @@ pub struct AppConfig {
#[serde(default = "default_tls_enabled")] #[serde(default = "default_tls_enabled")]
pub tls_enabled: bool, pub tls_enabled: bool,
/// Optional certificate/hostname verification override for IMAP and SMTP.
/// When omitted, verification is disabled only for localhost/loopback endpoints
/// and enabled for remote hosts.
#[serde(default)]
pub tls_verify: Option<bool>,
/// Maximum size of one uploaded compose attachment, in MiB.
#[serde(default = "default_max_attachment_size_mb")]
pub max_attachment_size_mb: usize,
/// Maximum size of the complete RFC822/MIME message submitted to the MTA, in MiB.
#[serde(default = "default_max_message_size_mb")]
pub max_message_size_mb: usize,
/// Directory for persistent data storage. /// Directory for persistent data storage.
#[serde(default = "default_data_dir")] #[serde(default = "default_data_dir")]
pub data_dir: String, pub data_dir: String,
@@ -64,6 +78,16 @@ pub struct AppConfig {
#[serde(default = "default_environment")] #[serde(default = "default_environment")]
pub environment: String, pub environment: String,
/// Server-wide light/dark theme used before authentication (including the login screen).
/// Authenticated per-user display preferences still take precedence.
#[serde(default = "default_theme")]
pub default_theme: String,
/// Server-wide interface style used on the login screen and by users whose
/// interface style is set to `default`. Examples: `gmail`, `proton`, `zoho`.
#[serde(default = "default_interface_skin")]
pub default_interface_skin: String,
/// Optional base path prefix (e.g. "/mail") for serving behind a reverse proxy subpath. /// Optional base path prefix (e.g. "/mail") for serving behind a reverse proxy subpath.
#[serde(default)] #[serde(default)]
pub base_path: Option<String>, pub base_path: Option<String>,
@@ -158,6 +182,10 @@ fn default_port() -> u16 {
3001 3001
} }
fn default_imap_host() -> Option<String> {
Some("localhost".to_string())
}
fn default_imap_port() -> u16 { fn default_imap_port() -> u16 {
993 993
} }
@@ -170,6 +198,14 @@ fn default_tls_enabled() -> bool {
true true
} }
fn default_max_attachment_size_mb() -> usize {
10
}
fn default_max_message_size_mb() -> usize {
50
}
fn default_data_dir() -> String { fn default_data_dir() -> String {
"/data".to_string() "/data".to_string()
} }
@@ -189,6 +225,14 @@ fn default_environment() -> String {
"development".to_string() "development".to_string()
} }
fn default_theme() -> String {
"system".to_string()
}
fn default_interface_skin() -> String {
"rustbox".to_string()
}
fn default_sieve_port() -> u16 { fn default_sieve_port() -> u16 {
4190 4190
} }
@@ -219,16 +263,21 @@ impl Default for AppConfig {
Self { Self {
host: default_host(), host: default_host(),
port: default_port(), port: default_port(),
imap_host: None, imap_host: default_imap_host(),
imap_port: default_imap_port(), imap_port: default_imap_port(),
smtp_host: None, smtp_host: None,
smtp_port: default_smtp_port(), smtp_port: default_smtp_port(),
tls_enabled: default_tls_enabled(), tls_enabled: default_tls_enabled(),
tls_verify: None,
max_attachment_size_mb: default_max_attachment_size_mb(),
max_message_size_mb: default_max_message_size_mb(),
data_dir: default_data_dir(), data_dir: default_data_dir(),
session_timeout_hours: default_session_timeout_hours(), session_timeout_hours: default_session_timeout_hours(),
remember_me_enabled: default_remember_me_enabled(), remember_me_enabled: default_remember_me_enabled(),
remember_me_days: default_remember_me_days(), remember_me_days: default_remember_me_days(),
environment: default_environment(), environment: default_environment(),
default_theme: default_theme(),
default_interface_skin: default_interface_skin(),
base_path: None, base_path: None,
sieve_host: None, sieve_host: None,
sieve_port: default_sieve_port(), sieve_port: default_sieve_port(),
@@ -261,6 +310,27 @@ impl AppConfig {
.unwrap_or("/") .unwrap_or("/")
} }
/// Certificate verification policy for a configured mail host. Explicit
/// `tls_verify` wins. Without it, only localhost/loopback skips validation.
pub fn tls_verify_for_host(&self, host: &str) -> bool {
if let Some(value) = self.tls_verify {
return value;
}
let normalized = host
.trim()
.trim_matches(|ch| ch == '[' || ch == ']')
.to_ascii_lowercase();
!matches!(normalized.as_str(), "localhost" | "127.0.0.1" | "::1")
}
pub fn max_attachment_size_bytes(&self) -> usize {
self.max_attachment_size_mb.saturating_mul(1024 * 1024)
}
pub fn max_message_size_bytes(&self) -> usize {
self.max_message_size_mb.saturating_mul(1024 * 1024)
}
/// Parse the `trusted_proxies` field into IP or CIDR entries. /// Parse the `trusted_proxies` field into IP or CIDR entries.
/// Logs warnings for unparseable entries. /// Logs warnings for unparseable entries.
pub fn parsed_trusted_proxies(&self) -> Vec<TrustedProxy> { pub fn parsed_trusted_proxies(&self) -> Vec<TrustedProxy> {
@@ -370,12 +440,46 @@ impl AppConfig {
if self.port == 0 || self.imap_port == 0 || self.smtp_port == 0 || self.sieve_port == 0 { if self.port == 0 || self.imap_port == 0 || self.smtp_port == 0 || self.sieve_port == 0 {
return Err("network ports must be greater than zero".to_string()); return Err("network ports must be greater than zero".to_string());
} }
if self.max_attachment_size_mb == 0 || self.max_attachment_size_mb > 512 {
return Err("MAX_ATTACHMENT_SIZE_MB must be between 1 and 512".to_string());
}
if self.max_message_size_mb == 0 || self.max_message_size_mb > 1024 {
return Err("MAX_MESSAGE_SIZE_MB must be between 1 and 1024".to_string());
}
if self.max_attachment_size_mb > self.max_message_size_mb {
return Err("MAX_ATTACHMENT_SIZE_MB cannot exceed MAX_MESSAGE_SIZE_MB".to_string());
}
if self.session_timeout_hours == 0 || self.session_timeout_hours > 24 * 365 { if self.session_timeout_hours == 0 || self.session_timeout_hours > 24 * 365 {
return Err("SESSION_TIMEOUT_HOURS must be between 1 and 8760".to_string()); return Err("SESSION_TIMEOUT_HOURS must be between 1 and 8760".to_string());
} }
if self.remember_me_days == 0 || self.remember_me_days > 365 { if self.remember_me_days == 0 || self.remember_me_days > 365 {
return Err("REMEMBER_ME_DAYS must be between 1 and 365".to_string()); return Err("REMEMBER_ME_DAYS must be between 1 and 365".to_string());
} }
if !matches!(self.default_theme.as_str(), "system" | "dark" | "light") {
return Err("DEFAULT_THEME must be one of: system, dark, light".to_string());
}
if !matches!(
self.default_interface_skin.as_str(),
"rustbox"
| "roundcube-classic"
| "roundcube-larry"
| "roundcube-elastic"
| "clean"
| "gmail"
| "proton"
| "zoho"
| "outlook"
| "minimal"
| "graphite"
| "high-contrast"
| "fastmail"
| "apple-mail"
| "yahoo"
| "hey"
| "nord"
) {
return Err("DEFAULT_INTERFACE_SKIN must be one of: rustbox, roundcube-classic, roundcube-larry, roundcube-elastic, clean, gmail, proton, zoho, outlook, minimal, graphite, high-contrast, fastmail, apple-mail, yahoo, hey, nord".to_string());
}
if !matches!( if !matches!(
self.sieve_tls_mode.as_str(), self.sieve_tls_mode.as_str(),
"starttls" | "implicit" | "none" "starttls" | "implicit" | "none"
@@ -417,16 +521,23 @@ mod tests {
assert_eq!(config.host, "0.0.0.0"); assert_eq!(config.host, "0.0.0.0");
assert_eq!(config.port, 3001); assert_eq!(config.port, 3001);
assert!(config.imap_host.is_none()); assert_eq!(config.imap_host.as_deref(), Some("localhost"));
assert_eq!(config.imap_port, 993); assert_eq!(config.imap_port, 993);
assert!(config.smtp_host.is_none()); assert!(config.smtp_host.is_none());
assert_eq!(config.smtp_port, 587); assert_eq!(config.smtp_port, 587);
assert!(config.tls_enabled); assert!(config.tls_enabled);
assert_eq!(config.tls_verify, None);
assert!(!config.tls_verify_for_host("localhost"));
assert!(config.tls_verify_for_host("mail.example.com"));
assert_eq!(config.max_attachment_size_mb, 10);
assert_eq!(config.max_message_size_mb, 50);
assert_eq!(config.data_dir, "/data"); assert_eq!(config.data_dir, "/data");
assert_eq!(config.session_timeout_hours, 24); assert_eq!(config.session_timeout_hours, 24);
assert!(config.remember_me_enabled); assert!(config.remember_me_enabled);
assert_eq!(config.remember_me_days, 30); assert_eq!(config.remember_me_days, 30);
assert_eq!(config.environment, "development"); assert_eq!(config.environment, "development");
assert_eq!(config.default_theme, "system");
assert_eq!(config.default_interface_skin, "rustbox");
assert!(config.sieve_host.is_none()); assert!(config.sieve_host.is_none());
assert_eq!(config.sieve_port, 4190); assert_eq!(config.sieve_port, 4190);
assert_eq!(config.sieve_tls_mode, "starttls"); assert_eq!(config.sieve_tls_mode, "starttls");
@@ -454,11 +565,16 @@ mod tests {
.merge(("smtp_host", "smtp.example.com")) .merge(("smtp_host", "smtp.example.com"))
.merge(("smtp_port", 465u16)) .merge(("smtp_port", 465u16))
.merge(("tls_enabled", false)) .merge(("tls_enabled", false))
.merge(("tls_verify", true))
.merge(("max_attachment_size_mb", 40usize))
.merge(("max_message_size_mb", 80usize))
.merge(("data_dir", "/var/rustbox")) .merge(("data_dir", "/var/rustbox"))
.merge(("session_timeout_hours", 48u64)) .merge(("session_timeout_hours", 48u64))
.merge(("remember_me_enabled", false)) .merge(("remember_me_enabled", false))
.merge(("remember_me_days", 14u64)) .merge(("remember_me_days", 14u64))
.merge(("environment", "production")) .merge(("environment", "production"))
.merge(("default_theme", "light"))
.merge(("default_interface_skin", "gmail"))
.merge(("sieve_host", "sieve.example.com")) .merge(("sieve_host", "sieve.example.com"))
.merge(("sieve_port", 4191u16)) .merge(("sieve_port", 4191u16))
.merge(("sieve_tls_mode", "implicit")) .merge(("sieve_tls_mode", "implicit"))
@@ -475,11 +591,16 @@ mod tests {
assert_eq!(config.smtp_host.as_deref(), Some("smtp.example.com")); assert_eq!(config.smtp_host.as_deref(), Some("smtp.example.com"));
assert_eq!(config.smtp_port, 465); assert_eq!(config.smtp_port, 465);
assert!(!config.tls_enabled); assert!(!config.tls_enabled);
assert_eq!(config.tls_verify, Some(true));
assert_eq!(config.max_attachment_size_mb, 40);
assert_eq!(config.max_message_size_mb, 80);
assert_eq!(config.data_dir, "/var/rustbox"); assert_eq!(config.data_dir, "/var/rustbox");
assert_eq!(config.session_timeout_hours, 48); assert_eq!(config.session_timeout_hours, 48);
assert!(!config.remember_me_enabled); assert!(!config.remember_me_enabled);
assert_eq!(config.remember_me_days, 14); assert_eq!(config.remember_me_days, 14);
assert_eq!(config.environment, "production"); assert_eq!(config.environment, "production");
assert_eq!(config.default_theme, "light");
assert_eq!(config.default_interface_skin, "gmail");
assert_eq!(config.sieve_host.as_deref(), Some("sieve.example.com")); assert_eq!(config.sieve_host.as_deref(), Some("sieve.example.com"));
assert_eq!(config.sieve_port, 4191); assert_eq!(config.sieve_port, 4191);
assert_eq!(config.sieve_tls_mode, "implicit"); assert_eq!(config.sieve_tls_mode, "implicit");
@@ -511,6 +632,31 @@ mod tests {
} }
} }
#[test]
fn invalid_default_theme_is_rejected() {
let config = AppConfig {
imap_host: Some("imap.example.com".to_string()),
default_theme: "sepia".to_string(),
..AppConfig::default()
};
assert!(config.validate().unwrap_err().contains("DEFAULT_THEME"));
}
#[test]
fn invalid_default_interface_skin_is_rejected() {
let config = AppConfig {
imap_host: Some("imap.example.com".to_string()),
default_interface_skin: "hotmail-1999".to_string(),
..AppConfig::default()
};
assert!(
config
.validate()
.unwrap_err()
.contains("DEFAULT_INTERFACE_SKIN")
);
}
#[test] #[test]
fn outgoing_headers_are_parsed_and_structural_headers_rejected() { fn outgoing_headers_are_parsed_and_structural_headers_rejected() {
let config = AppConfig { let config = AppConfig {
+31 -4
View File
@@ -54,6 +54,7 @@ pub struct DisplayPreferences {
pub forward_mode: String, pub forward_mode: String,
pub spellcheck_enabled: bool, pub spellcheck_enabled: bool,
pub interface_skin: String, pub interface_skin: String,
pub preset_scale_unlocked: bool,
pub updated_at: String, pub updated_at: String,
} }
@@ -111,6 +112,7 @@ pub struct UpdateDisplayPreferences {
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>, pub interface_skin: Option<String>,
pub preset_scale_unlocked: Option<bool>,
} }
fn deserialize_animation_mode_field<'de, D>( fn deserialize_animation_mode_field<'de, D>(
@@ -175,14 +177,15 @@ 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(), interface_skin: "default".into(),
preset_scale_unlocked: false,
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, interface_skin, 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, preset_scale_unlocked, 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)?,
@@ -195,7 +198,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, interface_skin: row.get(50)?, updated_at: row.get(51)?, 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)?, preset_scale_unlocked: row.get::<_, i32>(51)? != 0, updated_at: row.get(52)?,
}), }),
); );
match result { match result {
@@ -340,13 +343,24 @@ pub fn update_preferences(
"interface_skin", "interface_skin",
v, v,
&[ &[
"default",
"rustbox", "rustbox",
"roundcube-classic", "roundcube-classic",
"roundcube-larry", "roundcube-larry",
"roundcube-elastic", "roundcube-elastic",
"clean", "clean",
"gmail",
"proton",
"zoho",
"outlook",
"minimal",
"graphite", "graphite",
"high-contrast", "high-contrast",
"fastmail",
"apple-mail",
"yahoo",
"hey",
"nord",
], ],
)?; )?;
} }
@@ -479,6 +493,7 @@ pub fn update_preferences(
set_text!(forward_mode, "forward_mode"); set_text!(forward_mode, "forward_mode");
set_bool!(spellcheck_enabled, "spellcheck_enabled"); set_bool!(spellcheck_enabled, "spellcheck_enabled");
set_text!(interface_skin, "interface_skin"); set_text!(interface_skin, "interface_skin");
set_bool!(preset_scale_unlocked, "preset_scale_unlocked");
if sets.is_empty() { if sets.is_empty() {
return get_preferences(conn); return get_preferences(conn);
@@ -509,7 +524,7 @@ 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, "interface_skin":"roundcube-elastic" "show_avatars":false, "reduce_motion":true, "interface_skin":"roundcube-elastic", "preset_scale_unlocked":true
})) }))
.unwrap(); .unwrap();
let prefs = update_preferences(&conn, &update).unwrap(); let prefs = update_preferences(&conn, &update).unwrap();
@@ -518,18 +533,30 @@ mod tests {
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"); assert_eq!(prefs.interface_skin, "roundcube-elastic");
assert!(prefs.preset_scale_unlocked);
} }
#[test] #[test]
fn built_in_interface_skins_are_accepted() { fn built_in_interface_skins_are_accepted() {
for skin in [ for skin in [
"default",
"rustbox", "rustbox",
"roundcube-classic", "roundcube-classic",
"roundcube-larry", "roundcube-larry",
"roundcube-elastic", "roundcube-elastic",
"clean", "clean",
"gmail",
"proton",
"zoho",
"outlook",
"minimal",
"graphite", "graphite",
"high-contrast", "high-contrast",
"fastmail",
"apple-mail",
"yahoo",
"hey",
"nord",
] { ] {
let conn = open_test_db(); let conn = open_test_db();
let update: UpdateDisplayPreferences = let update: UpdateDisplayPreferences =
+4
View File
@@ -193,6 +193,10 @@ const MIGRATIONS: &[(u32, &str)] = &[
45, 45,
include_str!("../../migrations/V045__response_template_format.sql"), include_str!("../../migrations/V045__response_template_format.sql"),
), ),
(
46,
include_str!("../../migrations/V046__preset_scale_unlock.sql"),
),
]; ];
/// Run any pending migrations based on SQLite's `user_version` PRAGMA. /// Run any pending migrations based on SQLite's `user_version` PRAGMA.
+15 -1
View File
@@ -33,6 +33,8 @@ pub enum AppError {
Forbidden(String), Forbidden(String),
/// Service unavailable (503). /// Service unavailable (503).
ServiceUnavailable(String), ServiceUnavailable(String),
/// Request payload is larger than the configured mail limit (413).
PayloadTooLarge(String),
} }
impl AppError { impl AppError {
@@ -45,6 +47,7 @@ impl AppError {
AppError::BadRequest(_) => StatusCode::BAD_REQUEST, AppError::BadRequest(_) => StatusCode::BAD_REQUEST,
AppError::Forbidden(_) => StatusCode::FORBIDDEN, AppError::Forbidden(_) => StatusCode::FORBIDDEN,
AppError::ServiceUnavailable(_) => StatusCode::SERVICE_UNAVAILABLE, AppError::ServiceUnavailable(_) => StatusCode::SERVICE_UNAVAILABLE,
AppError::PayloadTooLarge(_) => StatusCode::PAYLOAD_TOO_LARGE,
} }
} }
@@ -57,6 +60,7 @@ impl AppError {
AppError::BadRequest(_) => "BAD_REQUEST", AppError::BadRequest(_) => "BAD_REQUEST",
AppError::Forbidden(_) => "FORBIDDEN", AppError::Forbidden(_) => "FORBIDDEN",
AppError::ServiceUnavailable(_) => "SERVICE_UNAVAILABLE", AppError::ServiceUnavailable(_) => "SERVICE_UNAVAILABLE",
AppError::PayloadTooLarge(_) => "PAYLOAD_TOO_LARGE",
} }
} }
@@ -68,7 +72,8 @@ impl AppError {
| AppError::Unauthorized(msg) | AppError::Unauthorized(msg)
| AppError::BadRequest(msg) | AppError::BadRequest(msg)
| AppError::Forbidden(msg) | AppError::Forbidden(msg)
| AppError::ServiceUnavailable(msg) => msg, | AppError::ServiceUnavailable(msg)
| AppError::PayloadTooLarge(msg) => msg,
} }
} }
} }
@@ -162,6 +167,15 @@ mod tests {
assert_eq!(json["error"]["status"], 403); assert_eq!(json["error"]["status"], 403);
} }
#[tokio::test]
async fn payload_too_large_returns_413() {
let (status, json) =
error_to_json(AppError::PayloadTooLarge("message too large".into())).await;
assert_eq!(status, StatusCode::PAYLOAD_TOO_LARGE);
assert_eq!(json["error"]["code"], "PAYLOAD_TOO_LARGE");
assert_eq!(json["error"]["status"], 413);
}
#[tokio::test] #[tokio::test]
async fn service_unavailable_returns_503() { async fn service_unavailable_returns_503() {
let (status, json) = error_to_json(AppError::ServiceUnavailable("IMAP down".into())).await; let (status, json) = error_to_json(AppError::ServiceUnavailable("IMAP down".into())).await;
+3 -1
View File
@@ -43,7 +43,9 @@ pub(crate) async fn connect(
.map_err(|e| ImapError::ConnectionFailed(e.to_string()))?; .map_err(|e| ImapError::ConnectionFailed(e.to_string()))?;
if creds.tls { if creds.tls {
let tls = async_native_tls::TlsConnector::new(); let tls = async_native_tls::TlsConnector::new()
.danger_accept_invalid_certs(!creds.tls_verify)
.danger_accept_invalid_hostnames(!creds.tls_verify);
let tls_stream = tls let tls_stream = tls
.connect(&creds.host, tcp) .connect(&creds.host, tcp)
.await .await
+2
View File
@@ -452,6 +452,7 @@ mod tests {
host: "imap.example.com".to_string(), host: "imap.example.com".to_string(),
port: 993, port: 993,
tls: true, tls: true,
tls_verify: true,
email: "user@example.com".to_string(), email: "user@example.com".to_string(),
password: "hunter2".to_string(), password: "hunter2".to_string(),
} }
@@ -785,6 +786,7 @@ mod tests {
host, host,
port, port,
tls, tls,
tls_verify: true,
email, email,
password, password,
}) })
+3
View File
@@ -54,6 +54,7 @@ pub(super) fn identity(creds: &ImapCredentials) -> [u8; 32] {
creds.host.as_bytes(), creds.host.as_bytes(),
&creds.port.to_be_bytes(), &creds.port.to_be_bytes(),
&[u8::from(creds.tls)], &[u8::from(creds.tls)],
&[u8::from(creds.tls_verify)],
creds.email.as_bytes(), creds.email.as_bytes(),
creds.password.as_bytes(), creds.password.as_bytes(),
] { ] {
@@ -264,6 +265,7 @@ mod tests {
host: "127.0.0.1".into(), host: "127.0.0.1".into(),
port: listener.local_addr().unwrap().port(), port: listener.local_addr().unwrap().port(),
tls: false, tls: false,
tls_verify: false,
email: "test@example.com".into(), email: "test@example.com".into(),
password: "secret".into(), password: "secret".into(),
}; };
@@ -450,6 +452,7 @@ mod tests {
host: std::env::var("TEST_IMAP_HOST").expect("TEST_IMAP_HOST required"), host: std::env::var("TEST_IMAP_HOST").expect("TEST_IMAP_HOST required"),
port: 993, port: 993,
tls: true, tls: true,
tls_verify: true,
email: std::env::var("TEST_IMAP_EMAIL").expect("TEST_IMAP_EMAIL required"), email: std::env::var("TEST_IMAP_EMAIL").expect("TEST_IMAP_EMAIL required"),
password: std::env::var("TEST_IMAP_PASSWORD").expect("TEST_IMAP_PASSWORD required"), password: std::env::var("TEST_IMAP_PASSWORD").expect("TEST_IMAP_PASSWORD required"),
}; };
+1
View File
@@ -88,6 +88,7 @@ pub struct ImapCredentials {
pub host: String, pub host: String,
pub port: u16, pub port: u16,
pub tls: bool, pub tls: bool,
pub tls_verify: bool,
pub email: String, pub email: String,
pub password: String, pub password: String,
} }
+4 -3
View File
@@ -54,9 +54,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let config = Arc::new(loaded_config); let config = Arc::new(loaded_config);
// Create the in-memory session store with the configured timeout. // Create the in-memory session store with the configured timeout.
let store = Arc::new(SessionStore::new(Duration::from_secs( let store = Arc::new(SessionStore::new_with_tls_verify(
config.session_timeout_hours * 3600, Duration::from_secs(config.session_timeout_hours * 3600),
))); config.tls_verify,
));
// Spawn a background task that periodically purges expired sessions. // Spawn a background task that periodically purges expired sessions.
{ {
+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.2\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.3\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
View File
@@ -927,6 +927,7 @@ mod tests {
host: "localhost".into(), host: "localhost".into(),
port: 143, port: 143,
tls: false, tls: false,
tls_verify: false,
email: "test@example.com".into(), email: "test@example.com".into(),
password: "test".into(), password: "test".into(),
}; };
+1
View File
@@ -116,6 +116,7 @@ async fn handle_socket_multi_account(
host: session.imap_host.clone(), host: session.imap_host.clone(),
port: session.imap_port, port: session.imap_port,
tls: session.imap_tls, tls: session.imap_tls,
tls_verify: session.imap_tls_verify,
email: session.email.clone(), email: session.email.clone(),
password: session.password.clone(), password: session.password.clone(),
}; };
+4 -5
View File
@@ -14,8 +14,6 @@ use crate::config::AppConfig;
use crate::db; use crate::db;
use crate::error::AppError; use crate::error::AppError;
/// Maximum attachment file size: 25 MB.
const MAX_ATTACHMENT_SIZE: usize = 25 * 1024 * 1024;
const STALE_PART_MAX_AGE: Duration = Duration::from_secs(24 * 60 * 60); const STALE_PART_MAX_AGE: Duration = Duration::from_secs(24 * 60 * 60);
async fn cleanup_stale_upload_parts(dir: &Path) { async fn cleanup_stale_upload_parts(dir: &Path) {
@@ -92,6 +90,7 @@ pub async fn upload_attachment(
cleanup_stale_upload_parts(&att_dir).await; cleanup_stale_upload_parts(&att_dir).await;
let mut uploaded = Vec::new(); let mut uploaded = Vec::new();
let max_attachment_size = config.max_attachment_size_bytes();
while let Some(mut field) = multipart while let Some(mut field) = multipart
.next_field() .next_field()
@@ -124,12 +123,12 @@ pub async fn upload_attachment(
}; };
let Some(chunk) = chunk else { break }; let Some(chunk) = chunk else { break };
size = size.saturating_add(chunk.len()); size = size.saturating_add(chunk.len());
if size > MAX_ATTACHMENT_SIZE { if size > max_attachment_size {
drop(file); drop(file);
let _ = tokio::fs::remove_file(&part_path).await; let _ = tokio::fs::remove_file(&part_path).await;
return Err(AppError::BadRequest(format!( return Err(AppError::BadRequest(format!(
"File '{}' exceeds maximum size of 25 MB", "File '{}' exceeds maximum size of {} MiB",
filename filename, config.max_attachment_size_mb
))); )));
} }
if let Err(e) = file.write_all(&chunk).await { if let Err(e) = file.write_all(&chunk).await {
+2
View File
@@ -197,6 +197,7 @@ pub async fn login(
let imap_port = config.imap_port; let imap_port = config.imap_port;
let imap_tls = config.tls_enabled; let imap_tls = config.tls_enabled;
let imap_tls_verify = config.tls_verify_for_host(&imap_host);
let smtp_port = config.smtp_port; let smtp_port = config.smtp_port;
let smtp_tls = config.tls_enabled; let smtp_tls = config.tls_enabled;
@@ -204,6 +205,7 @@ pub async fn login(
&imap_host, &imap_host,
imap_port, imap_port,
imap_tls, imap_tls,
imap_tls_verify,
&body.email, &body.email,
&body.password, &body.password,
) )
+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.2//EN\r\nCALSCALE:GREGORIAN\r\n", "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//RustBox//Calendar 0.0.3//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);
+3
View File
@@ -82,6 +82,7 @@ pub async fn imap_diagnostics(
host: session.imap_host.clone(), host: session.imap_host.clone(),
port: session.imap_port, port: session.imap_port,
tls: session.imap_tls, tls: session.imap_tls,
tls_verify: session.imap_tls_verify,
email: session.email.clone(), email: session.email.clone(),
password: session.password.clone(), password: session.password.clone(),
}; };
@@ -139,6 +140,7 @@ pub async fn smtp_diagnostics(
host: session.smtp_host.clone(), host: session.smtp_host.clone(),
port: session.smtp_port, port: session.smtp_port,
tls: session.smtp_tls, tls: session.smtp_tls,
tls_verify: session.smtp_tls_verify,
email: session.email.clone(), email: session.email.clone(),
password: session.password.clone(), password: session.password.clone(),
}; };
@@ -169,6 +171,7 @@ pub async fn reconnect_imap(
host: session.imap_host.clone(), host: session.imap_host.clone(),
port: session.imap_port, port: session.imap_port,
tls: session.imap_tls, tls: session.imap_tls,
tls_verify: session.imap_tls_verify,
email: session.email.clone(), email: session.email.clone(),
password: session.password.clone(), password: session.password.clone(),
}; };
+2
View File
@@ -89,9 +89,11 @@ mod tests {
imap_host: "imap.example.com".to_string(), imap_host: "imap.example.com".to_string(),
imap_port: 993, imap_port: 993,
imap_tls: true, imap_tls: true,
imap_tls_verify: true,
smtp_host: "smtp.example.com".to_string(), smtp_host: "smtp.example.com".to_string(),
smtp_port: 587, smtp_port: 587,
smtp_tls: true, smtp_tls: true,
smtp_tls_verify: true,
last_accessed: Instant::now(), last_accessed: Instant::now(),
timeout_override: None, timeout_override: None,
created_at_unix: 0, created_at_unix: 0,
+1
View File
@@ -245,6 +245,7 @@ fn session_imap_credentials(session: &SessionState) -> ImapCredentials {
host: session.imap_host.clone(), host: session.imap_host.clone(),
port: session.imap_port, port: session.imap_port,
tls: session.imap_tls, tls: session.imap_tls,
tls_verify: session.imap_tls_verify,
email: session.email.clone(), email: session.email.clone(),
password: session.password.clone(), password: session.password.clone(),
} }
+1
View File
@@ -79,6 +79,7 @@ fn build_creds(session: &SessionState, _config: &AppConfig) -> Result<ImapCreden
host: session.imap_host.clone(), host: session.imap_host.clone(),
port: session.imap_port, port: session.imap_port,
tls: session.imap_tls, tls: session.imap_tls,
tls_verify: session.imap_tls_verify,
email: session.email.clone(), email: session.email.clone(),
password: session.password.clone(), password: session.password.clone(),
}) })
+1
View File
@@ -53,6 +53,7 @@ pub async fn list_folders(
host: session.imap_host.clone(), host: session.imap_host.clone(),
port: session.imap_port, port: session.imap_port,
tls: session.imap_tls, tls: session.imap_tls,
tls_verify: session.imap_tls_verify,
email: session.email.clone(), email: session.email.clone(),
password: session.password.clone(), password: session.password.clone(),
}; };
+9
View File
@@ -28,6 +28,7 @@ fn build_creds(session: &SessionState, _config: &AppConfig) -> Result<ImapCreden
host: session.imap_host.clone(), host: session.imap_host.clone(),
port: session.imap_port, port: session.imap_port,
tls: session.imap_tls, tls: session.imap_tls,
tls_verify: session.imap_tls_verify,
email: session.email.clone(), email: session.email.clone(),
password: session.password.clone(), password: session.password.clone(),
}) })
@@ -1490,6 +1491,7 @@ mod sync_tests {
host: "localhost".into(), host: "localhost".into(),
port: 143, port: 143,
tls: false, tls: false,
tls_verify: false,
email: "test@example.com".into(), email: "test@example.com".into(),
password: "test".into(), password: "test".into(),
}; };
@@ -1506,9 +1508,11 @@ mod sync_tests {
imap_host: creds.host.clone(), imap_host: creds.host.clone(),
imap_port: creds.port, imap_port: creds.port,
imap_tls: false, imap_tls: false,
imap_tls_verify: false,
smtp_host: "localhost".into(), smtp_host: "localhost".into(),
smtp_port: 587, smtp_port: 587,
smtp_tls: false, smtp_tls: false,
smtp_tls_verify: false,
last_accessed: std::time::Instant::now(), last_accessed: std::time::Instant::now(),
timeout_override: None, timeout_override: None,
created_at_unix: 0, created_at_unix: 0,
@@ -1668,6 +1672,7 @@ mod sync_tests {
host: "localhost".into(), host: "localhost".into(),
port: 143, port: 143,
tls: false, tls: false,
tls_verify: false,
email: "test@example.com".into(), email: "test@example.com".into(),
password: "test".into(), password: "test".into(),
}; };
@@ -1684,9 +1689,11 @@ mod sync_tests {
imap_host: creds.host.clone(), imap_host: creds.host.clone(),
imap_port: 143, imap_port: 143,
imap_tls: false, imap_tls: false,
imap_tls_verify: false,
smtp_host: "localhost".into(), smtp_host: "localhost".into(),
smtp_port: 587, smtp_port: 587,
smtp_tls: false, smtp_tls: false,
smtp_tls_verify: false,
last_accessed: std::time::Instant::now(), last_accessed: std::time::Instant::now(),
timeout_override: None, timeout_override: None,
created_at_unix: 0, created_at_unix: 0,
@@ -1886,6 +1893,7 @@ mod sync_tests {
host: "localhost".into(), host: "localhost".into(),
port: 143, port: 143,
tls: false, tls: false,
tls_verify: false,
email: "test@example.com".into(), email: "test@example.com".into(),
password: "test".into(), password: "test".into(),
}, },
@@ -1955,6 +1963,7 @@ mod sync_tests {
host: "localhost".into(), host: "localhost".into(),
port: 143, port: 143,
tls: false, tls: false,
tls_verify: false,
email: "test@example.com".into(), email: "test@example.com".into(),
password: "test".into(), password: "test".into(),
}, },
+3 -1
View File
@@ -182,6 +182,8 @@ pub fn create_router(
.merge(protected_auth); .merge(protected_auth);
// Protected data routes (auth_guard + CSRF). // Protected data routes (auth_guard + CSRF).
// Multipart framing needs a small allowance on top of the configured per-file limit.
let attachment_body_limit = config.max_attachment_size_bytes().saturating_add(1024 * 1024);
let protected_data = Router::new() let protected_data = Router::new()
.route( .route(
"/folders", "/folders",
@@ -238,7 +240,7 @@ pub fn create_router(
) )
.route( .route(
"/drafts/{draft_id}/attachments", "/drafts/{draft_id}/attachments",
post(attachments::upload_attachment).layer(DefaultBodyLimit::max(30 * 1024 * 1024)), post(attachments::upload_attachment).layer(DefaultBodyLimit::max(attachment_body_limit)),
) )
.route( .route(
"/drafts/{draft_id}/attachments/{attachment_id}", "/drafts/{draft_id}/attachments/{attachment_id}",
+1
View File
@@ -146,6 +146,7 @@ pub async fn process_message(
host: session.imap_host.clone(), host: session.imap_host.clone(),
port: session.imap_port, port: session.imap_port,
tls: session.imap_tls, tls: session.imap_tls,
tls_verify: session.imap_tls_verify,
email: session.email.clone(), email: session.email.clone(),
password: session.password.clone(), password: session.password.clone(),
}; };
+1
View File
@@ -33,6 +33,7 @@ pub async fn get_quota(
host: session.imap_host.clone(), host: session.imap_host.clone(),
port: session.imap_port, port: session.imap_port,
tls: session.imap_tls, tls: session.imap_tls,
tls_verify: session.imap_tls_verify,
email: session.email.clone(), email: session.email.clone(),
password: session.password.clone(), password: session.password.clone(),
}; };
+13 -1
View File
@@ -128,6 +128,7 @@ pub async fn send_message_handler(
host: session.smtp_host.clone(), host: session.smtp_host.clone(),
port: session.smtp_port, port: session.smtp_port,
tls: session.smtp_tls, tls: session.smtp_tls,
tls_verify: session.smtp_tls_verify,
email: session.email.clone(), email: session.email.clone(),
password: session.password.clone(), password: session.password.clone(),
}; };
@@ -302,6 +303,7 @@ pub async fn send_message_handler(
.map_err(AppError::InternalError)?, .map_err(AppError::InternalError)?,
attachments, attachments,
pgp, pgp,
max_message_size_bytes: config.max_message_size_bytes(),
}; };
// Send via SMTP. The exact RFC822 bytes returned here are also appended // Send via SMTP. The exact RFC822 bytes returned here are also appended
@@ -309,7 +311,16 @@ pub async fn send_message_handler(
let sent = smtp_client let sent = smtp_client
.send_message(&smtp_creds, &message) .send_message(&smtp_creds, &message)
.await .await
.map_err(|e| AppError::ServiceUnavailable(format!("Failed to send email: {e}")))?; .map_err(|e| match e {
crate::smtp::client::SmtpError::MessageTooLarge { actual, limit } => {
AppError::PayloadTooLarge(format!(
"Message is {:.2} MiB after MIME encoding; configured limit is {:.2} MiB",
actual as f64 / 1024.0 / 1024.0,
limit as f64 / 1024.0 / 1024.0
))
}
other => AppError::ServiceUnavailable(format!("Failed to send email: {other}")),
})?;
let message_id = sent.message_id.clone(); let message_id = sent.message_id.clone();
// Best-effort: append a copy to the "Sent" folder via the account's IMAP server. // Best-effort: append a copy to the "Sent" folder via the account's IMAP server.
@@ -318,6 +329,7 @@ pub async fn send_message_handler(
host: session.imap_host.clone(), host: session.imap_host.clone(),
port: session.imap_port, port: session.imap_port,
tls: session.imap_tls, tls: session.imap_tls,
tls_verify: session.imap_tls_verify,
email: session.email.clone(), email: session.email.clone(),
password: session.password.clone(), password: session.password.clone(),
}; };
+1
View File
@@ -110,6 +110,7 @@ fn imap_credentials(session: &SessionState) -> ImapCredentials {
host: session.imap_host.clone(), host: session.imap_host.clone(),
port: session.imap_port, port: session.imap_port,
tls: session.imap_tls, tls: session.imap_tls,
tls_verify: session.imap_tls_verify,
email: session.email.clone(), email: session.email.clone(),
password: session.password.clone(), password: session.password.clone(),
} }
+2 -1
View File
@@ -86,6 +86,7 @@ async fn load_raw_headers(
host: session.imap_host.clone(), host: session.imap_host.clone(),
port: session.imap_port, port: session.imap_port,
tls: session.imap_tls, tls: session.imap_tls,
tls_verify: session.imap_tls_verify,
email: session.email.clone(), email: session.email.clone(),
password: session.password.clone(), password: session.password.clone(),
}; };
@@ -229,7 +230,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.2\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.3\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.2 size limit"), Self::ScriptTooLarge => write!(f, "Sieve script exceeds the RustBox 0.0.3 size limit"),
} }
} }
} }
+37 -43
View File
@@ -43,6 +43,34 @@ pub trait SmtpClient: Send + Sync {
/// performs the operation, and disconnects. /// performs the operation, and disconnects.
pub struct RealSmtpClient; pub struct RealSmtpClient;
fn smtp_transport(
creds: &SmtpCredentials,
) -> Result<lettre::AsyncSmtpTransport<lettre::Tokio1Executor>, SmtpError> {
use lettre::transport::smtp::authentication::Credentials;
use lettre::transport::smtp::client::{Tls, TlsParameters};
use lettre::{AsyncSmtpTransport, Tokio1Executor};
let smtp_creds = Credentials::new(creds.email.clone(), creds.password.clone());
let mut builder = AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&creds.host)
.port(creds.port)
.credentials(smtp_creds);
if creds.tls {
let parameters = TlsParameters::builder(creds.host.clone())
.dangerous_accept_invalid_certs(!creds.tls_verify)
.dangerous_accept_invalid_hostnames(!creds.tls_verify)
.build()
.map_err(|e| SmtpError::ConnectionFailed(e.to_string()))?;
builder = builder.tls(if creds.port == 587 {
Tls::Required(parameters)
} else {
Tls::Wrapper(parameters)
});
} else {
builder = builder.tls(Tls::None);
}
Ok(builder.build())
}
#[async_trait] #[async_trait]
impl SmtpClient for RealSmtpClient { impl SmtpClient for RealSmtpClient {
async fn send_message( async fn send_message(
@@ -50,8 +78,7 @@ impl SmtpClient for RealSmtpClient {
creds: &SmtpCredentials, creds: &SmtpCredentials,
message: &SendableMessage, message: &SendableMessage,
) -> Result<SmtpSendResult, SmtpError> { ) -> Result<SmtpSendResult, SmtpError> {
use lettre::transport::smtp::authentication::Credentials; use lettre::AsyncTransport;
use lettre::{AsyncSmtpTransport, AsyncTransport, Tokio1Executor};
let message_id = format!( let message_id = format!(
"<{}.{}@{}>", "<{}.{}@{}>",
@@ -62,26 +89,14 @@ impl SmtpClient for RealSmtpClient {
let email = crate::smtp::builder::build_message(message, &message_id) let email = crate::smtp::builder::build_message(message, &message_id)
.map_err(SmtpError::SendFailed)?; .map_err(SmtpError::SendFailed)?;
let rfc822 = email.formatted(); let rfc822 = email.formatted();
if message.max_message_size_bytes > 0 && rfc822.len() > message.max_message_size_bytes {
return Err(SmtpError::MessageTooLarge {
actual: rfc822.len(),
limit: message.max_message_size_bytes,
});
}
let smtp_creds = Credentials::new(creds.email.clone(), creds.password.clone()); let transport = smtp_transport(creds)?;
let transport: AsyncSmtpTransport<Tokio1Executor> = if creds.tls && creds.port == 587 {
AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&creds.host)
.map_err(|e| SmtpError::ConnectionFailed(e.to_string()))?
.port(creds.port)
.credentials(smtp_creds)
.build()
} else if creds.tls {
AsyncSmtpTransport::<Tokio1Executor>::relay(&creds.host)
.map_err(|e| SmtpError::ConnectionFailed(e.to_string()))?
.port(creds.port)
.credentials(smtp_creds)
.build()
} else {
AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&creds.host)
.port(creds.port)
.credentials(smtp_creds)
.build()
};
transport.send(email).await.map_err(|e| { transport.send(email).await.map_err(|e| {
let msg = e.to_string(); let msg = e.to_string();
@@ -107,28 +122,7 @@ impl SmtpClient for RealSmtpClient {
} }
async fn check_connection(&self, creds: &SmtpCredentials) -> Result<(), SmtpError> { async fn check_connection(&self, creds: &SmtpCredentials) -> Result<(), SmtpError> {
use lettre::transport::smtp::authentication::Credentials; let transport = smtp_transport(creds)?;
use lettre::{AsyncSmtpTransport, Tokio1Executor};
let smtp_creds = Credentials::new(creds.email.clone(), creds.password.clone());
let transport: AsyncSmtpTransport<Tokio1Executor> = if creds.tls && creds.port == 587 {
AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&creds.host)
.map_err(|e| SmtpError::ConnectionFailed(e.to_string()))?
.port(creds.port)
.credentials(smtp_creds)
.build()
} else if creds.tls {
AsyncSmtpTransport::<Tokio1Executor>::relay(&creds.host)
.map_err(|e| SmtpError::ConnectionFailed(e.to_string()))?
.port(creds.port)
.credentials(smtp_creds)
.build()
} else {
AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&creds.host)
.port(creds.port)
.credentials(smtp_creds)
.build()
};
let accepted = transport.test_connection().await.map_err(|e| { let accepted = transport.test_connection().await.map_err(|e| {
let message = e.to_string(); let message = e.to_string();
+6
View File
@@ -12,6 +12,8 @@ pub enum SmtpError {
AuthenticationFailed, AuthenticationFailed,
/// The message could not be sent. /// The message could not be sent.
SendFailed(String), SendFailed(String),
/// The fully rendered RFC822/MIME payload exceeds the configured MTA limit.
MessageTooLarge { actual: usize, limit: usize },
} }
impl fmt::Display for SmtpError { impl fmt::Display for SmtpError {
@@ -20,6 +22,10 @@ impl fmt::Display for SmtpError {
SmtpError::ConnectionFailed(msg) => write!(f, "Connection failed: {msg}"), SmtpError::ConnectionFailed(msg) => write!(f, "Connection failed: {msg}"),
SmtpError::AuthenticationFailed => write!(f, "Authentication failed"), SmtpError::AuthenticationFailed => write!(f, "Authentication failed"),
SmtpError::SendFailed(msg) => write!(f, "Send failed: {msg}"), SmtpError::SendFailed(msg) => write!(f, "Send failed: {msg}"),
SmtpError::MessageTooLarge { actual, limit } => write!(
f,
"Message is too large after MIME encoding ({actual} bytes; limit {limit} bytes)"
),
} }
} }
} }
+25 -1
View File
@@ -55,6 +55,10 @@ fn clone_error(err: &SmtpError) -> SmtpError {
SmtpError::ConnectionFailed(msg) => SmtpError::ConnectionFailed(msg.clone()), SmtpError::ConnectionFailed(msg) => SmtpError::ConnectionFailed(msg.clone()),
SmtpError::AuthenticationFailed => SmtpError::AuthenticationFailed, SmtpError::AuthenticationFailed => SmtpError::AuthenticationFailed,
SmtpError::SendFailed(msg) => SmtpError::SendFailed(msg.clone()), SmtpError::SendFailed(msg) => SmtpError::SendFailed(msg.clone()),
SmtpError::MessageTooLarge { actual, limit } => SmtpError::MessageTooLarge {
actual: *actual,
limit: *limit,
},
} }
} }
@@ -68,10 +72,16 @@ impl SmtpClient for MockSmtpClient {
if let Some(ref err) = *self.should_fail.lock().unwrap() { if let Some(ref err) = *self.should_fail.lock().unwrap() {
return Err(clone_error(err)); return Err(clone_error(err));
} }
self.sent_messages.lock().unwrap().push(message.clone());
let message_id = format!("<mock-{}>", uuid::Uuid::new_v4()); let message_id = format!("<mock-{}>", uuid::Uuid::new_v4());
let rfc822 = crate::smtp::builder::build_rfc822(message, &message_id) let rfc822 = crate::smtp::builder::build_rfc822(message, &message_id)
.map_err(SmtpError::SendFailed)?; .map_err(SmtpError::SendFailed)?;
if message.max_message_size_bytes > 0 && rfc822.len() > message.max_message_size_bytes {
return Err(SmtpError::MessageTooLarge {
actual: rfc822.len(),
limit: message.max_message_size_bytes,
});
}
self.sent_messages.lock().unwrap().push(message.clone());
Ok(SmtpSendResult { message_id, rfc822 }) Ok(SmtpSendResult { message_id, rfc822 })
} }
} }
@@ -90,6 +100,7 @@ mod tests {
host: "smtp.example.com".to_string(), host: "smtp.example.com".to_string(),
port: 587, port: 587,
tls: true, tls: true,
tls_verify: true,
email: "user@example.com".to_string(), email: "user@example.com".to_string(),
password: "hunter2".to_string(), password: "hunter2".to_string(),
} }
@@ -114,6 +125,7 @@ mod tests {
extra_headers: vec![], extra_headers: vec![],
attachments: vec![], attachments: vec![],
pgp: None, pgp: None,
max_message_size_bytes: 50 * 1024 * 1024,
} }
} }
@@ -127,6 +139,16 @@ mod tests {
assert!(!result.rfc822.is_empty()); assert!(!result.rfc822.is_empty());
} }
#[tokio::test]
async fn mock_rejects_message_larger_than_final_mime_limit() {
let mock = MockSmtpClient::new();
let mut msg = test_message();
msg.max_message_size_bytes = 1;
let err = mock.send_message(&test_creds(), &msg).await.unwrap_err();
assert!(matches!(err, SmtpError::MessageTooLarge { .. }));
assert_eq!(mock.sent_count(), 0);
}
#[tokio::test] #[tokio::test]
async fn mock_send_captures_message() { async fn mock_send_captures_message() {
let mock = MockSmtpClient::new(); let mock = MockSmtpClient::new();
@@ -152,6 +174,7 @@ mod tests {
content_id: None, content_id: None,
}], }],
pgp: None, pgp: None,
max_message_size_bytes: 50 * 1024 * 1024,
}; };
let result = mock.send_message(&test_creds(), &msg).await; let result = mock.send_message(&test_creds(), &msg).await;
@@ -205,6 +228,7 @@ mod tests {
host: "invalid.host.test".to_string(), host: "invalid.host.test".to_string(),
port: 587, port: 587,
tls: true, tls: true,
tls_verify: true,
email: "user@invalid.host.test".to_string(), email: "user@invalid.host.test".to_string(),
password: "password".to_string(), password: "password".to_string(),
}; };
+3
View File
@@ -7,6 +7,7 @@ pub struct SmtpCredentials {
pub host: String, pub host: String,
pub port: u16, pub port: u16,
pub tls: bool, pub tls: bool,
pub tls_verify: bool,
pub email: String, pub email: String,
pub password: String, pub password: String,
} }
@@ -52,6 +53,8 @@ pub struct SendableMessage {
pub attachments: Vec<AttachmentData>, pub attachments: Vec<AttachmentData>,
/// Optional OpenPGP transformation applied to the MIME entity. /// Optional OpenPGP transformation applied to the MIME entity.
pub pgp: Option<PgpSendConfig>, pub pgp: Option<PgpSendConfig>,
/// Hard limit for the final RFC822/MIME payload submitted to SMTP.
pub max_message_size_bytes: usize,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
+6
View File
@@ -95,6 +95,12 @@ pub async fn serve_ui(uri: Uri, Extension(config): Extension<Arc<AppConfig>>) ->
.replace("__RUSTBOX_ASSET_PREFIX__", asset_prefix) .replace("__RUSTBOX_ASSET_PREFIX__", asset_prefix)
.replace("__RUSTBOX_ASSET_ID__", ASSET_ID) .replace("__RUSTBOX_ASSET_ID__", ASSET_ID)
.replace("__RUSTBOX_VERSION__", env!("CARGO_PKG_VERSION")) .replace("__RUSTBOX_VERSION__", env!("CARGO_PKG_VERSION"))
.replace("__RUSTBOX_DEFAULT_THEME__", &config.default_theme)
.replace("__RUSTBOX_DEFAULT_INTERFACE_SKIN__", &config.default_interface_skin)
.replace("__RUSTBOX_MAX_ATTACHMENT_BYTES__", &config.max_attachment_size_bytes().to_string())
.replace("__RUSTBOX_MAX_ATTACHMENT_MB__", &config.max_attachment_size_mb.to_string())
.replace("__RUSTBOX_MAX_MESSAGE_BYTES__", &config.max_message_size_bytes().to_string())
.replace("__RUSTBOX_MAX_MESSAGE_MB__", &config.max_message_size_mb.to_string())
.replace("__RUSTBOX_OXI_BASE__", OXI_BASE.trim()); .replace("__RUSTBOX_OXI_BASE__", OXI_BASE.trim());
( (
StatusCode::OK, StatusCode::OK,
+157 -28
View File
@@ -37,6 +37,7 @@
--field-pad-x: 8px; --field-pad-x: 8px;
--field-pad-y: 5px; --field-pad-y: 5px;
} }
html[data-theme="light"] { html[data-theme="light"] {
color-scheme: light; color-scheme: light;
--bg: #f2f4f1; --bg: #f2f4f1;
@@ -61,32 +62,137 @@ html[data-theme="light"] {
--shadow: rgba(31, 39, 33, .16); --shadow: rgba(31, 39, 33, .16);
} }
/* User personalization */
:root { :root {
--folder-pane-width: 228px; --folder-pane-width: 228px;
--message-pane-width: 420px; --message-pane-width: 420px;
} }
html[data-color-preset="blue"] { --accent: #6f96c9; --accent-strong: #86a9d5; --selected: color-mix(in srgb, var(--accent) 20%, var(--panel)); }
html[data-color-preset="slate"] { --accent: #8e9aa8; --accent-strong: #aab4bf; --selected: color-mix(in srgb, var(--accent) 20%, var(--panel)); } html[data-color-preset="blue"] {
html[data-color-preset="amber"] { --accent: #c69a55; --accent-strong: #d7ad6b; --selected: color-mix(in srgb, var(--accent) 20%, var(--panel)); } --accent: #6f96c9;
html[data-color-preset="violet"] { --accent: #9b88c9; --accent-strong: #ad9bd5; --selected: color-mix(in srgb, var(--accent) 20%, var(--panel)); } --accent-strong: #86a9d5;
html[data-color-preset="rose"] { --accent: #c98591; --accent-strong: #d79aa5; --selected: color-mix(in srgb, var(--accent) 20%, var(--panel)); } --selected: color-mix(in srgb, var(--accent) 20%, var(--panel));
html[data-color-preset="custom"] { --bg: var(--user-bg, #121513); --panel: var(--user-panel, #181c19); --panel-2: color-mix(in srgb, var(--panel) 88%, var(--text)); --panel-3: color-mix(in srgb, var(--panel) 80%, var(--text)); --sidebar: color-mix(in srgb, var(--bg) 88%, black); --text: var(--user-text, #edf0ec); --muted: color-mix(in srgb, var(--text) 66%, var(--panel)); --subtle: color-mix(in srgb, var(--text) 52%, var(--panel)); --field: color-mix(in srgb, var(--bg) 82%, var(--panel)); --line: color-mix(in srgb, var(--text) 18%, var(--panel)); --line-strong: color-mix(in srgb, var(--text) 30%, var(--panel)); --hover: color-mix(in srgb, var(--text) 8%, var(--panel)); --accent: var(--user-accent, #6f9c86); --accent-strong: color-mix(in srgb, var(--accent) 82%, white); --selected: color-mix(in srgb, var(--accent) 22%, var(--panel)); } }
html[data-font-family="inter"] { --font-sans: Inter, ui-sans-serif, system-ui, sans-serif; }
html[data-font-family="humanist"] { --font-sans: "Segoe UI", Candara, Calibri, ui-sans-serif, system-ui, sans-serif; } html[data-color-preset="slate"] {
html[data-font-family="mono"] { --font-sans: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace; } --accent: #8e9aa8;
html[data-contrast="soft"] { --line: color-mix(in srgb, var(--line) 70%, transparent); --line-strong: color-mix(in srgb, var(--line-strong) 78%, transparent); } --accent-strong: #aab4bf;
html[data-contrast="high"] { --line: color-mix(in srgb, var(--text) 28%, var(--panel)); --line-strong: color-mix(in srgb, var(--text) 48%, var(--panel)); --muted: color-mix(in srgb, var(--text) 74%, var(--panel)); } --selected: color-mix(in srgb, var(--accent) 20%, var(--panel));
html[data-hide-avatars="true"] .avatar { display: none !important; } }
html[data-hide-attachments="true"] .message-attachment, html[data-hide-attachments="true"] .attachment-indicator { display: none !important; }
html[data-hide-flags="true"] .message-flag, html[data-hide-flags="true"] .flag-indicator { display: none !important; } html[data-color-preset="amber"] {
html[data-reduce-motion="true"] * { scroll-behavior: auto !important; transition: none !important; animation: none !important; } --accent: #c69a55;
html[data-color-preset="teal"] { --accent:#4f9d93; --accent-strong:#68b0a5; --selected:color-mix(in srgb,var(--accent) 20%,var(--panel)); } --accent-strong: #d7ad6b;
html[data-color-preset="cyan"] { --accent:#4c9fba; --accent-strong:#68b4cb; --selected:color-mix(in srgb,var(--accent) 20%,var(--panel)); } --selected: color-mix(in srgb, var(--accent) 20%, var(--panel));
html[data-color-preset="indigo"] { --accent:#7184c5; --accent-strong:#8b9bd2; --selected:color-mix(in srgb,var(--accent) 20%,var(--panel)); } }
html[data-color-preset="graphite"] { --accent:#9aa19c; --accent-strong:#b0b6b2; --selected:color-mix(in srgb,var(--accent) 18%,var(--panel)); }
html[data-color-preset="orange"] { --accent:#c9824e; --accent-strong:#d69a6d; --selected:color-mix(in srgb,var(--accent) 20%,var(--panel)); } html[data-color-preset="violet"] {
html[data-color-preset="red"] { --accent:#bd6662; --accent-strong:#cf7e7a; --selected:color-mix(in srgb,var(--accent) 20%,var(--panel)); } --accent: #9b88c9;
--accent-strong: #ad9bd5;
--selected: color-mix(in srgb, var(--accent) 20%, var(--panel));
}
html[data-color-preset="rose"] {
--accent: #c98591;
--accent-strong: #d79aa5;
--selected: color-mix(in srgb, var(--accent) 20%, var(--panel));
}
html[data-color-preset="custom"] {
--bg: var(--user-bg, #121513);
--panel: var(--user-panel, #181c19);
--panel-2: color-mix(in srgb, var(--panel) 88%, var(--text));
--panel-3: color-mix(in srgb, var(--panel) 80%, var(--text));
--sidebar: color-mix(in srgb, var(--bg) 88%, black);
--text: var(--user-text, #edf0ec);
--muted: color-mix(in srgb, var(--text) 66%, var(--panel));
--subtle: color-mix(in srgb, var(--text) 52%, var(--panel));
--field: color-mix(in srgb, var(--bg) 82%, var(--panel));
--line: color-mix(in srgb, var(--text) 18%, var(--panel));
--line-strong: color-mix(in srgb, var(--text) 30%, var(--panel));
--hover: color-mix(in srgb, var(--text) 8%, var(--panel));
--accent: var(--user-accent, #6f9c86);
--accent-strong: color-mix(in srgb, var(--accent) 82%, white);
--selected: color-mix(in srgb, var(--accent) 22%, var(--panel));
}
html[data-font-family="inter"] {
--font-sans: Inter, ui-sans-serif, system-ui, sans-serif;
}
html[data-font-family="humanist"] {
--font-sans: "Segoe UI", Candara, Calibri, ui-sans-serif, system-ui, sans-serif;
}
html[data-font-family="mono"] {
--font-sans: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace;
}
html[data-contrast="soft"] {
--line: color-mix(in srgb, var(--line) 70%, transparent);
--line-strong: color-mix(in srgb, var(--line-strong) 78%, transparent);
}
html[data-contrast="high"] {
--line: color-mix(in srgb, var(--text) 28%, var(--panel));
--line-strong: color-mix(in srgb, var(--text) 48%, var(--panel));
--muted: color-mix(in srgb, var(--text) 74%, var(--panel));
}
html[data-hide-avatars="true"] .avatar {
display: none !important;
}
html[data-hide-attachments="true"] .message-attachment,
html[data-hide-attachments="true"] .attachment-indicator {
display: none !important;
}
html[data-hide-flags="true"] .message-flag,
html[data-hide-flags="true"] .flag-indicator {
display: none !important;
}
html[data-reduce-motion="true"] * {
scroll-behavior: auto !important;
transition: none !important;
animation: none !important;
}
html[data-color-preset="teal"] {
--accent: #4f9d93;
--accent-strong: #68b0a5;
--selected: color-mix(in srgb, var(--accent) 20%, var(--panel));
}
html[data-color-preset="cyan"] {
--accent: #4c9fba;
--accent-strong: #68b4cb;
--selected: color-mix(in srgb, var(--accent) 20%, var(--panel));
}
html[data-color-preset="indigo"] {
--accent: #7184c5;
--accent-strong: #8b9bd2;
--selected: color-mix(in srgb, var(--accent) 20%, var(--panel));
}
html[data-color-preset="graphite"] {
--accent: #9aa19c;
--accent-strong: #b0b6b2;
--selected: color-mix(in srgb, var(--accent) 18%, var(--panel));
}
html[data-color-preset="orange"] {
--accent: #c9824e;
--accent-strong: #d69a6d;
--selected: color-mix(in srgb, var(--accent) 20%, var(--panel));
}
html[data-color-preset="red"] {
--accent: #bd6662;
--accent-strong: #cf7e7a;
--selected: color-mix(in srgb, var(--accent) 20%, var(--panel));
}
/* Advanced per-user interface controls. */ /* Advanced per-user interface controls. */
:root { :root {
@@ -99,11 +205,32 @@ html[data-color-preset="red"] { --accent:#bd6662; --accent-strong:#cf7e7a; --sel
--message-bg: var(--panel); --message-bg: var(--panel);
--reader-bg: var(--panel); --reader-bg: var(--panel);
} }
html[data-monochrome="true"] { --accent:#929a95; --accent-strong:#aeb4b0; --danger:#9f7775; --warning:#9b8e75; --success:#879482; }
html[data-scrollbar-density="normal"] * { scrollbar-width:auto; } html[data-monochrome="true"] {
html[data-scrollbar-density="thin"] * { scrollbar-width:thin; } --accent: #929a95;
html[data-scrollbar-density="hidden"] * { scrollbar-width:none; } --accent-strong: #aeb4b0;
html[data-scrollbar-density="hidden"] *::-webkit-scrollbar { width:0; height:0; } --danger: #9f7775;
--warning: #9b8e75;
--success: #879482;
}
html[data-scrollbar-density="normal"] * {
scrollbar-width: auto;
}
html[data-scrollbar-density="thin"] * {
scrollbar-width: thin;
}
html[data-scrollbar-density="hidden"] * {
scrollbar-width: none;
}
html[data-scrollbar-density="hidden"] *::-webkit-scrollbar {
width: 0;
height: 0;
}
html[data-show-borders="false"] .folder-pane, html[data-show-borders="false"] .folder-pane,
html[data-show-borders="false"] .message-pane, html[data-show-borders="false"] .message-pane,
html[data-show-borders="false"] .pane-toolbar, html[data-show-borders="false"] .pane-toolbar,
@@ -111,4 +238,6 @@ html[data-show-borders="false"] .reader-toolbar,
html[data-show-borders="false"] .message-header, html[data-show-borders="false"] .message-header,
html[data-show-borders="false"] .thread-section, html[data-show-borders="false"] .thread-section,
html[data-show-borders="false"] .attachment-list, html[data-show-borders="false"] .attachment-list,
html[data-show-borders="false"] .message-row { border-color:transparent !important; } html[data-show-borders="false"] .message-row {
border-color: transparent !important;
}
+63
View File
@@ -1131,3 +1131,66 @@ html[data-toolbar-density="spacious"] .reader-toolbar {
max-height: 72dvh; max-height: 72dvh;
background: #000; background: #000;
} }
.toolbar-menu {
position: relative;
flex: 0 0 auto;
}
.toolbar-menu>summary {
list-style: none;
user-select: none;
white-space: nowrap;
}
.toolbar-menu>summary::-webkit-details-marker {
display: none;
}
.toolbar-menu>summary::after {
content: "▾";
margin-left: 3px;
color: var(--subtle);
font-size: 9px;
}
.toolbar-menu[open]>summary {
border-color: var(--accent);
background: var(--selected);
}
.toolbar-menu-popover {
position: fixed;
z-index: 160;
top: 0;
left: 0;
min-width: 164px;
padding: 4px;
border: 1px solid var(--line-strong);
border-radius: var(--radius-control);
background: var(--panel);
box-shadow: 0 10px 28px var(--shadow);
}
.toolbar-menu-popover button {
width: 100%;
min-height: 30px;
display: block;
padding: 6px 8px;
border: 0;
border-radius: var(--radius-control);
background: transparent;
color: var(--text);
text-align: left;
white-space: nowrap;
}
.toolbar-menu-popover button:hover,
.toolbar-menu-popover button:focus-visible {
background: var(--hover);
}
#message-transfer-menu .toolbar-menu-popover {
left: auto;
right: 0;
}
+47 -6
View File
@@ -213,7 +213,7 @@
} }
.compose-editor:empty::before { .compose-editor:empty::before {
content: "Write a message"; content: attr(data-placeholder);
color: var(--subtle); color: var(--subtle);
} }
@@ -295,11 +295,54 @@
} }
.compose-mode-control { .compose-mode-control {
margin-left: auto; flex: 0 0 auto;
justify-self: start;
} }
.compose-mode-control select { .compose-mode-control select {
min-width: 108px; width: auto;
min-width: 122px;
}
.compose-footer {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto auto;
align-items: center;
gap: 8px;
}
.compose-submit-actions {
display: inline-flex;
align-items: center;
justify-self: end;
gap: 7px;
}
.compose-footer #compose-status {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.compose-message-size {
justify-self: end;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.compose-message-size.warning {
color: var(--warning);
}
.compose-message-size.over-limit {
color: var(--danger);
font-weight: 650;
}
.compose-dialog.compose-fullscreen .compose-mode-control {
opacity: .9;
} }
.compose-editor.plain-mode { .compose-editor.plain-mode {
@@ -386,8 +429,6 @@
font-size: 10px; font-size: 10px;
} }
/* Inline image editing */
.compose-editor img[data-rustbox-cid] { .compose-editor img[data-rustbox-cid] {
max-width: 100%; max-width: 100%;
height: auto; height: auto;
@@ -673,7 +714,6 @@
} }
/* Signature/footer editor */
.signature-editor-shell { .signature-editor-shell {
overflow: hidden; overflow: hidden;
border: 1px solid var(--line-strong); border: 1px solid var(--line-strong);
@@ -800,6 +840,7 @@
overflow: auto; overflow: auto;
} }
} }
.compose-response-control { .compose-response-control {
min-width: 118px; min-width: 118px;
max-width: 190px; max-width: 190px;
+40 -7
View File
@@ -1165,7 +1165,6 @@
margin: 6px 0; margin: 6px 0;
} }
/* Diagnostics and mailbox behavior */
.diagnostics-grid { .diagnostics-grid {
display: grid; display: grid;
gap: 12px; gap: 12px;
@@ -1363,7 +1362,6 @@
min-width: 0; min-width: 0;
} }
/* Calendar views */
.calendar-toolbar { .calendar-toolbar {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -1521,7 +1519,6 @@
} }
} }
/* 0.0.1 UI reliability pass: viewport-safe dialogs, toasts and calendar layout */
.dialog { .dialog {
max-width: calc(100vw - 16px); max-width: calc(100vw - 16px);
max-height: calc(100dvh - 16px); max-height: calc(100dvh - 16px);
@@ -1539,9 +1536,6 @@
scrollbar-gutter: auto; scrollbar-gutter: auto;
} }
/* Grid dialogs own their internal scroll regions; simple/legacy dialogs fall
back to scrolling the complete card so actions can never be trapped below
the viewport. */
.compose-card, .compose-card,
.settings-card, .settings-card,
.identity-card, .identity-card,
@@ -1830,6 +1824,41 @@
border-left-color: var(--accent); border-left-color: var(--accent);
} }
.toast-progress {
grid-template-columns: 20px minmax(0, 1fr) auto 24px;
}
.toast-progress-content {
min-width: 0;
display: grid;
gap: 6px;
}
.toast-progress-track {
display: block;
width: 100%;
height: 4px;
overflow: hidden;
border-radius: 999px;
background: var(--panel-3);
}
.toast-progress-bar {
display: block;
width: 0;
height: 100%;
border-radius: inherit;
background: var(--accent);
transition: width .12s linear;
}
.toast-progress-value {
min-width: 34px;
color: var(--muted);
font: 10px var(--font-mono);
text-align: right;
}
@media (max-width: 720px) { @media (max-width: 720px) {
.contacts-card, .contacts-card,
@@ -2291,7 +2320,6 @@ kbd {
} }
} }
/* OpenPGP settings */
.pgp-settings-grid { .pgp-settings-grid {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -2394,3 +2422,8 @@ kbd {
.response-template-meta span { .response-template-meta span {
font-size: 10px; font-size: 10px;
} }
.diagnostic-imap-actions {
justify-content: flex-end;
margin-top: 10px;
}
+626 -3
View File
@@ -1,6 +1,3 @@
/* Fixed interface styles for RustBox 0.0.2.
These styles intentionally ignore RustBox custom palette variables. */
/* ---------- Roundcube Larry ---------- */ /* ---------- Roundcube Larry ---------- */
html[data-interface-skin="roundcube-larry"] { html[data-interface-skin="roundcube-larry"] {
--font-sans: "Lucida Grande", "Segoe UI", Arial, Helvetica, sans-serif; --font-sans: "Lucida Grande", "Segoe UI", Arial, Helvetica, sans-serif;
@@ -360,3 +357,629 @@ html[data-interface-skin="high-contrast"] .button.primary {
border-color: #3b4449; border-color: #3b4449;
} }
} }
/* ---------- Modern mail-inspired styles ---------- */
html[data-interface-skin="gmail"],
html[data-interface-skin="proton"],
html[data-interface-skin="zoho"],
html[data-interface-skin="outlook"],
html[data-interface-skin="minimal"] {
--font-sans: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
html[data-interface-skin="gmail"][data-theme="light"] {
color-scheme: light;
--bg: #f6f8fc;
--panel: #ffffff;
--panel-2: #f8fafd;
--panel-3: #eef3f8;
--sidebar: #eef3f8;
--line: #e2e6ea;
--line-strong: #c7cdd3;
--text: #202124;
--muted: #5f6368;
--subtle: #7a7f85;
--accent: #0b57d0;
--accent-strong: #0842a0;
--accent-ink: #ffffff;
--danger: #b3261e;
--warning: #8a5a00;
--success: #1e8e3e;
--field: #ffffff;
--selected: #d3e3fd;
--hover: #edf2fa;
--shadow: rgba(60, 64, 67, .18);
--folder-bg: #eef3f8;
--message-bg: #ffffff;
--reader-bg: #ffffff;
}
html[data-interface-skin="gmail"][data-theme="dark"] {
color-scheme: dark;
--bg: #1f1f1f;
--panel: #242424;
--panel-2: #2b2b2b;
--panel-3: #333333;
--sidebar: #202124;
--line: #3c4043;
--line-strong: #5f6368;
--text: #e8eaed;
--muted: #bdc1c6;
--subtle: #969ba1;
--accent: #8ab4f8;
--accent-strong: #aecbfa;
--accent-ink: #10223e;
--danger: #f28b82;
--warning: #fdd663;
--success: #81c995;
--field: #202124;
--selected: #2d4668;
--hover: #303134;
--shadow: rgba(0, 0, 0, .38);
--folder-bg: #202124;
--message-bg: #242424;
--reader-bg: #242424;
}
html[data-interface-skin="proton"][data-theme="light"] {
color-scheme: light;
--bg: #f7f5ff;
--panel: #ffffff;
--panel-2: #f7f5ff;
--panel-3: #eeeaff;
--sidebar: #f2efff;
--line: #e0daf7;
--line-strong: #c5bce7;
--text: #28253d;
--muted: #6d6688;
--subtle: #8d86a5;
--accent: #6d4aff;
--accent-strong: #5637d8;
--accent-ink: #ffffff;
--danger: #c43b54;
--warning: #8d6714;
--success: #23845b;
--field: #ffffff;
--selected: #e7e1ff;
--hover: #f0edff;
--shadow: rgba(50, 38, 96, .16);
--folder-bg: #f2efff;
--message-bg: #ffffff;
--reader-bg: #ffffff;
}
html[data-interface-skin="proton"][data-theme="dark"] {
color-scheme: dark;
--bg: #171426;
--panel: #201b34;
--panel-2: #292242;
--panel-3: #332b50;
--sidebar: #1b172d;
--line: #40375e;
--line-strong: #62567f;
--text: #f2efff;
--muted: #beb6d7;
--subtle: #948aae;
--accent: #9b82ff;
--accent-strong: #b5a3ff;
--accent-ink: #171027;
--danger: #f07a91;
--warning: #e1bd62;
--success: #75c7a2;
--field: #1b172d;
--selected: #3d315f;
--hover: #2a2342;
--shadow: rgba(0, 0, 0, .42);
--folder-bg: #1b172d;
--message-bg: #201b34;
--reader-bg: #201b34;
}
html[data-interface-skin="zoho"][data-theme="light"] {
color-scheme: light;
--bg: #f5f7fb;
--panel: #ffffff;
--panel-2: #f8f9fc;
--panel-3: #edf1f6;
--sidebar: #f1f4f8;
--line: #dde3eb;
--line-strong: #bcc7d4;
--text: #202936;
--muted: #647182;
--subtle: #8390a0;
--accent: #2f6fdf;
--accent-strong: #2459b5;
--accent-ink: #ffffff;
--danger: #cf4858;
--warning: #966d11;
--success: #2c8a63;
--field: #ffffff;
--selected: #e1ebff;
--hover: #edf3fd;
--shadow: rgba(31, 45, 66, .15);
--folder-bg: #f1f4f8;
--message-bg: #ffffff;
--reader-bg: #ffffff;
}
html[data-interface-skin="zoho"][data-theme="dark"] {
color-scheme: dark;
--bg: #151a21;
--panel: #1b222c;
--panel-2: #222b36;
--panel-3: #2a3542;
--sidebar: #171e27;
--line: #344251;
--line-strong: #506175;
--text: #eef3f8;
--muted: #abb8c5;
--subtle: #8594a3;
--accent: #73a5ff;
--accent-strong: #91b8ff;
--accent-ink: #0d1c34;
--danger: #ee7b88;
--warning: #dab85d;
--success: #78bf9c;
--field: #171e27;
--selected: #293d5c;
--hover: #25303c;
--shadow: rgba(0, 0, 0, .38);
--folder-bg: #171e27;
--message-bg: #1b222c;
--reader-bg: #1b222c;
}
html[data-interface-skin="outlook"][data-theme="light"] {
color-scheme: light;
--bg: #f5f5f5;
--panel: #ffffff;
--panel-2: #faf9f8;
--panel-3: #f3f2f1;
--sidebar: #f3f2f1;
--line: #e1dfdd;
--line-strong: #c8c6c4;
--text: #242424;
--muted: #616161;
--subtle: #858585;
--accent: #0f6cbd;
--accent-strong: #0b5a9e;
--accent-ink: #ffffff;
--danger: #c50f1f;
--warning: #8a6a00;
--success: #107c10;
--field: #ffffff;
--selected: #deecf9;
--hover: #f3f6fa;
--shadow: rgba(0, 0, 0, .16);
--folder-bg: #f3f2f1;
--message-bg: #ffffff;
--reader-bg: #ffffff;
}
html[data-interface-skin="outlook"][data-theme="dark"] {
color-scheme: dark;
--bg: #1b1a19;
--panel: #252423;
--panel-2: #2d2c2b;
--panel-3: #343332;
--sidebar: #201f1e;
--line: #3b3a39;
--line-strong: #605e5c;
--text: #f3f2f1;
--muted: #c8c6c4;
--subtle: #a19f9d;
--accent: #69afe5;
--accent-strong: #8bc3ea;
--accent-ink: #102334;
--danger: #f1707b;
--warning: #e2c15d;
--success: #7fba7a;
--field: #201f1e;
--selected: #284764;
--hover: #302f2e;
--shadow: rgba(0, 0, 0, .42);
--folder-bg: #201f1e;
--message-bg: #252423;
--reader-bg: #252423;
}
html[data-interface-skin="minimal"][data-theme="light"] {
color-scheme: light;
--bg: #f8f8f7;
--panel: #ffffff;
--panel-2: #fafafa;
--panel-3: #f0f0ef;
--sidebar: #f7f7f6;
--line: #e7e7e5;
--line-strong: #d1d1ce;
--text: #242424;
--muted: #6e6e6b;
--subtle: #92928e;
--accent: #555b62;
--accent-strong: #3d4349;
--accent-ink: #ffffff;
--danger: #b74d4d;
--warning: #836b2c;
--success: #557a59;
--field: #ffffff;
--selected: #ececeb;
--hover: #f2f2f1;
--shadow: rgba(0, 0, 0, .10);
--folder-bg: #f7f7f6;
--message-bg: #ffffff;
--reader-bg: #ffffff;
}
html[data-interface-skin="minimal"][data-theme="dark"] {
color-scheme: dark;
--bg: #161616;
--panel: #1c1c1c;
--panel-2: #222222;
--panel-3: #292929;
--sidebar: #191919;
--line: #303030;
--line-strong: #484848;
--text: #eeeeec;
--muted: #b4b4b0;
--subtle: #8a8a87;
--accent: #b8bdc2;
--accent-strong: #d3d6d9;
--accent-ink: #171717;
--danger: #de7a7a;
--warning: #c5ad6a;
--success: #82a985;
--field: #181818;
--selected: #323232;
--hover: #252525;
--shadow: rgba(0, 0, 0, .35);
--folder-bg: #191919;
--message-bg: #1c1c1c;
--reader-bg: #1c1c1c;
}
html[data-interface-skin="gmail"] .folder-button.active,
html[data-interface-skin="gmail"] .message-row.active,
html[data-interface-skin="gmail"] .message-row.selected,
html[data-interface-skin="gmail"] .settings-tab.active,
html[data-interface-skin="proton"] .folder-button.active,
html[data-interface-skin="proton"] .message-row.active,
html[data-interface-skin="proton"] .message-row.selected,
html[data-interface-skin="proton"] .settings-tab.active,
html[data-interface-skin="zoho"] .folder-button.active,
html[data-interface-skin="zoho"] .message-row.active,
html[data-interface-skin="zoho"] .message-row.selected,
html[data-interface-skin="zoho"] .settings-tab.active,
html[data-interface-skin="outlook"] .folder-button.active,
html[data-interface-skin="outlook"] .message-row.active,
html[data-interface-skin="outlook"] .message-row.selected,
html[data-interface-skin="outlook"] .settings-tab.active,
html[data-interface-skin="minimal"] .folder-button.active,
html[data-interface-skin="minimal"] .message-row.active,
html[data-interface-skin="minimal"] .message-row.selected,
html[data-interface-skin="minimal"] .settings-tab.active {
background: var(--selected);
color: var(--text);
}
html[data-interface-skin="gmail"] .button.primary,
html[data-interface-skin="proton"] .button.primary,
html[data-interface-skin="zoho"] .button.primary,
html[data-interface-skin="outlook"] .button.primary,
html[data-interface-skin="minimal"] .button.primary {
background: var(--accent);
border-color: var(--accent);
color: var(--accent-ink);
}
/* ---------- Additional predefined mail styles ---------- */
html[data-interface-skin="fastmail"],
html[data-interface-skin="apple-mail"],
html[data-interface-skin="yahoo"],
html[data-interface-skin="hey"],
html[data-interface-skin="nord"] {
--font-sans: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
html[data-interface-skin="fastmail"][data-theme="light"] {
color-scheme: light;
--bg: #f5f8fc;
--panel: #fff;
--panel-2: #f8faff;
--panel-3: #edf3fb;
--sidebar: #f1f5fb;
--line: #dfe6ef;
--line-strong: #bdc9d8;
--text: #172033;
--muted: #607086;
--subtle: #8491a2;
--accent: #2563d9;
--accent-strong: #194eb8;
--accent-ink: #fff;
--danger: #c63f4c;
--warning: #8d6610;
--success: #23805b;
--field: #fff;
--selected: #d9e8ff;
--hover: #edf4ff;
--shadow: rgba(35, 55, 90, .14);
--folder-bg: #f1f5fb;
--message-bg: #fff;
--reader-bg: #fff;
}
html[data-interface-skin="fastmail"][data-theme="dark"] {
color-scheme: dark;
--bg: #151b25;
--panel: #1c2430;
--panel-2: #232d3a;
--panel-3: #2b3746;
--sidebar: #18202b;
--line: #334254;
--line-strong: #52657d;
--text: #eef4fc;
--muted: #b4c0cf;
--subtle: #8998aa;
--accent: #79a8ff;
--accent-strong: #9bbdff;
--accent-ink: #10203b;
--danger: #ef7e89;
--warning: #ddb75c;
--success: #7ac6a0;
--field: #18202b;
--selected: #2b466d;
--hover: #263343;
--shadow: rgba(0, 0, 0, .4);
--folder-bg: #18202b;
--message-bg: #1c2430;
--reader-bg: #1c2430;
}
html[data-interface-skin="apple-mail"][data-theme="light"] {
color-scheme: light;
--bg: #f5f5f7;
--panel: #fff;
--panel-2: #fafafa;
--panel-3: #f0f1f3;
--sidebar: #f3f4f6;
--line: #e2e3e6;
--line-strong: #c7c9ce;
--text: #1d1d1f;
--muted: #66666b;
--subtle: #8d8d93;
--accent: #0a84ff;
--accent-strong: #006bd6;
--accent-ink: #fff;
--danger: #d43b48;
--warning: #946b00;
--success: #24845d;
--field: #fff;
--selected: #dcebff;
--hover: #f0f6ff;
--shadow: rgba(0, 0, 0, .12);
--folder-bg: #f3f4f6;
--message-bg: #fff;
--reader-bg: #fff;
}
html[data-interface-skin="apple-mail"][data-theme="dark"] {
color-scheme: dark;
--bg: #171719;
--panel: #202023;
--panel-2: #27272a;
--panel-3: #303034;
--sidebar: #1b1b1e;
--line: #38383d;
--line-strong: #595960;
--text: #f5f5f7;
--muted: #b7b7bd;
--subtle: #8e8e95;
--accent: #64a8ff;
--accent-strong: #82baff;
--accent-ink: #102237;
--danger: #ff808b;
--warning: #dfbc65;
--success: #78c69f;
--field: #1c1c1f;
--selected: #294867;
--hover: #2a2a2e;
--shadow: rgba(0, 0, 0, .38);
--folder-bg: #1b1b1e;
--message-bg: #202023;
--reader-bg: #202023;
}
html[data-interface-skin="yahoo"][data-theme="light"] {
color-scheme: light;
--bg: #f7f5fb;
--panel: #fff;
--panel-2: #faf8ff;
--panel-3: #f1edfa;
--sidebar: #f4f0fb;
--line: #e6dff1;
--line-strong: #cbbdde;
--text: #24202d;
--muted: #6c6278;
--subtle: #91859e;
--accent: #6001d2;
--accent-strong: #4b00a6;
--accent-ink: #fff;
--danger: #c63f58;
--warning: #8f6810;
--success: #28815e;
--field: #fff;
--selected: #ede3ff;
--hover: #f4edff;
--shadow: rgba(65, 27, 100, .14);
--folder-bg: #f4f0fb;
--message-bg: #fff;
--reader-bg: #fff;
}
html[data-interface-skin="yahoo"][data-theme="dark"] {
color-scheme: dark;
--bg: #1a1420;
--panel: #231a2b;
--panel-2: #2d2137;
--panel-3: #392946;
--sidebar: #1e1726;
--line: #44334f;
--line-strong: #685275;
--text: #f6effb;
--muted: #c8b9d0;
--subtle: #9e8ba9;
--accent: #b686ff;
--accent-strong: #c9a7ff;
--accent-ink: #231038;
--danger: #ef8298;
--warning: #dfbd61;
--success: #7ac5a0;
--field: #1e1726;
--selected: #493166;
--hover: #30243a;
--shadow: rgba(0, 0, 0, .42);
--folder-bg: #1e1726;
--message-bg: #231a2b;
--reader-bg: #231a2b;
}
html[data-interface-skin="hey"][data-theme="light"] {
color-scheme: light;
--bg: #fff8de;
--panel: #fffdf4;
--panel-2: #fff6d0;
--panel-3: #f5e8ae;
--sidebar: #fff3c2;
--line: #eadb99;
--line-strong: #cbb568;
--text: #29251c;
--muted: #756b4d;
--subtle: #9a8e69;
--accent: #e5533d;
--accent-strong: #bd3e2c;
--accent-ink: #fff;
--danger: #c73838;
--warning: #8c6500;
--success: #35795c;
--field: #fffdf4;
--selected: #fff0a8;
--hover: #fff4c5;
--shadow: rgba(90, 70, 15, .14);
--folder-bg: #fff3c2;
--message-bg: #fffdf4;
--reader-bg: #fffdf4;
}
html[data-interface-skin="hey"][data-theme="dark"] {
color-scheme: dark;
--bg: #211f18;
--panel: #29271e;
--panel-2: #333025;
--panel-3: #403a28;
--sidebar: #252219;
--line: #4b4633;
--line-strong: #70684a;
--text: #fff7d8;
--muted: #c9bd91;
--subtle: #9f9470;
--accent: #ff8a6e;
--accent-strong: #ffa18b;
--accent-ink: #34160f;
--danger: #f47c7c;
--warning: #e0bd60;
--success: #81c29e;
--field: #252219;
--selected: #554a25;
--hover: #363223;
--shadow: rgba(0, 0, 0, .38);
--folder-bg: #252219;
--message-bg: #29271e;
--reader-bg: #29271e;
}
html[data-interface-skin="nord"][data-theme="light"] {
color-scheme: light;
--bg: #eceff4;
--panel: #f8fafc;
--panel-2: #eef2f7;
--panel-3: #e5e9f0;
--sidebar: #e5e9f0;
--line: #d2d8e1;
--line-strong: #b4bfcc;
--text: #2e3440;
--muted: #596273;
--subtle: #7a8494;
--accent: #5e81ac;
--accent-strong: #4c6f99;
--accent-ink: #fff;
--danger: #bf616a;
--warning: #a47718;
--success: #4f806c;
--field: #f8fafc;
--selected: #d8dee9;
--hover: #e7ecf2;
--shadow: rgba(46, 52, 64, .15);
--folder-bg: #e5e9f0;
--message-bg: #f8fafc;
--reader-bg: #f8fafc;
}
html[data-interface-skin="nord"][data-theme="dark"] {
color-scheme: dark;
--bg: #2e3440;
--panel: #343b49;
--panel-2: #3b4252;
--panel-3: #434c5e;
--sidebar: #303744;
--line: #4c566a;
--line-strong: #67748c;
--text: #eceff4;
--muted: #c3cad6;
--subtle: #9aa5b6;
--accent: #88c0d0;
--accent-strong: #8fbcbb;
--accent-ink: #223039;
--danger: #bf616a;
--warning: #ebcb8b;
--success: #a3be8c;
--field: #303744;
--selected: #46556c;
--hover: #3b4555;
--shadow: rgba(0, 0, 0, .36);
--folder-bg: #303744;
--message-bg: #343b49;
--reader-bg: #343b49;
}
html[data-interface-skin="fastmail"] .folder-button.active,
html[data-interface-skin="fastmail"] .message-row.active,
html[data-interface-skin="fastmail"] .message-row.selected,
html[data-interface-skin="fastmail"] .settings-tab.active,
html[data-interface-skin="apple-mail"] .folder-button.active,
html[data-interface-skin="apple-mail"] .message-row.active,
html[data-interface-skin="apple-mail"] .message-row.selected,
html[data-interface-skin="apple-mail"] .settings-tab.active,
html[data-interface-skin="yahoo"] .folder-button.active,
html[data-interface-skin="yahoo"] .message-row.active,
html[data-interface-skin="yahoo"] .message-row.selected,
html[data-interface-skin="yahoo"] .settings-tab.active,
html[data-interface-skin="hey"] .folder-button.active,
html[data-interface-skin="hey"] .message-row.active,
html[data-interface-skin="hey"] .message-row.selected,
html[data-interface-skin="hey"] .settings-tab.active,
html[data-interface-skin="nord"] .folder-button.active,
html[data-interface-skin="nord"] .message-row.active,
html[data-interface-skin="nord"] .message-row.selected,
html[data-interface-skin="nord"] .settings-tab.active {
background: var(--selected);
color: var(--text);
}
html[data-interface-skin="fastmail"] .button.primary,
html[data-interface-skin="apple-mail"] .button.primary,
html[data-interface-skin="yahoo"] .button.primary,
html[data-interface-skin="hey"] .button.primary,
html[data-interface-skin="nord"] .button.primary {
background: var(--accent);
border-color: var(--accent);
color: var(--accent-ink);
}
+325
View File
@@ -270,3 +270,328 @@ html[data-preview="bottom"] .reader-pane {
animation: none !important; animation: none !important;
} }
} }
/* 0.0.3 mobile reliability pass */
.folder-quota {
display: grid;
gap: 5px;
margin-top: auto;
padding: 9px 0 8px;
border-top: 1px solid var(--line);
}
.folder-quota+.folder-pane-footer {
margin-top: 0;
padding-top: 0;
border-top: 0;
}
.folder-quota-label {
display: flex;
justify-content: space-between;
gap: 8px;
min-width: 0;
color: var(--subtle);
font: 9px var(--font-mono);
}
.folder-quota-label span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.folder-quota-track {
height: 5px;
overflow: hidden;
border-radius: 999px;
background: var(--panel-3);
}
.folder-quota-track span {
display: block;
width: 0;
height: 100%;
border-radius: inherit;
background: var(--accent);
transition: width .16s ease;
}
.folder-quota.quota-warning .folder-quota-track span {
background: var(--warning);
}
.folder-quota.quota-danger .folder-quota-track span {
background: var(--danger);
}
/* The visual Sieve builder must never let checkbox inputs inherit width:100%. */
.sieve-builder .checkbox-row input[type="checkbox"] {
width: 15px;
min-width: 15px;
max-width: 15px;
height: 15px;
flex: 0 0 15px;
padding: 0;
}
.sieve-builder .checkbox-row span {
min-width: 0;
flex: 0 1 auto;
}
.reader-recipients {
cursor: pointer;
border-radius: var(--radius-control);
}
.reader-recipients:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.message-meta.addresses-expanded .sender strong,
.message-meta.addresses-expanded .recipient-line>span {
overflow: visible;
text-overflow: clip;
white-space: normal;
overflow-wrap: anywhere;
}
.message-meta.addresses-expanded {
align-items: start;
}
@media (max-width: 760px) {
.sieve-builder-grid,
.sieve-builder-grid-rule,
.sieve-dynamic-row {
grid-template-columns: minmax(0, 1fr) !important;
}
.sieve-builder .checkbox-row {
align-self: start;
justify-content: flex-start;
min-height: auto;
padding-block: 4px;
}
}
@media (max-width: 560px) {
.dialog {
box-sizing: border-box;
width: calc(100vw - 12px);
max-width: calc(100vw - 12px);
overflow-x: hidden;
}
.dialog>.dialog-card {
box-sizing: border-box;
width: 100% !important;
min-width: 0 !important;
max-width: 100% !important;
}
.dialog-header,
.dialog-actions,
.settings-panel,
.settings-panel-heading,
.settings-section,
.settings-grid,
.appearance-grid,
.appearance-toggles,
.appearance-presets,
.sieve-layout,
.sieve-editor-wrap,
.headers-content,
.message-source-content {
min-width: 0;
max-width: 100%;
}
.dialog-header {
width: 100%;
}
.dialog-actions {
flex-wrap: wrap;
gap: 6px;
}
.dialog-actions .toolbar-spacer {
display: none;
}
.settings-card {
width: 100%;
height: calc(100dvh - 12px);
}
.settings-layout {
width: 100%;
min-width: 0;
height: 100%;
grid-template-columns: minmax(0, 1fr);
grid-template-rows: auto minmax(0, 1fr);
align-content: start;
overflow: hidden;
}
.settings-nav {
width: 100%;
min-width: 0;
min-height: auto;
max-height: none;
align-items: center;
padding: 6px;
}
.settings-tab {
align-self: center;
min-height: 32px;
padding: 5px 8px;
}
.settings-content {
width: 100%;
min-width: 0;
max-width: 100%;
padding: 12px;
overflow-x: hidden;
overflow-y: auto;
}
.settings-content * {
max-width: 100%;
}
.settings-content .settings-grid,
.settings-content .appearance-grid,
.settings-content .appearance-toggles,
.settings-content .notification-folder-list,
.settings-content .pgp-settings-grid {
grid-template-columns: minmax(0, 1fr) !important;
}
.settings-panel-heading,
.sieve-header {
flex-wrap: wrap;
}
.settings-panel-heading>.button,
.sieve-header>.inline-actions {
max-width: 100%;
}
.headers-card,
.source-card,
.calendar-card,
.contacts-card {
width: 100% !important;
max-width: 100% !important;
}
.headers-content,
.message-source-content {
width: 100%;
overflow-x: auto;
}
.calendar-toolbar {
display: grid !important;
grid-template-columns: repeat(2, minmax(0, 1fr)) !important;
gap: 7px;
}
.calendar-toolbar>* {
min-width: 0 !important;
width: 100%;
max-width: 100%;
}
.calendar-toolbar #calendar-range {
grid-column: 1 / -1;
order: -2;
min-height: 24px;
}
.calendar-toolbar #calendar-view {
grid-column: 1 / -1;
}
.calendar-toolbar #calendar-new {
grid-column: 1 / -1;
}
.calendar-toolbar .button,
.calendar-toolbar select {
white-space: normal;
}
.message-meta {
grid-template-columns: 32px minmax(0, 1fr);
}
.message-meta time {
grid-column: 2;
justify-self: start;
}
}
@media (max-width: 560px) {
.reader-toolbar .toolbar-menu>summary span {
display: none;
}
.reader-toolbar .toolbar-menu>summary {
min-width: 30px;
padding-inline: 7px;
}
.reader-toolbar .toolbar-menu-popover {
position: fixed;
top: auto;
right: 8px;
bottom: 8px;
left: 8px;
min-width: 0;
max-width: none;
}
.compose-footer {
grid-template-columns: minmax(0, 1fr) auto;
}
.compose-footer #compose-status {
grid-column: 1;
grid-row: 1;
min-height: 0;
}
.compose-message-size {
grid-column: 2;
grid-row: 1;
justify-self: end;
font-size: 11px;
}
.compose-mode-control {
grid-column: 1;
grid-row: 2;
justify-self: start;
margin-left: 0;
}
.compose-submit-actions {
grid-column: 2;
grid-row: 2;
justify-self: end;
}
.compose-mode-control select {
min-width: 108px;
}
}
+20 -1
View File
@@ -36,5 +36,24 @@
"print_message": "Print message", "print_message": "Print message",
"create_filter_sender": "Create filter from sender", "create_filter_sender": "Create filter from sender",
"filter_draft_created": "Filter draft created from message", "filter_draft_created": "Filter draft created from message",
"select_all_results": "Select all results" "select_all_results": "Select all results",
"loading": "Loading…",
"loading_quota": "Loading quota…",
"of": "of",
"used": "used",
"server_limit_unavailable": "server limit unavailable",
"quota_unavailable": "Quota unavailable",
"unavailable": "Unavailable",
"show_full_addresses": "Show full addresses",
"collapse_addresses": "Collapse addresses",
"fixed_interface_style_note": "fixed interface style. Theme still switches light/dark; choose RustBox (custom) to edit colors, spacing and geometry.",
"custom_interface_style_note": "RustBox is fully customizable: colors, spacing, panes, typography and advanced interface controls are available below.",
"reply": "Reply",
"reply_all": "Reply all",
"forward": "Forward",
"headers": "Headers",
"message_details": "Message details",
"move_copy": "Move / copy",
"move_to": "Move to…",
"copy_to": "Copy to…"
} }
+594 -1
View File
@@ -36,5 +36,598 @@
"print_message": "Drukuj wiadomość", "print_message": "Drukuj wiadomość",
"create_filter_sender": "Utwórz filtr z nadawcy", "create_filter_sender": "Utwórz filtr z nadawcy",
"filter_draft_created": "Utworzono szkic filtra z wiadomości", "filter_draft_created": "Utworzono szkic filtra z wiadomości",
"select_all_results": "Zaznacz wszystkie wyniki" "select_all_results": "Zaznacz wszystkie wyniki",
"loading": "Ładowanie…",
"loading_quota": "Ładowanie limitu…",
"of": "z",
"used": "wykorzystano",
"server_limit_unavailable": "limit serwera niedostępny",
"quota_unavailable": "Limit niedostępny",
"unavailable": "Niedostępne",
"show_full_addresses": "Pokaż pełne adresy",
"collapse_addresses": "Zwiń adresy",
"_text": {
"Webmail": "Poczta internetowa",
"Email": "E-mail",
"Password": "Hasło",
"Keep me signed in": "Zapamiętaj mnie",
"Sign in": "Zaloguj się",
"Connecting": "Łączenie",
"Folders": "Foldery",
"Compose": "Napisz",
"Labels": "Etykiety",
"IMAP storage": "Pojemność IMAP",
"Loading…": "Ładowanie…",
"New message": "Nowa wiadomość",
"Recover draft": "Odzyskaj szkic",
"From": "Od",
"Account default": "Domyślne dla konta",
"To": "Do",
"Cc": "DW",
"Bcc": "UDW",
"Subject": "Temat",
"Options": "Opcje",
"Priority": "Priorytet",
"Normal": "Normalny",
"High": "Wysoki",
"Low": "Niski",
"Off": "Wyłączone",
"Sign": "Podpisz",
"Encrypt": "Szyfruj",
"Sign + encrypt": "Podpisz i szyfruj",
"Request read receipt": "Żądaj potwierdzenia odczytu",
"OpenPGP disabled": "OpenPGP wyłączone",
"Details": "Szczegóły",
"Inline OpenPGP (text only; PGP/MIME recommended)": "OpenPGP inline (tylko tekst; zalecane PGP/MIME)",
"Attach my public OpenPGP key": "Dołącz mój publiczny klucz OpenPGP",
"Attach public keys of To/Cc recipients": "Dołącz klucze publiczne odbiorców Do/DW",
"Bcc recipient keys are never attached, so hidden recipients are not disclosed.": "Klucze odbiorców UDW nie są dołączane, aby nie ujawniać ukrytych odbiorców.",
"Private-key passphrase": "Hasło klucza prywatnego",
"Check recipient keys": "Sprawdź klucze odbiorców",
"Find missing keys": "Znajdź brakujące klucze",
"Font": "Czcionka",
"Text size": "Rozmiar tekstu",
"Size": "Rozmiar",
"Small": "Mały",
"Large": "Duży",
"Larger": "Większy",
"Text color": "Kolor tekstu",
"Saved response": "Zapisana odpowiedź",
"Response…": "Odpowiedź…",
"Compose format": "Format wiadomości",
"Plain text": "Zwykły tekst",
"Uploading…": "Wysyłanie…",
"Cancel": "Anuluj",
"Send": "Wyślij",
"Create folder": "Utwórz folder",
"Create": "Utwórz",
"Current folder": "Bieżący folder",
"Subscribe / Unsubscribe": "Subskrybuj / anuluj subskrypcję",
"Rename": "Zmień nazwę",
"Delete": "Usuń",
"Settings": "Ustawienia",
"Appearance": "Wygląd",
"Mailbox": "Skrzynka",
"Composing": "Redagowanie",
"Identities & signatures": "Tożsamości i podpisy",
"Notifications": "Powiadomienia",
"Filters": "Filtry",
"Diagnostics": "Diagnostyka",
"Account": "Konto",
"About": "Informacje",
"Personalize RustBox without changing mail behavior.": "Dostosuj wygląd RustBox bez zmiany działania poczty.",
"Reset appearance": "Przywróć wygląd",
"Language": "Język",
"English": "Angielski",
"Theme": "Motyw",
"Dark": "Ciemny",
"Light": "Jasny",
"System": "Systemowy",
"Interface style": "Styl interfejsu",
"RustBox (custom)": "RustBox (niestandardowy)",
"Global default": "Domyślny globalny",
"Gmail inspired": "Inspiracja Gmail",
"Proton inspired": "Inspiracja Proton",
"Zoho inspired": "Inspiracja Zoho",
"Outlook inspired": "Inspiracja Outlook",
"High contrast": "Wysoki kontrast",
"RustBox is fully customizable: colors, spacing, panes and typography.": "RustBox można w pełni dostosować: kolory, odstępy, panele i typografię.",
"Color preset": "Paleta kolorów",
"Custom": "Niestandardowy",
"Accent color": "Kolor akcentu",
"Custom background": "Niestandardowe tło",
"Custom panel": "Niestandardowy panel",
"Custom text": "Niestandardowy tekst",
"Density": "Gęstość",
"Compact": "Kompaktowa",
"Comfortable": "Wygodna",
"Preview pane": "Panel podglądu",
"Right": "Prawo",
"Bottom": "Dół",
"UI size": "Rozmiar interfejsu",
"Font family": "Krój pisma",
"System UI": "Systemowy",
"Humanist": "Humanistyczny",
"Monospace": "Monospace",
"Font size": "Rozmiar tekstu",
"Message row height": "Wysokość wiersza wiadomości",
"Folder pane width": "Szerokość panelu folderów",
"Message pane width": "Szerokość listy wiadomości",
"Corner radius": "Zaokrąglenie narożników",
"Contrast": "Kontrast",
"Soft": "Łagodny",
"Date format": "Format daty",
"Locale": "Lokalny",
"Time format": "Format godziny",
"24-hour": "24-godzinny",
"12-hour": "12-godzinny",
"Show avatars": "Pokazuj awatary",
"Show attachment icons": "Pokazuj ikony załączników",
"Show flags": "Pokazuj flagi",
"Reduce motion": "Ogranicz animacje",
"Advanced interface styling": "Zaawansowany wygląd interfejsu",
"Icon size": "Rozmiar ikon",
"Toolbar density": "Gęstość paska narzędzi",
"Spacious": "Przestronna",
"Sidebar item height": "Wysokość elementu paska bocznego",
"Reader max width": "Maks. szerokość czytnika",
"Fluid": "Płynna",
"Composer width": "Szerokość edytora",
"Selection color": "Kolor zaznaczenia",
"Unread weight": "Grubość nieprzeczytanych",
"Unread marker": "Znacznik nieprzeczytanych",
"Dot": "Kropka",
"Outline": "Obrys",
"Solid": "Pełny",
"Scrollbar": "Pasek przewijania",
"Thin": "Cienki",
"Hidden": "Ukryty",
"Compact message headers": "Kompaktowe nagłówki wiadomości",
"Show panel borders": "Pokazuj obramowania paneli",
"Monochrome interface": "Monochromatyczny interfejs",
"Folder background override": "Nadpisanie tła folderów",
"Message-list background override": "Nadpisanie tła listy wiadomości",
"Reader background override": "Nadpisanie tła czytnika",
"Control message-list and reader behavior for this account.": "Ustaw zachowanie listy wiadomości i czytnika dla tego konta.",
"Messages per page": "Wiadomości na stronę",
"Mark as read": "Oznacz jako przeczytane",
"Immediately": "Natychmiast",
"After 0.75 s": "Po 0,75 s",
"After 1.5 s": "Po 1,5 s",
"After 3 s": "Po 3 s",
"After 5 s": "Po 5 s",
"Remote images": "Zdalne obrazy",
"Never": "Nigdy",
"Contacts only": "Tylko kontakty",
"Ask for each message": "Pytaj dla każdej wiadomości",
"Always load": "Zawsze wczytuj",
"Message body": "Treść wiadomości",
"Auto": "Automatycznie",
"Prefer HTML": "Preferuj HTML",
"Threads": "Wątki",
"Threaded": "Wątki",
"Flat": "Płasko",
"Default sort": "Domyślne sortowanie",
"Newest first": "Najnowsze najpierw",
"Oldest first": "Najstarsze najpierw",
"Sender A–Z": "Nadawca A–Z",
"Sender Z–A": "Nadawca Z–A",
"Subject A–Z": "Temat A–Z",
"Subject Z–A": "Temat Z–A",
"Smallest first": "Najmniejsze najpierw",
"Largest first": "Największe najpierw",
"Collapse quoted text where possible": "Zwijaj cytowany tekst, gdy to możliwe",
"Show message snippets in the list": "Pokazuj fragmenty wiadomości na liście",
"Keep flagged messages on top by default": "Domyślnie trzymaj oznaczone wiadomości na górze",
"Synchronize RustBox labels as IMAP keywords": "Synchronizuj etykiety RustBox jako słowa kluczowe IMAP",
"Keyword synchronization is used only in folders whose IMAP server advertises persistent custom flags. Local labels remain available everywhere.": "Synchronizacja słów kluczowych działa tylko w folderach, w których serwer IMAP obsługuje trwałe własne flagi. Lokalne etykiety pozostają dostępne wszędzie.",
"Save mailbox settings": "Zapisz ustawienia skrzynki",
"Reconcile IMAP labels now": "Synchronizuj etykiety IMAP teraz",
"Defaults for new messages and draft behavior.": "Domyślne ustawienia nowych wiadomości i szkiców.",
"Default format": "Domyślny format",
"Autosave interval": "Interwał autozapisu",
"1 second": "1 sekunda",
"3 seconds": "3 sekundy",
"5 seconds": "5 sekund",
"10 seconds": "10 sekund",
"20 seconds": "20 sekund",
"60 seconds": "60 sekund",
"Default priority": "Domyślny priorytet",
"Default message font": "Domyślna czcionka wiadomości",
"System sans": "Systemowy sans",
"Humanist sans": "Humanistyczny sans",
"Serif": "Szeryfowa",
"Reply position": "Pozycja odpowiedzi",
"Write above quote": "Pisz nad cytatem",
"Write below quote": "Pisz pod cytatem",
"Quote style": "Styl cytatu",
"Standard attribution": "Standardowe przypisanie",
"Compact attribution": "Kompaktowe przypisanie",
"Do not include quote": "Nie dołączaj cytatu",
"Forward button": "Przycisk przekazywania",
"Forward inline": "Przekazuj w treści",
"Forward as .eml attachment": "Przekazuj jako załącznik .eml",
"Request read receipt by default": "Domyślnie żądaj potwierdzenia odczytu",
"Warn when message text suggests a missing attachment": "Ostrzegaj, gdy treść sugeruje brak załącznika",
"Use browser spellcheck while composing": "Używaj sprawdzania pisowni przeglądarki",
"Markdown is stored as source text and sent as a safe rendered HTML alternative with a plain-text fallback.": "Markdown jest przechowywany jako tekst źródłowy i wysyłany jako bezpiecznie wyrenderowany HTML z wersją tekstową.",
"Saved responses": "Zapisane odpowiedzi",
"Reusable text or HTML responses, compatible with the Roundcube canned-response workflow.": "Wielokrotnego użytku odpowiedzi tekstowe lub HTML, zgodne z przepływem gotowych odpowiedzi Roundcube.",
"New response": "Nowa odpowiedź",
"No saved responses.": "Brak zapisanych odpowiedzi.",
"Choose special IMAP folders used by RustBox. Values are stored per account.": "Wybierz specjalne foldery IMAP używane przez RustBox. Wartości są zapisywane osobno dla konta.",
"Auto-detect": "Wykryj automatycznie",
"Sent": "Wysłane",
"Drafts": "Szkice",
"Trash": "Kosz",
"Default spam destination": "Domyślny folder spamu",
"Shared by all accounts in this browser when that folder exists.": "Wspólne dla wszystkich kont w tej przeglądarce, jeśli folder istnieje.",
"Spam destination override (this account)": "Nadpisanie folderu spamu (to konto)",
"Leave automatic to use the default above, then IMAP Junk/Spam auto-detection.": "Pozostaw automatyczne, aby użyć ustawienia powyżej, a następnie automatycznego wykrywania IMAP Junk/Spam.",
"Archive": "Archiwizuj",
"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.": "Foldery Wysłane, Kosz, Spam/Junk i Archiwum są używane bezpośrednio przez akcje poczty. Folder spamu jest wybierany kolejno: ustawienie konta → domyślne przeglądarki → automatyczne wykrywanie IMAP Junk/Spam.",
"Mailbox storage": "Pamięć skrzynki",
"Loading quota…": "Ładowanie limitu…",
"Save folder settings": "Zapisz ustawienia folderów",
"Sender identities and account-wide signatures. Editing opens in a dedicated window so the form never collides with Settings.": "Tożsamości nadawcy i podpisy konta. Edycja otwiera się w osobnym oknie, aby formularz nie kolidował z Ustawieniami.",
"New identity": "Nowa tożsamość",
"Configured identities": "Skonfigurowane tożsamości",
"Select an identity to edit its sender data, text/HTML footer, image and OpenPGP defaults.": "Wybierz tożsamość, aby edytować dane nadawcy, stopkę tekstową/HTML, obraz i domyślne ustawienia OpenPGP.",
"Manage per-user keys, trust and discovery. Private keys never leave the server unless you explicitly export one.": "Zarządzaj kluczami użytkownika, zaufaniem i wyszukiwaniem. Klucze prywatne nie opuszczają serwera, chyba że jawnie je wyeksportujesz.",
"Refresh": "Odśwież",
"Policy & discovery": "Polityka i wyszukiwanie",
"Discover keys with WKD": "Wyszukuj klucze przez WKD",
"Fallback to configured HTTPS key server": "Użyj skonfigurowanego serwera kluczy HTTPS jako zapasowego",
"Encrypt only to trusted keys": "Szyfruj tylko do zaufanych kluczy",
"Save OpenPGP policy": "Zapisz politykę OpenPGP",
"Generate key": "Generuj klucz",
"Name": "Nazwa",
"Passphrase": "Hasło",
"Not stored by RustBox.": "Nie jest przechowywane przez RustBox.",
"Import / discover": "Import / wyszukiwanie",
"Armored public/private key": "Klucz publiczny/prywatny ASCII-armored",
"Initial trust": "Początkowe zaufanie",
"Unknown": "Nieznane",
"Verified": "Zweryfikowane",
"Trusted": "Zaufane",
"Untrusted": "Niezaufane",
"Import": "Importuj",
"Import file": "Importuj plik",
"Discover by email": "Wyszukaj po e-mailu",
"Discover key": "Wyszukaj klucz",
"Keyring": "Brelok kluczy",
"No keys loaded": "Brak wczytanych kluczy",
"Desktop notifications": "Powiadomienia systemowe",
"Sound for new mail and calendar reminders": "Dźwięk dla nowej poczty i przypomnień kalendarza",
"Do Not Disturb": "Nie przeszkadzać",
"DND from": "DND od",
"DND until": "DND do",
"Browser permission: unknown": "Uprawnienie przeglądarki: nieznane",
"Request permission": "Poproś o uprawnienie",
"Test notification": "Testuj powiadomienie",
"Save": "Zapisz",
"ManageSieve filters": "Filtry ManageSieve",
"Not checked": "Niesprawdzono",
"Backup": "Kopia zapasowa",
"Restore": "Przywróć",
"Scripts": "Skrypty",
"New script": "Nowy skrypt",
"Script name": "Nazwa skryptu",
"Rules in this script": "Reguły w tym skrypcie",
"Existing": "Istniejące",
"filters and RustBox rules are parsed automatically.": "filtry i reguły RustBox są analizowane automatycznie.",
"No rules found in this script": "Brak reguł w tym skrypcie",
"Create filter": "Utwórz filtr",
"Rules are edited in a separate visual window with a live “what will happen” preview.": "Reguły są edytowane w osobnym oknie wizualnym z podglądem „co się stanie”.",
"Autoresponder / vacation": "Autoresponder / urlop",
"Reply interval (days)": "Odstęp odpowiedzi (dni)",
"Active from": "Aktywne od",
"Active until": "Aktywne do",
"Message": "Wiadomość",
"Skip mailing lists": "Pomiń listy mailingowe",
"Skip automated messages": "Pomiń wiadomości automatyczne",
"Append autoresponder": "Dodaj autoresponder",
"Advanced Sieve source": "Zaawansowane źródło Sieve",
"Use this only for syntax the visual editor does not understand. Parsed rules stay editable above.": "Używaj tylko dla składni, której edytor wizualny nie rozumie. Rozpoznane reguły nadal można edytować powyżej.",
"Validate": "Sprawdź",
"Activate": "Aktywuj",
"Deactivate all": "Dezaktywuj wszystkie",
"Live capability checks for the current account. Credentials and server host names are not returned to the browser.": "Bieżące sprawdzanie możliwości aktualnego konta. Dane logowania i nazwy hostów serwera nie są zwracane do przeglądarki.",
"Reconnect": "Połącz ponownie",
"Realtime": "Czas rzeczywisty",
"Disconnected": "Rozłączono",
"IMAP IDLE events are delivered to the browser through the RustBox WebSocket connection.": "Zdarzenia IMAP IDLE są dostarczane do przeglądarki przez połączenie WebSocket RustBox.",
"Synchronization cache": "Pamięć synchronizacji",
"Search index": "Indeks wyszukiwania",
"Rebuild index": "Przebuduj indeks",
"Accounts signed in to this RustBox browser session.": "Konta zalogowane w tej sesji przeglądarki RustBox.",
"Add account": "Dodaj konto",
"Active sessions": "Aktywne sesje",
"Live RustBox sessions for this account, including other browsers while the server is running.": "Aktywne sesje RustBox dla tego konta, także w innych przeglądarkach, gdy serwer działa.",
"Revoke other sessions": "Unieważnij pozostałe sesje",
"Webmail based on the Oxi codebase, with a RustBox-owned interface compiled into the Rust executable.": "Webmail oparty na kodzie Oxi, z interfejsem RustBox kompilowanym bezpośrednio do programu Rust.",
"Upstream": "Projekt bazowy",
"Oxi base": "Baza Oxi",
"Frontend": "Frontend",
"HTML, CSS and vanilla JavaScript embedded at Cargo build time": "HTML, CSS i czysty JavaScript osadzane podczas kompilacji Cargo",
"Author": "Autor",
"Keyboard shortcuts": "Skróty klawiaturowe",
"Identity & signature": "Tożsamość i podpis",
"Sender identity": "Tożsamość nadawcy",
"Address and headers used when this identity is selected in the composer.": "Adres i nagłówki używane po wybraniu tej tożsamości w edytorze.",
"Display name": "Nazwa wyświetlana",
"Reply-To": "Odpowiedź do",
"Automatic Bcc": "Automatyczne UDW",
"Organization": "Organizacja",
"Signature / footer": "Podpis / stopka",
"Maintain separate text and HTML versions. Imported images are embedded into outgoing mail as inline CID images.": "Utrzymuj osobne wersje tekstową i HTML. Importowane obrazy są osadzane w wysyłanej poczcie jako obrazy CID inline.",
"Plain-text signature / footer": "Podpis / stopka tekstowa",
"HTML signature / footer": "Podpis / stopka HTML",
"You can also paste an image directly into this editor.": "Możesz też wkleić obraz bezpośrednio do edytora.",
"Signature position": "Pozycja podpisu",
"Above quoted text": "Nad cytowanym tekstem",
"Below quoted text": "Pod cytowanym tekstem",
"OpenPGP key": "Klucz OpenPGP",
"Automatic / none": "Automatycznie / brak",
"Defaults": "Domyślne",
"Choose where this signature is inserted and, when OpenPGP is configured, its default behavior.": "Wybierz miejsce wstawiania podpisu oraz domyślne zachowanie OpenPGP, jeśli jest skonfigurowane.",
"Signature on new messages": "Podpis w nowych wiadomościach",
"Signature on replies": "Podpis w odpowiedziach",
"Signature on forwards": "Podpis przy przekazywaniu",
"Default identity": "Domyślna tożsamość",
"Sign by default with OpenPGP": "Domyślnie podpisuj przez OpenPGP",
"Encrypt by default with OpenPGP": "Domyślnie szyfruj przez OpenPGP",
"Use inline OpenPGP by default": "Domyślnie używaj OpenPGP inline",
"Save identity": "Zapisz tożsamość",
"Build the rule visually and verify the preview before applying it.": "Zbuduj regułę wizualnie i sprawdź podgląd przed zastosowaniem.",
"Rule": "Reguła",
"Give it a readable name and decide whether it is active.": "Nadaj czytelną nazwę i zdecyduj, czy reguła ma być aktywna.",
"Rule name": "Nazwa reguły",
"Rule enabled": "Reguła włączona",
"Stop after this rule": "Zatrzymaj po tej regule",
"When": "Kiedy",
"Choose what the incoming message must match.": "Wybierz, co ma pasować w przychodzącej wiadomości.",
"Condition": "Warunek",
"From address": "Adres nadawcy",
"From domain": "Domena nadawcy",
"To / Cc address": "Adres Do / DW",
"From header": "Nagłówek From",
"To header": "Nagłówek To",
"Custom header": "Własny nagłówek",
"Match": "Dopasowanie",
"contains": "zawiera",
"is exactly": "jest dokładnie",
"wildcard matches": "pasuje do wzorca",
"Header name": "Nazwa nagłówka",
"Value": "Wartość",
"NOT / negate": "NIE / negacja",
"Join next condition": "Połącz następny warunek",
"Second condition": "Drugi warunek",
"None": "Brak",
"Join third condition": "Połącz trzeci warunek",
"Third condition": "Trzeci warunek",
"+ Add condition": "+ Dodaj warunek",
"Then": "Wtedy",
"Actions run in the order shown below.": "Akcje są wykonywane w kolejności pokazanej poniżej.",
"Action": "Akcja",
"Move to folder": "Przenieś do folderu",
"Copy to folder": "Kopiuj do folderu",
"Redirect": "Przekieruj",
"Redirect and keep copy": "Przekieruj i zachowaj kopię",
"Discard": "Odrzuć",
"Mark read": "Oznacz jako przeczytaną",
"Add IMAP flag": "Dodaj flagę IMAP",
"Set IMAP flags": "Ustaw flagi IMAP",
"Keep": "Zachowaj",
"Folder": "Folder",
"Second action": "Druga akcja",
"+ Add action": "+ Dodaj akcję",
"What will happen": "Co się stanie",
"Live explanation of the rule before you save it.": "Opis działania reguły przed zapisaniem.",
"Ready": "Gotowe",
"Complete the rule to see its effect.": "Uzupełnij regułę, aby zobaczyć jej efekt.",
"Add rule": "Dodaj regułę",
"Update selected rule": "Aktualizuj wybraną regułę",
"Clear": "Wyczyść",
"Global": "Globalne",
"Focus search": "Przejdź do wyszukiwania",
"Toggle selection mode": "Przełącz tryb zaznaczania",
"Show shortcuts": "Pokaż skróty",
"Next / previous message": "Następna / poprzednia wiadomość",
"Select / unselect current message": "Zaznacz / odznacz bieżącą wiadomość",
"Reply": "Odpowiedz",
"Reply all": "Odpowiedz wszystkim",
"Forward": "Przekaż",
"Read / unread": "Przeczytana / nieprzeczytana",
"Flag / unflag": "Oznacz / usuń oznaczenie",
"Spam / not spam": "Spam / nie spam",
"Delete / move to Trash": "Usuń / przenieś do Kosza",
"Close": "Zamknij",
"Label": "Etykieta",
"Color": "Kolor",
"Advanced search": "Wyszukiwanie zaawansowane",
"Contains": "Zawiera",
"All folders": "Wszystkie foldery",
"Date from": "Data od",
"Date to": "Data do",
"Read status": "Stan odczytu",
"Any": "Dowolny",
"Unread": "Nieprzeczytane",
"Read": "Przeczytane",
"Flag": "Oznacz gwiazdką",
"Flagged": "Oznaczone",
"Not flagged": "Nieoznaczone",
"Attachments": "Załączniki",
"Has attachment": "Ma załącznik",
"No attachment": "Bez załącznika",
"Sort": "Sortowanie",
"Saved searches": "Zapisane wyszukiwania",
"Reset": "Wyczyść",
"Save search": "Zapisz wyszukiwanie",
"Search": "Szukaj",
"Contacts": "Kontakty",
"All contacts": "Wszystkie kontakty",
"New group": "Nowa grupa",
"New": "Nowy",
"Import vCard": "Importuj vCard",
"Export vCard": "Eksportuj vCard",
"Find duplicates": "Znajdź duplikaty",
"Photo": "Zdjęcie",
"Upload photo": "Wgraj zdjęcie",
"Remove photo": "Usuń zdjęcie",
"Other emails": "Inne adresy e-mail",
"Phone": "Telefon",
"Other phones": "Inne telefony",
"Company": "Firma",
"Job title": "Stanowisko",
"Website": "Strona WWW",
"Birthday": "Urodziny",
"Address": "Adres",
"Other addresses": "Inne adresy",
"Notes": "Notatki",
"Favorite": "Ulubiony",
"Groups": "Grupy",
"Duplicate contacts": "Duplikaty kontaktów",
"Duplicates are detected when two contacts share a primary or alternate e-mail address. Choose which record to keep.": "Duplikaty są wykrywane, gdy dwa kontakty mają ten sam główny lub dodatkowy adres e-mail. Wybierz rekord do zachowania.",
"Calendar": "Kalendarz",
"Today": "Dzisiaj",
"Previous": "Poprzedni",
"Next": "Następny",
"Month": "Miesiąc",
"Week": "Tydzień",
"Day": "Dzień",
"Agenda": "Agenda",
"Import ICS": "Importuj ICS",
"Export ICS": "Eksportuj ICS",
"New event": "Nowe wydarzenie",
"Title": "Tytuł",
"All day": "Cały dzień",
"Start": "Początek",
"End": "Koniec",
"Recurrence": "Powtarzanie",
"Does not repeat": "Nie powtarza się",
"Daily": "Codziennie",
"Weekly": "Co tydzień",
"Monthly": "Co miesiąc",
"Yearly": "Co rok",
"Custom RRULE": "Własna reguła RRULE",
"Status": "Status",
"Confirmed": "Potwierdzone",
"Tentative": "Wstępne",
"Cancelled": "Anulowane",
"Reminder": "Przypomnienie",
"At start": "Na początku",
"5 minutes": "5 minut",
"15 minutes": "15 minut",
"30 minutes": "30 minut",
"1 hour": "1 godzina",
"1 day": "1 dzień",
"Organizer": "Organizator",
"Attendees": "Uczestnicy",
"Location": "Lokalizacja",
"Meeting link": "Link do spotkania",
"Description": "Opis",
"Message headers": "Nagłówki wiadomości",
"Copy": "Kopiuj",
"Message source": "Źródło wiadomości",
"Copy source": "Kopiuj źródło",
"Download .eml": "Pobierz .eml",
"Show full addresses": "Pokaż pełne adresy",
"Collapse addresses": "Zwiń adresy",
"Close folders": "Zamknij foldery",
"Back to messages": "Wróć do wiadomości",
"Manage folders": "Zarządzaj folderami",
"New label": "Nowa etykieta",
"Mail folders": "Foldery poczty",
"Message labels": "Etykiety wiadomości",
"Sign out": "Wyloguj się",
"Recover a saved draft": "Odzyskaj zapisany szkic",
"Toggle full screen composer": "Przełącz pełny ekran edytora",
"Full screen": "Pełny ekran",
"Formatting": "Formatowanie",
"Bold": "Pogrubienie",
"Italic": "Kursywa",
"Underline": "Podkreślenie",
"Strikethrough": "Przekreślenie",
"Bullet list": "Lista punktowana",
"Numbered list": "Lista numerowana",
"Quote": "Cytat",
"Align left": "Wyrównaj do lewej",
"Align center": "Wyśrodkuj",
"Align right": "Wyrównaj do prawej",
"Insert link": "Wstaw link",
"Insert inline image": "Wstaw obraz w treści",
"Edit selected image": "Edytuj zaznaczony obraz",
"Clear formatting": "Wyczyść formatowanie",
"Undo": "Cofnij",
"Redo": "Ponów",
"Attach files": "Dołącz pliki",
"Preview Markdown": "Podgląd Markdown",
"Insert saved response": "Wstaw zapisaną odpowiedź",
"Write a message": "Napisz wiadomość",
"Markdown preview": "Podgląd Markdown",
"Recovered draft": "Odzyskany szkic",
"Forward as attachment": "Przekaż jako załącznik",
"Open": "Otwórz",
"Mark unread": "Oznacz jako nieprzeczytaną",
"Unflag": "Usuń oznaczenie",
"Move to…": "Przenieś do…",
"Copy to…": "Kopiuj do…",
"Add label…": "Dodaj etykietę…",
"Remove label…": "Usuń etykietę…",
"Create filter from sender": "Utwórz filtr z nadawcy",
"Create filter from domain": "Utwórz filtr z domeny",
"Create filter from subject": "Utwórz filtr z tematu",
"Add sender to contacts": "Dodaj nadawcę do kontaktów",
"Search messages from sender": "Szukaj wiadomości od nadawcy",
"Unsubscribe from mailing list": "Wypisz się z listy mailingowej",
"Show headers": "Pokaż nagłówki",
"Show source": "Pokaż źródło",
"Copy Message-ID": "Kopiuj Message-ID",
"Copy sender address": "Kopiuj adres nadawcy",
"Print": "Drukuj",
"Choose folder": "Wybierz folder",
"Compose to sender": "Napisz do nadawcy",
"Add to contacts": "Dodaj do kontaktów",
"Stop trusting sender": "Przestań ufać nadawcy",
"Trust sender and load remote images": "Ufaj nadawcy i ładuj zdalne obrazy",
"Copy address": "Kopiuj adres",
"Search messages": "Szukaj wiadomości",
"Draft saved": "Szkic zapisany",
"Uploading files…": "Wysyłanie plików…",
"Upload complete": "Wysyłanie zakończone",
"Allow UI scaling for predefined style": "Zezwól na skalowanie interfejsu dla gotowego stylu",
"When enabled, the UI size control can override the scale defined by the selected preset.": "Po włączeniu ustawienie rozmiaru interfejsu może nadpisać skalę wybranego stylu.",
"Maximum attachment size": "Maksymalny rozmiar załącznika",
"Server-wide limit set with": "Globalny limit serwera ustawiany przez",
"in rustbox.toml.": "w rustbox.toml.",
"Uploading attachment…": "Wysyłanie załącznika…",
"maximum attachment size is": "maksymalny rozmiar załącznika to",
"Attachment uploaded": "Załącznik wysłany",
"attachments uploaded": "załączników wysłano",
"Upload failed": "Wysyłanie nie powiodło się",
"Another upload is already in progress": "Inne wysyłanie jest już w toku",
"Repository": "Repozytorium",
"Fastmail inspired": "Inspirowany Fastmail",
"Apple Mail inspired": "Inspirowany Apple Mail",
"Yahoo Mail inspired": "Inspirowany Yahoo Mail",
"HEY inspired": "Inspirowany HEY",
"Maximum message size": "Maksymalny rozmiar wiadomości",
"Per-file upload limit set with": "Limit pojedynczego pliku ustawiany przez",
"Hard limit for the complete RFC822/MIME message sent to the MTA, including base64/MIME overhead.": "Twardy limit całej wiadomości RFC822/MIME wysyłanej do MTA, wraz z narzutem base64/MIME.",
"Estimated final message size": "Szacowany końcowy rozmiar wiadomości",
"Estimated size. The exact RFC822/MIME size is checked by the server before SMTP delivery.": "Rozmiar szacowany. Dokładny rozmiar RFC822/MIME jest sprawdzany przez serwer przed wysłaniem przez SMTP.",
"Estimated message size exceeds the configured limit": "Szacowany rozmiar wiadomości przekracza skonfigurowany limit",
"The exact size will be checked before sending.": "Dokładny rozmiar zostanie sprawdzony przed wysłaniem."
},
"fixed_interface_style_note": "stały styl interfejsu. Motyw nadal przełącza jasny/ciemny; wybierz RustBox (niestandardowy), aby edytować kolory, odstępy i geometrię.",
"custom_interface_style_note": "RustBox można w pełni dostosować: kolory, odstępy, panele, typografię i zaawansowane ustawienia interfejsu.",
"reply": "Odpowiedz",
"reply_all": "Odpowiedz wszystkim",
"forward": "Przekaż",
"headers": "Nagłówki",
"message_details": "Szczegóły",
"move_copy": "Przenieś / kopiuj",
"move_to": "Przenieś do…",
"copy_to": "Kopiuj do…"
} }
+86 -45
View File
@@ -1,5 +1,5 @@
<!doctype html> <!doctype html>
<html lang="en" data-base-path="__RUSTBOX_BASE_PATH__" data-theme="dark" data-density="compact" data-preview="right"> <html lang="en" data-base-path="__RUSTBOX_BASE_PATH__" data-default-theme="__RUSTBOX_DEFAULT_THEME__" data-default-interface-skin="__RUSTBOX_DEFAULT_INTERFACE_SKIN__" data-max-attachment-bytes="__RUSTBOX_MAX_ATTACHMENT_BYTES__" data-max-attachment-mb="__RUSTBOX_MAX_ATTACHMENT_MB__" data-max-message-bytes="__RUSTBOX_MAX_MESSAGE_BYTES__" data-max-message-mb="__RUSTBOX_MAX_MESSAGE_MB__" data-theme="dark" data-interface-skin="__RUSTBOX_DEFAULT_INTERFACE_SKIN__" data-density="compact" data-preview="right">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
@@ -7,6 +7,16 @@
<meta name="color-scheme" content="dark light"> <meta name="color-scheme" content="dark light">
<meta name="description" content="RustBox webmail"> <meta name="description" content="RustBox webmail">
<title>RustBox</title> <title>RustBox</title>
<script>
(() => {
const root = document.documentElement;
const configured = root.dataset.defaultTheme || "system";
root.dataset.theme = configured === "system"
? (matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark")
: configured;
root.dataset.interfaceSkin = root.dataset.defaultInterfaceSkin || "rustbox";
})();
</script>
<link rel="icon" href="__RUSTBOX_ASSET_PREFIX__/favicon.svg" type="image/svg+xml"> <link rel="icon" href="__RUSTBOX_ASSET_PREFIX__/favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="__RUSTBOX_ASSET_PREFIX__/assets/app.__RUSTBOX_ASSET_ID__.css"> <link rel="stylesheet" href="__RUSTBOX_ASSET_PREFIX__/assets/app.__RUSTBOX_ASSET_ID__.css">
</head> </head>
@@ -19,7 +29,7 @@
<strong>RustBox</strong> <strong>RustBox</strong>
</div> </div>
<section id="login-view" class="login-view hidden" aria-label="Sign in"> <section id="login-view" class="login-view hidden" data-i18n-scope aria-label="Sign in">
<div class="login-panel"> <div class="login-panel">
<header class="login-brand"> <header class="login-brand">
<svg class="brand-icon" aria-hidden="true"> <svg class="brand-icon" aria-hidden="true">
@@ -43,7 +53,7 @@
<main id="app-view" class="app-view hidden"> <main id="app-view" class="app-view hidden">
<div id="offline-banner" class="offline-banner hidden" role="status" aria-live="polite"></div> <div id="offline-banner" class="offline-banner hidden" role="status" aria-live="polite"></div>
<header class="appbar"> <header class="appbar" data-i18n-scope>
<div class="app-brand"> <div class="app-brand">
<svg class="brand-icon" aria-hidden="true"> <svg class="brand-icon" aria-hidden="true">
<use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-mail"></use> <use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-mail"></use>
@@ -82,7 +92,7 @@
<div class="mail-shell"> <div class="mail-shell">
<button id="folder-drawer-scrim" class="folder-drawer-scrim" type="button" aria-label="Close folders" <button id="folder-drawer-scrim" class="folder-drawer-scrim" type="button" aria-label="Close folders"
tabindex="-1"></button> tabindex="-1"></button>
<aside id="folder-pane" class="folder-pane"> <aside id="folder-pane" class="folder-pane" data-i18n-scope>
<div class="folder-drawer-header mobile-only"> <div class="folder-drawer-header mobile-only">
<button id="folder-drawer-close" class="icon-button folder-drawer-close" type="button" <button id="folder-drawer-close" class="icon-button folder-drawer-close" type="button"
aria-label="Back to messages" title="Back to messages"><svg> aria-label="Back to messages" title="Back to messages"><svg>
@@ -105,6 +115,10 @@
<use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-plus"></use> <use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-plus"></use>
</svg></button></div> </svg></button></div>
<nav id="tag-list" class="folder-list tag-list" aria-label="Message labels"></nav> <nav id="tag-list" class="folder-list tag-list" aria-label="Message labels"></nav>
<div id="folder-quota" class="folder-quota" aria-live="polite">
<div class="folder-quota-label"><span>IMAP storage</span><span id="folder-quota-text">Loading…</span></div>
<div class="folder-quota-track"><span id="folder-quota-bar"></span></div>
</div>
<div class="folder-pane-footer"><span id="mail-server-label">IMAP</span><span>RustBox __RUSTBOX_VERSION__</span> <div class="folder-pane-footer"><span id="mail-server-label">IMAP</span><span>RustBox __RUSTBOX_VERSION__</span>
</div> </div>
<div id="folder-resizer" class="pane-resizer" role="separator" aria-orientation="vertical" <div id="folder-resizer" class="pane-resizer" role="separator" aria-orientation="vertical"
@@ -193,15 +207,22 @@
</svg></button> </svg></button>
<button id="reply-btn" class="button quiet" type="button"><svg> <button id="reply-btn" class="button quiet" type="button"><svg>
<use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-reply"></use> <use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-reply"></use>
</svg><span>Reply</span></button> </svg><span data-i18n="reply">Reply</span></button>
<button id="reply-all-btn" class="button quiet" type="button"><svg> <button id="reply-all-btn" class="button quiet" type="button"><svg>
<use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-reply-all"></use> <use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-reply-all"></use>
</svg><span>Reply all</span></button> </svg><span data-i18n="reply_all">Reply all</span></button>
<button id="forward-btn" class="button quiet" type="button"><svg> <button id="forward-btn" class="button quiet" type="button"><svg>
<use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-forward"></use> <use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-forward"></use>
</svg><span>Forward</span></button> </svg><span data-i18n="forward">Forward</span></button>
<button id="headers-btn" class="button quiet" type="button">Headers</button><button id="source-btn" <details id="message-details-menu" class="toolbar-menu">
class="button quiet" type="button" data-i18n="source">Source</button> <summary class="button quiet"><svg aria-hidden="true">
<use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-file-text"></use>
</svg><span data-i18n="message_details">Message details</span></summary>
<div class="toolbar-menu-popover" role="menu">
<button id="headers-btn" type="button" role="menuitem" data-i18n="headers">Headers</button>
<button id="source-btn" type="button" role="menuitem" data-i18n="source">Source</button>
</div>
</details>
<button id="quick-filter-btn" class="icon-button" type="button" title="Create filter from sender" <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" aria-label="Create filter from sender" data-i18n-title="create_filter_sender"
data-i18n-aria-label="create_filter_sender"><svg> data-i18n-aria-label="create_filter_sender"><svg>
@@ -225,12 +246,15 @@
aria-label="Mark read or unread"><svg> aria-label="Mark read or unread"><svg>
<use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-mail"></use> <use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-mail"></use>
</svg></button> </svg></button>
<label class="move-control"><span class="sr-only">Move to folder</span><select id="move-select"> <details id="message-transfer-menu" class="toolbar-menu">
<option value="">Move to…</option> <summary class="button quiet"><svg aria-hidden="true">
</select></label> <use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-folder"></use>
<label class="move-control"><span class="sr-only">Copy to folder</span><select id="copy-select"> </svg><span data-i18n="move_copy">Move / copy</span></summary>
<option value="">Copy to…</option> <div class="toolbar-menu-popover" role="menu">
</select></label> <button id="reader-move-action" type="button" role="menuitem" data-i18n="move_to">Move to…</button>
<button id="reader-copy-action" type="button" role="menuitem" data-i18n="copy_to">Copy to…</button>
</div>
</details>
<span class="toolbar-spacer"></span> <span class="toolbar-spacer"></span>
<button id="delete-btn" class="icon-button danger" type="button" title="Delete" aria-label="Delete"><svg> <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> <use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-trash"></use>
@@ -246,7 +270,7 @@
aria-label="Add sender to contacts"><svg> aria-label="Add sender to contacts"><svg>
<use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-user-round"></use> <use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-user-round"></use>
</svg></button></div> </svg></button></div>
<div id="reader-to" class="reader-recipients"></div> <div id="reader-to" class="reader-recipients" role="button" tabindex="0" aria-expanded="false" title="Show full addresses"></div>
</div><time id="reader-date"></time> </div><time id="reader-date"></time>
</div> </div>
</header> </header>
@@ -275,7 +299,7 @@
</div> </div>
</main> </main>
<dialog id="compose-dialog" class="dialog compose-dialog"> <dialog id="compose-dialog" class="dialog compose-dialog" data-i18n-scope>
<form id="compose-form" class="dialog-card compose-card" method="dialog"> <form id="compose-form" class="dialog-card compose-card" method="dialog">
<header class="dialog-header"><strong id="compose-title">New message</strong> <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" <div class="compose-header-actions"><button type="button" id="compose-recover" class="button quiet small"
@@ -410,28 +434,31 @@
<button type="button" id="compose-response-insert" title="Insert saved response" disabled><svg> <button type="button" id="compose-response-insert" title="Insert saved response" disabled><svg>
<use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-file-text"></use> <use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-file-text"></use>
</svg></button> </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>
<div id="compose-editor" class="compose-editor" contenteditable="true" role="textbox" aria-multiline="true"></div> <div id="compose-editor" class="compose-editor" contenteditable="true" role="textbox" aria-multiline="true"
data-placeholder="Write a message"></div>
<div id="compose-upload-progress" class="compose-upload-progress hidden" role="status" aria-live="polite"> <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 <div class="compose-upload-track" aria-hidden="true"><span id="compose-upload-bar"></span></div><span
id="compose-upload-label">Uploading…</span> id="compose-upload-label">Uploading…</span>
</div> </div>
<div id="compose-attachments" class="compose-attachments hidden"></div> <div id="compose-attachments" class="compose-attachments hidden"></div>
<div id="compose-error" class="form-error 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" <footer class="dialog-actions compose-footer">
class="button quiet close-dialog">Cancel</button><button id="send-btn" type="submit" <label class="compose-mode-control"><span class="sr-only">Compose format</span><select id="compose-mode">
class="button primary"><svg> <option value="html">HTML</option>
<use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-send"></use> <option value="text">Plain text</option>
</svg><span>Send</span></button></footer> <option value="markdown">Markdown</option>
</select></label>
<span id="compose-status" class="muted"></span><span id="compose-message-size" class="muted compose-message-size" title="Estimated final message size">~0 / __RUSTBOX_MAX_MESSAGE_MB__ MiB</span>
<div class="compose-submit-actions"><button type="button" class="button quiet close-dialog">Cancel</button><button
id="send-btn" type="submit" class="button primary"><svg>
<use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-send"></use>
</svg><span>Send</span></button></div>
</footer>
</form> </form>
</dialog> </dialog>
<dialog id="markdown-preview-dialog" class="dialog markdown-preview-dialog"> <dialog id="markdown-preview-dialog" class="dialog markdown-preview-dialog" data-i18n-scope>
<div class="dialog-card markdown-preview-card"> <div class="dialog-card markdown-preview-card">
<header class="dialog-header"><strong>Markdown preview</strong><button type="button" <header class="dialog-header"><strong>Markdown preview</strong><button type="button"
class="icon-button close-dialog" aria-label="Close"><svg> class="icon-button close-dialog" aria-label="Close"><svg>
@@ -490,7 +517,7 @@
</form> </form>
</dialog> </dialog>
<dialog id="folder-dialog" class="dialog"> <dialog id="folder-dialog" class="dialog" data-i18n-scope>
<div class="dialog-card folder-card"> <div class="dialog-card folder-card">
<header class="dialog-header"><strong>Folders</strong><button type="button" class="icon-button close-dialog" <header class="dialog-header"><strong>Folders</strong><button type="button" class="icon-button close-dialog"
aria-label="Close"><svg> aria-label="Close"><svg>
@@ -509,7 +536,7 @@
</div> </div>
</dialog> </dialog>
<dialog id="settings-dialog" class="dialog settings-dialog"> <dialog id="settings-dialog" class="dialog settings-dialog" data-i18n-scope>
<div class="dialog-card settings-card"> <div class="dialog-card settings-card">
<header class="dialog-header"><strong>Settings</strong><button type="button" class="icon-button close-dialog" <header class="dialog-header"><strong>Settings</strong><button type="button" class="icon-button close-dialog"
aria-label="Close"><svg> aria-label="Close"><svg>
@@ -548,15 +575,27 @@
<option value="system">System</option> <option value="system">System</option>
</select></label> </select></label>
<label><span>Interface style</span><select id="pref-interface-skin"> <label><span>Interface style</span><select id="pref-interface-skin">
<option value="default">Global default</option>
<option value="rustbox">RustBox (custom)</option> <option value="rustbox">RustBox (custom)</option>
<option value="roundcube-classic">Roundcube Classic</option> <option value="roundcube-classic">Roundcube Classic</option>
<option value="roundcube-larry">Roundcube Larry</option> <option value="roundcube-larry">Roundcube Larry</option>
<option value="roundcube-elastic">Roundcube Elastic</option> <option value="roundcube-elastic">Roundcube Elastic</option>
<option value="clean">Clean</option> <option value="clean">Clean</option>
<option value="gmail">Gmail inspired</option>
<option value="proton">Proton inspired</option>
<option value="zoho">Zoho inspired</option>
<option value="outlook">Outlook inspired</option>
<option value="minimal">Minimal</option>
<option value="graphite">Graphite</option> <option value="graphite">Graphite</option>
<option value="high-contrast">High contrast</option> <option value="high-contrast">High contrast</option>
<option value="fastmail">Fastmail inspired</option>
<option value="apple-mail">Apple Mail inspired</option>
<option value="yahoo">Yahoo Mail inspired</option>
<option value="hey">HEY inspired</option>
<option value="nord">Nord</option>
</select><small id="pref-interface-skin-note" class="muted">RustBox is fully customizable: colors, </select><small id="pref-interface-skin-note" class="muted">RustBox is fully customizable: colors,
spacing, panes and typography.</small></label> spacing, panes and typography.</small></label>
<label id="pref-preset-scale-unlocked-row" class="checkbox-row hidden"><input id="pref-preset-scale-unlocked" type="checkbox"><span>Allow UI scaling for predefined style</span><small class="muted">When enabled, the UI size control can override the scale defined by the selected preset.</small></label>
<label><span data-i18n="color_preset">Color preset</span><select id="pref-color-preset"> <label><span data-i18n="color_preset">Color preset</span><select id="pref-color-preset">
<option value="sage">Sage</option> <option value="sage">Sage</option>
<option value="blue">Blue</option> <option value="blue">Blue</option>
@@ -819,6 +858,8 @@
message text suggests a missing attachment</span></label> message text suggests a missing attachment</span></label>
<label class="checkbox-row"><input id="compose-pref-spellcheck" type="checkbox"><span>Use browser <label class="checkbox-row"><input id="compose-pref-spellcheck" type="checkbox"><span>Use browser
spellcheck while composing</span></label> spellcheck while composing</span></label>
<label><span>Maximum attachment size</span><output id="compose-pref-max-attachment">__RUSTBOX_MAX_ATTACHMENT_MB__ MiB</output><small class="muted">Per-file upload limit set with <code>max_attachment_size_mb</code> in rustbox.toml.</small></label>
<label><span>Maximum message size</span><output id="compose-pref-max-message">__RUSTBOX_MAX_MESSAGE_MB__ MiB</output><small class="muted">Hard limit for the complete RFC822/MIME message sent to the MTA, including base64/MIME overhead.</small></label>
</div> </div>
<p class="muted">Markdown is stored as source text and sent as a safe rendered HTML alternative with a <p class="muted">Markdown is stored as source text and sent as a safe rendered HTML alternative with a
plain-text fallback.</p> plain-text fallback.</p>
@@ -1032,12 +1073,10 @@
</div> </div>
<div class="diagnostics-grid"> <div class="diagnostics-grid">
<section class="diagnostic-card"> <section class="diagnostic-card">
<div class="section-heading"><span>IMAP</span> <div class="section-heading"><span>IMAP</span><strong id="diagnostic-imap-status">Not checked</strong></div>
<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-summary" class="capability-grid"></div>
<div id="diagnostic-imap-caps" class="capability-list"></div> <div id="diagnostic-imap-caps" class="capability-list"></div>
<div class="inline-actions diagnostic-imap-actions"><button id="diagnostic-imap-reconnect" class="button quiet" type="button">Reconnect</button></div>
</section> </section>
<section class="diagnostic-card"> <section class="diagnostic-card">
<div class="section-heading"><span>SMTP</span><strong id="diagnostic-smtp-status">Not checked</strong> <div class="section-heading"><span>SMTP</span><strong id="diagnostic-smtp-status">Not checked</strong>
@@ -1099,6 +1138,8 @@
<dd>HTML, CSS and vanilla JavaScript embedded at Cargo build time</dd> <dd>HTML, CSS and vanilla JavaScript embedded at Cargo build time</dd>
<dt>Author</dt> <dt>Author</dt>
<dd>@linuxiarz.pl</dd> <dd>@linuxiarz.pl</dd>
<dt>Repository</dt>
<dd><a href="https://git.linuxiarz.pl/gru/RustBox" target="_blank" rel="noopener noreferrer">git.linuxiarz.pl/gru/RustBox</a></dd>
</dl> </dl>
<div class="inline-actions"><button id="shortcuts-open" class="button quiet" type="button">Keyboard <div class="inline-actions"><button id="shortcuts-open" class="button quiet" type="button">Keyboard
shortcuts</button></div> shortcuts</button></div>
@@ -1110,7 +1151,7 @@
<dialog id="identity-dialog" class="dialog identity-dialog"> <dialog id="identity-dialog" class="dialog identity-dialog" data-i18n-scope>
<form id="identity-form" class="dialog-card identity-card"> <form id="identity-form" class="dialog-card identity-card">
<header class="dialog-header"><strong id="identity-dialog-title">Identity & signature</strong><button <header class="dialog-header"><strong id="identity-dialog-title">Identity & signature</strong><button
type="button" class="icon-button close-dialog" aria-label="Close"><svg> type="button" class="icon-button close-dialog" aria-label="Close"><svg>
@@ -1197,7 +1238,7 @@
</form> </form>
</dialog> </dialog>
<dialog id="sieve-rule-dialog" class="dialog sieve-rule-dialog"> <dialog id="sieve-rule-dialog" class="dialog sieve-rule-dialog" data-i18n-scope>
<div class="dialog-card sieve-rule-card"> <div class="dialog-card sieve-rule-card">
<header class="dialog-header"> <header class="dialog-header">
<div><strong id="sieve-rule-dialog-title">Create filter</strong><span id="sieve-rule-dialog-subtitle" <div><strong id="sieve-rule-dialog-title">Create filter</strong><span id="sieve-rule-dialog-subtitle"
@@ -1365,7 +1406,7 @@
</div> </div>
</dialog> </dialog>
<dialog id="shortcuts-dialog" class="dialog"> <dialog id="shortcuts-dialog" class="dialog" data-i18n-scope>
<div class="dialog-card shortcuts-card"> <div class="dialog-card shortcuts-card">
<header class="dialog-header"><strong>Keyboard shortcuts</strong><button type="button" <header class="dialog-header"><strong>Keyboard shortcuts</strong><button type="button"
class="icon-button close-dialog" aria-label="Close"><svg> class="icon-button close-dialog" aria-label="Close"><svg>
@@ -1415,7 +1456,7 @@
</div> </div>
</dialog> </dialog>
<dialog id="tag-dialog" class="dialog"> <dialog id="tag-dialog" class="dialog" data-i18n-scope>
<div class="dialog-card tag-card"> <div class="dialog-card tag-card">
<header class="dialog-header"><strong>Label</strong><button type="button" class="icon-button close-dialog" <header class="dialog-header"><strong>Label</strong><button type="button" class="icon-button close-dialog"
aria-label="Close"><svg> aria-label="Close"><svg>
@@ -1433,7 +1474,7 @@
</div> </div>
</dialog> </dialog>
<dialog id="advanced-search-dialog" class="dialog"> <dialog id="advanced-search-dialog" class="dialog" data-i18n-scope>
<div class="dialog-card advanced-search-card"> <div class="dialog-card advanced-search-card">
<header class="dialog-header"><strong>Advanced search</strong><button type="button" <header class="dialog-header"><strong>Advanced search</strong><button type="button"
class="icon-button close-dialog" aria-label="Close"><svg> class="icon-button close-dialog" aria-label="Close"><svg>
@@ -1485,7 +1526,7 @@
</div> </div>
</dialog> </dialog>
<dialog id="contacts-dialog" class="dialog"> <dialog id="contacts-dialog" class="dialog" data-i18n-scope>
<div class="dialog-card contacts-card"> <div class="dialog-card contacts-card">
<header class="dialog-header"><strong>Contacts</strong><button type="button" class="icon-button close-dialog" <header class="dialog-header"><strong>Contacts</strong><button type="button" class="icon-button close-dialog"
aria-label="Close"><svg> aria-label="Close"><svg>
@@ -1544,7 +1585,7 @@
</div> </div>
</dialog> </dialog>
<dialog id="contact-duplicates-dialog" class="dialog"> <dialog id="contact-duplicates-dialog" class="dialog" data-i18n-scope>
<div class="dialog-card duplicates-card"> <div class="dialog-card duplicates-card">
<header class="dialog-header"><strong>Duplicate contacts</strong><button type="button" <header class="dialog-header"><strong>Duplicate contacts</strong><button type="button"
class="icon-button close-dialog" aria-label="Close"><svg> class="icon-button close-dialog" aria-label="Close"><svg>
@@ -1557,7 +1598,7 @@
</div> </div>
</dialog> </dialog>
<dialog id="calendar-dialog" class="dialog"> <dialog id="calendar-dialog" class="dialog" data-i18n-scope>
<div class="dialog-card calendar-card"> <div class="dialog-card calendar-card">
<header class="dialog-header"><strong>Calendar</strong> <header class="dialog-header"><strong>Calendar</strong>
<div class="inline-actions"><button id="calendar-today" type="button" class="button quiet">Today</button><button <div class="inline-actions"><button id="calendar-today" type="button" class="button quiet">Today</button><button
@@ -1620,7 +1661,7 @@
</form> </form>
</div> </div>
</dialog> </dialog>
<dialog id="headers-dialog" class="dialog"> <dialog id="headers-dialog" class="dialog" data-i18n-scope>
<div class="dialog-card headers-card"> <div class="dialog-card headers-card">
<header class="dialog-header"><strong>Message headers</strong><button type="button" <header class="dialog-header"><strong>Message headers</strong><button type="button"
class="icon-button close-dialog" aria-label="Close"><svg> class="icon-button close-dialog" aria-label="Close"><svg>
@@ -1634,7 +1675,7 @@
</div> </div>
</dialog> </dialog>
<dialog id="add-account-dialog" class="dialog"> <dialog id="add-account-dialog" class="dialog" data-i18n-scope>
<div class="dialog-card add-account-card"> <div class="dialog-card add-account-card">
<header class="dialog-header"><strong>Add account</strong><button type="button" class="icon-button close-dialog" <header class="dialog-header"><strong>Add account</strong><button type="button" class="icon-button close-dialog"
aria-label="Close"><svg> aria-label="Close"><svg>
+1 -1
View File
@@ -68,7 +68,7 @@
imageEditorBlob: null, imageEditorBlob: null,
draftSaveTimer: null, draftSaveTimer: null,
prefs: { prefs: {
theme: "system", interfaceSkin: "rustbox", density: "compact", preview: "right", scale: "normal", language: "en", composeFormat: "html", theme: root.dataset.defaultTheme || "system", interfaceSkin: "default", presetScaleUnlocked: false, 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, 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, colorPreset: "sage", accentColor: "#6f9c86", backgroundColor: "#121513", panelColor: "#181c19", textColor: "#edf0ec", fontFamily: "system", fontSize: 13,
rowHeight: 58, folderPaneWidth: 228, messagePaneWidth: 420, borderRadius: 4, rowHeight: 58, folderPaneWidth: 228, messagePaneWidth: 420, borderRadius: 4,
+371 -304
View File
@@ -1,333 +1,400 @@
/* RustBox UI module: 10-core.js */ /* RustBox UI module: 10-core.js */
function setConnectivityState(online, detail = "") { function setConnectivityState(online, detail = "") {
state.networkOnline = Boolean(online); state.networkOnline = Boolean(online);
document.body.classList.toggle("network-offline", !state.networkOnline); document.body.classList.toggle("network-offline", !state.networkOnline);
const banner = $("#offline-banner"); const banner = $("#offline-banner");
if (banner) { if (banner) {
banner.classList.toggle("hidden", state.networkOnline); 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."); 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";
} }
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) { function listStateStorageKey(folder = state.folder) {
return `rustbox.listState.${state.accountId || "anonymous"}.${encodeURIComponent(folder || "INBOX")}`; 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 lastFolderStorageKey() { function clearActiveSearchContext() {
return `rustbox.lastFolder.${state.accountId || "anonymous"}`; 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 activeSearchStorageKey() { function saveMessageListState() {
return `rustbox.activeSearch.${state.accountId || "anonymous"}`; 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 saveActiveSearchContext() { function scheduleMessageListStateSave() {
if (!state.searchContext) return sessionStorage.removeItem(activeSearchStorageKey()); clearTimeout(state.listStateTimer);
sessionStorage.setItem(activeSearchStorageKey(), JSON.stringify(state.searchContext)); state.listStateTimer = setTimeout(saveMessageListState, 120);
} }
function readActiveSearchContext() { 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 { try {
const parsed = JSON.parse(sessionStorage.getItem(activeSearchStorageKey()) || "null"); const response = await fetch(apiPath(path), {
return parsed && typeof parsed.params === "string" ? parsed : null; credentials: "include",
} catch (_) { ...options,
return null; headers,
}
}
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); if ([502, 503, 504].includes(response.status) && attempt + 1 < maxAttempts) {
}); await sleep(250 * (attempt + 1));
} continue;
}
function toast(message, kind = "info", duration = null, action = null) { const contentType = response.headers.get("content-type") || "";
const normalized = ["success", "error", "warning", "info"].includes(kind) ? kind : "info"; const payload = contentType.includes("application/json")
const lifetime = Number(duration) > 0 ? Number(duration) : normalized === "error" ? 6500 : normalized === "warning" ? 5200 : 3800; ? await response.json().catch(() => ({}))
const node = el("div", `toast toast-${normalized}`); : await response.text().catch(() => "");
node.setAttribute("role", normalized === "error" ? "alert" : "status"); if (!response.ok) {
const iconName = normalized === "success" ? "check-circle" : normalized === "error" || normalized === "warning" ? "circle-alert" : "info"; const message = payload?.error?.message || (typeof payload === "string" && payload) || `Request failed (${response.status})`;
const icon = el("span", "toast-icon"); const error = new Error(message);
icon.innerHTML = svgIcon(iconName); error.status = response.status;
const text = el("span", "toast-message", message); throw error;
let actionButton = null; }
if (action?.label && typeof action.onClick === "function") { setConnectivityState(true);
actionButton = el("button", "toast-action", action.label); return payload;
actionButton.type = "button"; } catch (error) {
} if (error?.status) throw error;
const countdown = el("span", "toast-countdown"); lastNetworkError = error;
countdown.setAttribute("aria-hidden", "true"); setConnectivityState(false);
const close = el("button", "toast-close"); if (attempt + 1 < maxAttempts) {
close.type = "button"; await sleep(350 * (attempt + 1));
close.setAttribute("aria-label", "Dismiss notification"); continue;
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")); const error = new Error(navigator.onLine ? "RustBox server is temporarily unreachable" : "You are offline");
error.cause = lastNetworkError;
error.network = true;
throw error;
}
let remaining = lifetime; function uploadRequest(path, formData, onProgress = null) {
let startedAt = 0; return new Promise((resolve, reject) => {
let timeout = null; const xhr = new XMLHttpRequest();
let ticker = null; xhr.open("POST", apiPath(path), true);
let dismissed = false; xhr.withCredentials = true;
const renderCountdown = () => { countdown.textContent = `${Math.max(1, Math.ceil(remaining / 1000))}s`; }; if (state.accountId) xhr.setRequestHeader("X-Active-Account", state.accountId);
const stopTimers = () => { clearTimeout(timeout); clearInterval(ticker); timeout = null; ticker = null; }; xhr.setRequestHeader("X-Requested-With", "XMLHttpRequest");
const dismiss = (immediate = false) => { if (typeof onProgress === "function") {
if (dismissed) return; xhr.upload.addEventListener("progress", (event) => {
dismissed = true; if (event.lengthComputable && event.total > 0) onProgress(Math.round((event.loaded / event.total) * 100));
stopTimers(); });
if (immediate) return node.remove(); }
node.classList.remove("visible"); xhr.addEventListener("error", () => reject(new Error("Upload failed: network error")));
setTimeout(() => node.remove(), 160); xhr.addEventListener("abort", () => reject(new Error("Upload cancelled")));
}; xhr.addEventListener("load", () => {
const pause = () => { const contentType = xhr.getResponseHeader("content-type") || "";
if (dismissed || !startedAt) return; let payload = xhr.responseText || "";
remaining = Math.max(0, remaining - (performance.now() - startedAt)); if (contentType.includes("application/json")) {
startedAt = 0; try { payload = payload ? JSON.parse(payload) : {}; } catch (_) { payload = {}; }
stopTimers(); }
renderCountdown(); if (xhr.status < 200 || xhr.status >= 300) {
}; const message = payload?.error?.message || (typeof payload === "string" && payload) || `Upload failed (${xhr.status})`;
const resume = () => { const error = new Error(message);
if (dismissed || remaining <= 0) return dismiss(); error.status = xhr.status;
stopTimers(); reject(error);
startedAt = performance.now(); return;
renderCountdown(); }
timeout = setTimeout(() => dismiss(), remaining); resolve(payload);
ticker = setInterval(() => { });
if (!startedAt) return; xhr.send(formData);
const elapsed = performance.now() - startedAt; });
const visibleRemaining = Math.max(0, remaining - elapsed); }
countdown.textContent = `${Math.max(1, Math.ceil(visibleRemaining / 1000))}s`;
}, 200); function toast(message, kind = "info", duration = null, action = null) {
}; const normalized = ["success", "error", "warning", "info"].includes(kind) ? kind : "info";
close.addEventListener("click", (event) => { 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.preventDefault();
event.stopPropagation(); event.stopPropagation();
dismiss(true); dismiss(true);
try { await action.onClick(); }
catch (error) { toast(error?.message || "Action failed", "error"); }
}); });
if (actionButton) { }
actionButton.addEventListener("click", async (event) => { node.addEventListener("mouseenter", pause);
event.preventDefault(); node.addEventListener("mouseleave", resume);
event.stopPropagation(); node.addEventListener("focusin", pause);
dismiss(true); node.addEventListener("focusout", (event) => { if (!node.contains(event.relatedTarget)) resume(); });
try { await action.onClick(); } resume();
catch (error) { toast(error?.message || "Action failed", "error"); } }
});
function progressToast(message) {
const node = el("div", "toast toast-info toast-progress");
node.setAttribute("role", "status");
const icon = el("span", "toast-icon");
icon.innerHTML = svgIcon("paperclip");
const content = el("span", "toast-progress-content");
const text = el("span", "toast-message", message);
const track = el("span", "toast-progress-track");
const bar = el("span", "toast-progress-bar");
track.append(bar);
content.append(text, track);
const value = el("span", "toast-progress-value", "0%");
const close = el("button", "toast-close");
close.type = "button";
close.setAttribute("aria-label", "Dismiss notification");
close.innerHTML = svgIcon("x");
let stack = $("#toast-stack");
const openDialogs = $$("dialog[open]");
if (openDialogs.length) {
const dialog = openDialogs[openDialogs.length - 1];
stack = dialog.querySelector(":scope > .modal-toast-stack");
if (!stack) {
stack = el("div", "toast-stack modal-toast-stack");
stack.setAttribute("aria-live", "polite");
dialog.append(stack);
} }
node.addEventListener("mouseenter", pause);
node.addEventListener("mouseleave", resume);
node.addEventListener("focusin", pause);
node.addEventListener("focusout", (event) => { if (!node.contains(event.relatedTarget)) resume(); });
resume();
} }
node.append(icon, content, value, close);
stack.append(node);
requestAnimationFrame(() => node.classList.add("visible"));
let dismissed = false;
const dismiss = (delay = 0) => {
if (dismissed) return;
dismissed = true;
setTimeout(() => {
node.classList.remove("visible");
setTimeout(() => node.remove(), 160);
}, Math.max(0, delay));
};
close.addEventListener("click", () => dismiss());
return {
update(percent, label = null) {
const normalized = Math.max(0, Math.min(100, Math.round(Number(percent) || 0)));
bar.style.width = `${normalized}%`;
value.textContent = `${normalized}%`;
if (label) text.textContent = label;
},
complete(label = trText("Upload complete")) {
bar.style.width = "100%";
value.textContent = "100%";
text.textContent = label;
node.classList.remove("toast-info");
node.classList.add("toast-success");
icon.innerHTML = svgIcon("check-circle");
dismiss(1200);
},
fail(label) {
text.textContent = label || trText("Upload failed");
node.classList.remove("toast-info");
node.classList.add("toast-error");
icon.innerHTML = svgIcon("circle-alert");
dismiss(4800);
},
dismiss,
};
}
function appDialog({ title = "RustBox", message = "", input = null, selectOptions = null, confirmLabel = "OK", cancelLabel = "Cancel", danger = false } = {}) { function appDialog({ title = "RustBox", message = "", input = null, selectOptions = null, confirmLabel = "OK", cancelLabel = "Cancel", danger = false } = {}) {
return new Promise((resolve) => { return new Promise((resolve) => {
const dialog = el("dialog", "dialog app-prompt-dialog"); const dialog = el("dialog", "dialog app-prompt-dialog");
const form = el("form", "dialog-card app-prompt-card"); const form = el("form", "dialog-card app-prompt-card");
form.method = "dialog"; form.method = "dialog";
const header = el("header", "dialog-header"); const header = el("header", "dialog-header");
header.append(el("strong", "", title)); header.append(el("strong", "", title));
const close = el("button", "icon-button"); const close = el("button", "icon-button");
close.type = "button"; close.type = "button";
close.setAttribute("aria-label", cancelLabel); close.setAttribute("aria-label", cancelLabel);
close.innerHTML = svgIcon("x"); close.innerHTML = svgIcon("x");
header.append(close); header.append(close);
const body = el("div", "app-prompt-body"); const body = el("div", "app-prompt-body");
if (message) body.append(el("p", "app-prompt-message", message)); if (message) body.append(el("p", "app-prompt-message", message));
let control = null; let control = null;
if (Array.isArray(selectOptions)) { if (Array.isArray(selectOptions)) {
control = document.createElement("select"); control = document.createElement("select");
control.className = "app-prompt-control"; control.className = "app-prompt-control";
for (const option of selectOptions) control.add(new Option(option.label ?? option.value, option.value)); for (const option of selectOptions) control.add(new Option(option.label ?? option.value, option.value));
if (input?.value !== undefined) control.value = String(input.value); if (input?.value !== undefined) control.value = String(input.value);
body.append(control); body.append(control);
} else if (input) { } else if (input) {
control = input.multiline ? document.createElement("textarea") : document.createElement("input"); control = input.multiline ? document.createElement("textarea") : document.createElement("input");
control.className = "app-prompt-control"; control.className = "app-prompt-control";
if (!input.multiline) control.type = input.type || "text"; if (!input.multiline) control.type = input.type || "text";
control.value = input.value ?? ""; control.value = input.value ?? "";
if (input.placeholder) control.placeholder = input.placeholder; if (input.placeholder) control.placeholder = input.placeholder;
if (input.required) control.required = true; if (input.required) control.required = true;
body.append(control); body.append(control);
} }
const actions = el("footer", "dialog-actions app-prompt-actions"); const actions = el("footer", "dialog-actions app-prompt-actions");
const cancel = el("button", "button quiet", cancelLabel); const cancel = el("button", "button quiet", cancelLabel);
cancel.type = "button"; cancel.type = "button";
const confirm = el("button", danger ? "button danger" : "button primary", confirmLabel); const confirm = el("button", danger ? "button danger" : "button primary", confirmLabel);
confirm.type = "submit"; confirm.type = "submit";
actions.append(cancel, confirm); actions.append(cancel, confirm);
form.append(header, body, actions); form.append(header, body, actions);
dialog.append(form); dialog.append(form);
document.body.append(dialog); document.body.append(dialog);
let settled = false; let settled = false;
const finish = (value) => { const finish = (value) => {
if (settled) return; if (settled) return;
settled = true; settled = true;
resolve(value); resolve(value);
if (dialog.open) dialog.close(); if (dialog.open) dialog.close();
else dialog.remove(); else dialog.remove();
}; };
close.addEventListener("click", () => finish(null)); close.addEventListener("click", () => finish(null));
cancel.addEventListener("click", () => finish(null)); cancel.addEventListener("click", () => finish(null));
dialog.addEventListener("cancel", (event) => { event.preventDefault(); finish(null); }); dialog.addEventListener("cancel", (event) => { event.preventDefault(); finish(null); });
dialog.addEventListener("close", () => { if (!settled) { settled = true; resolve(null); } dialog.remove(); }, { once: true }); dialog.addEventListener("close", () => { if (!settled) { settled = true; resolve(null); } dialog.remove(); }, { once: true });
form.addEventListener("submit", (event) => { form.addEventListener("submit", (event) => {
event.preventDefault(); event.preventDefault();
if (control && !control.reportValidity()) return; if (control && !control.reportValidity()) return;
finish(control ? control.value : true); finish(control ? control.value : true);
});
dialog.showModal();
setTimeout(() => (control || confirm).focus(), 20);
}); });
} dialog.showModal();
setTimeout(() => (control || confirm).focus(), 20);
});
}
async function appConfirm(message, { title = "Confirm action", confirmLabel = "Continue", danger = false } = {}) { async function appConfirm(message, { title = "Confirm action", confirmLabel = "Continue", danger = false } = {}) {
return (await appDialog({ title, message, confirmLabel, danger })) === true; return (await appDialog({ title, message, confirmLabel, danger })) === true;
} }
function appPrompt(title, { message = "", value = "", placeholder = "", type = "text", confirmLabel = "OK", required = false } = {}) { function appPrompt(title, { message = "", value = "", placeholder = "", type = "text", confirmLabel = "OK", required = false } = {}) {
return appDialog({ title, message, input: { value, placeholder, type, required }, confirmLabel }); return appDialog({ title, message, input: { value, placeholder, type, required }, confirmLabel });
} }
function appSelect(title, options, { message = "", value = "", confirmLabel = "Choose" } = {}) { function appSelect(title, options, { message = "", value = "", confirmLabel = "Choose" } = {}) {
return appDialog({ title, message, input: { value }, selectOptions: options, confirmLabel }); return appDialog({ title, message, input: { value }, selectOptions: options, confirmLabel });
} }
function setBusy(button, busy) { function setBusy(button, busy) {
if (!button) return; if (!button) return;
button.disabled = busy; button.disabled = busy;
button.setAttribute("aria-busy", String(busy)); button.setAttribute("aria-busy", String(busy));
} }
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+1153 -1072
View File
File diff suppressed because it is too large. Load diff
+5 -3
View File
@@ -455,7 +455,7 @@ function showContextMenu(x, y, items) {
menu.replaceChildren(); menu.replaceChildren();
for (const item of items) { for (const item of items) {
if (item === "separator") { menu.append(el("div", "separator")); continue; } if (item === "separator") { menu.append(el("div", "separator")); continue; }
const button = el("button", item.danger ? "danger" : "", item.label); const button = el("button", item.danger ? "danger" : "", trText(item.label));
button.type = "button"; button.type = "button";
button.addEventListener("click", async () => { hideContextMenu(); await item.action(); }); button.addEventListener("click", async () => { hideContextMenu(); await item.action(); });
menu.append(button); menu.append(button);
@@ -476,7 +476,7 @@ async function ensureMessageOpen(message) {
async function promptFolder(label, exclude = null) { async function promptFolder(label, exclude = null) {
const options = orderedFolders().map((folder) => folder.name).filter((name) => name !== exclude); const options = orderedFolders().map((folder) => folder.name).filter((name) => name !== exclude);
if (!options.length) return null; if (!options.length) return null;
return appSelect(label, options.map((name) => ({ value: name, label: name })), { value: options[0], confirmLabel: "Choose folder" }); return appSelect(trText(label), options.map((name) => ({ value: name, label: name })), { value: options[0], confirmLabel: trText("Choose folder") });
} }
function openMessageContextMenu(x, y, message) { function openMessageContextMenu(x, y, message) {
@@ -735,6 +735,7 @@ async function showMessageSource() {
const source = await blob.text(); const source = await blob.text();
const dialog = document.createElement("dialog"); const dialog = document.createElement("dialog");
dialog.className = "dialog"; dialog.className = "dialog";
dialog.dataset.i18nScope = "";
const card = el("div", "dialog-card source-card"); const card = el("div", "dialog-card source-card");
const header = el("header", "dialog-header"); const header = el("header", "dialog-header");
header.append(el("strong", "", "Message source")); header.append(el("strong", "", "Message source"));
@@ -747,6 +748,7 @@ async function showMessageSource() {
const download = el("button", "button primary", "Download .eml"); download.type = "button"; download.addEventListener("click", downloadEml); const download = el("button", "button primary", "Download .eml"); download.type = "button"; download.addEventListener("click", downloadEml);
actions.append(copy, download); actions.append(copy, download);
card.append(header, pre, actions); dialog.append(card); document.body.append(dialog); card.append(header, pre, actions); dialog.append(card); document.body.append(dialog);
translateScopedUiText(state.prefs.language);
dialog.addEventListener("close", () => dialog.remove(), { once: true }); dialog.addEventListener("close", () => dialog.remove(), { once: true });
dialog.showModal(); dialog.showModal();
} catch (error) { toast(error.message, "error"); } } catch (error) { toast(error.message, "error"); }
@@ -813,7 +815,7 @@ async function forwardAsAttachment() {
try { try {
const blob = await fetchEmlBlob(detail); const blob = await fetchEmlBlob(detail);
openCompose("new"); openCompose("new");
$("#compose-title").textContent = "Forward as attachment"; $("#compose-title").textContent = trText("Forward as attachment");
$("#compose-subject").value = /^fwd:/i.test(detail.subject || "") ? detail.subject : `Fwd: ${detail.subject || ""}`; $("#compose-subject").value = /^fwd:/i.test(detail.subject || "") ? detail.subject : `Fwd: ${detail.subject || ""}`;
const safe = String(detail.subject || `message-${detail.uid}`).replace(/[\\/:*?"<>|]+/g, "_").slice(0, 120) || `message-${detail.uid}`; const safe = String(detail.subject || `message-${detail.uid}`).replace(/[\\/:*?"<>|]+/g, "_").slice(0, 120) || `message-${detail.uid}`;
const file = new File([blob], `${safe}.eml`, { type: "message/rfc822" }); const file = new File([blob], `${safe}.eml`, { type: "message/rfc822" });
+27 -10
View File
@@ -2,29 +2,46 @@
async function loadQuota() { async function loadQuota() {
const summary = $("#quota-summary span"); const summary = $("#quota-summary span");
const bar = $("#quota-bar"); const bar = $("#quota-bar");
if (!summary || !bar) return; const sidebar = $("#folder-quota");
summary.textContent = "Loading quota…"; const sidebarText = $("#folder-quota-text");
bar.style.width = "0%"; const sidebarBar = $("#folder-quota-bar");
if (!summary && !sidebarText) return;
if (summary) summary.textContent = tr("loading_quota", "Loading quota…");
if (bar) bar.style.width = "0%";
if (sidebarText) sidebarText.textContent = tr("loading", "Loading…");
if (sidebarBar) sidebarBar.style.width = "0%";
const setLevel = (level) => {
for (const box of [$("#quota-summary"), sidebar]) {
box?.classList.toggle("quota-warning", level === "warning");
box?.classList.toggle("quota-danger", level === "danger");
}
};
try { try {
const quota = await request("/quota", { method: "GET" }); const quota = await request("/quota", { method: "GET" });
const used = Number(quota.usage_bytes || 0); const used = Number(quota.usage_bytes || 0);
const limit = Number(quota.limit_bytes || 0); const limit = Number(quota.limit_bytes || 0);
if (limit > 0) { if (limit > 0) {
const percent = Math.max(0, Math.min(100, (used / limit) * 100)); const percent = Math.max(0, Math.min(100, (used / limit) * 100));
summary.textContent = `${formatBytes(used)} of ${formatBytes(limit)} (${percent.toFixed(1)}%)`; if (summary) summary.textContent = `${formatBytes(used)} ${tr("of", "of")} ${formatBytes(limit)} (${percent.toFixed(1)}%)`;
bar.style.width = `${percent}%`; if (bar) bar.style.width = `${percent}%`;
const quotaBox = $("#quota-summary"); if (sidebarText) sidebarText.textContent = `${percent.toFixed(0)}% · ${formatBytes(used)} / ${formatBytes(limit)}`;
if (sidebarBar) sidebarBar.style.width = `${percent}%`;
const level = percent >= 95 ? "danger" : percent >= 80 ? "warning" : "normal"; const level = percent >= 95 ? "danger" : percent >= 80 ? "warning" : "normal";
quotaBox?.classList.toggle("quota-warning", level === "warning"); setLevel(level);
quotaBox?.classList.toggle("quota-danger", level === "danger");
if (level !== "normal" && state.quotaWarningLevel !== `${state.accountId}:${level}`) { if (level !== "normal" && state.quotaWarningLevel !== `${state.accountId}:${level}`) {
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"); toast(level === "danger" ? `Mailbox storage is ${percent.toFixed(0)}% full` : `Mailbox storage is ${percent.toFixed(0)}% used`, level === "danger" ? "error" : "warning");
} }
} else { } else {
summary.textContent = `${formatBytes(used)} used · server limit unavailable`; if (summary) summary.textContent = `${formatBytes(used)} ${tr("used", "used")} · ${tr("server_limit_unavailable", "server limit unavailable")}`;
if (sidebarText) sidebarText.textContent = `${formatBytes(used)} ${tr("used", "used")}`;
setLevel("normal");
} }
} catch (error) { summary.textContent = `Quota unavailable: ${error.message}`; } } catch (error) {
if (summary) summary.textContent = `${tr("quota_unavailable", "Quota unavailable")}: ${error.message}`;
if (sidebarText) sidebarText.textContent = tr("unavailable", "Unavailable");
setLevel("normal");
}
} }
function openSettings(tab = "appearance") { function openSettings(tab = "appearance") {
+64 -10
View File
@@ -61,7 +61,41 @@ function bindPaneResizer(selector, preferenceKey, minPx, maxPx) {
}); });
} }
function positionToolbarMenu(menu) {
if (!menu?.open) return;
const popover = menu.querySelector(".toolbar-menu-popover");
const summary = menu.querySelector("summary");
if (!popover || !summary) return;
if (matchMedia("(max-width: 560px)").matches) {
Object.assign(popover.style, { left: "8px", right: "8px", top: "auto", bottom: "8px" });
return;
}
Object.assign(popover.style, { right: "auto", bottom: "auto" });
const rect = summary.getBoundingClientRect();
const width = Math.max(164, popover.offsetWidth || 164);
const height = popover.offsetHeight || 80;
const margin = 8;
let left = Math.min(rect.left, window.innerWidth - width - margin);
left = Math.max(margin, left);
let top = rect.bottom + 5;
if (top + height > window.innerHeight - margin) top = Math.max(margin, rect.top - height - 5);
popover.style.left = `${Math.round(left)}px`;
popover.style.top = `${Math.round(top)}px`;
}
function bindToolbarMenus() {
const menus = $$(".toolbar-menu");
menus.forEach((menu) => menu.addEventListener("toggle", () => {
if (!menu.open) return;
menus.forEach((other) => { if (other !== menu) other.open = false; });
requestAnimationFrame(() => positionToolbarMenu(menu));
}));
window.addEventListener("resize", () => menus.forEach(positionToolbarMenu), { passive: true });
document.addEventListener("scroll", () => menus.forEach(positionToolbarMenu), true);
}
function bindEvents() { function bindEvents() {
bindToolbarMenus();
bindPaneResizer("#folder-resizer", "folderPaneWidth", 170, 420); bindPaneResizer("#folder-resizer", "folderPaneWidth", 170, 420);
bindPaneResizer("#message-resizer", "messagePaneWidth", 300, 760); bindPaneResizer("#message-resizer", "messagePaneWidth", 300, 760);
bindRecipientAutocomplete("#compose-to"); bindRecipientAutocomplete("#compose-to");
@@ -95,20 +129,23 @@ function bindEvents() {
setComposeMode(next, true); setComposeMode(next, true);
insertIdentitySignature(editor); insertIdentitySignature(editor);
scheduleDraftSave(); scheduleDraftSave();
updateComposeMessageSize();
}); });
$("#compose-from").addEventListener("change", () => { insertIdentitySignature($("#compose-editor")); updateComposePgpUi(true); }); $("#compose-from").addEventListener("change", () => { insertIdentitySignature($("#compose-editor")); updateComposePgpUi(true); updateComposeMessageSize(); });
["#compose-to", "#compose-cc", "#compose-bcc"].forEach((selector) => $(selector).addEventListener("input", () => { ["#compose-to", "#compose-cc", "#compose-bcc"].forEach((selector) => $(selector).addEventListener("input", () => {
state.pgpRecipientStatus = []; state.pgpRecipientStatus = [];
updateComposePgpUi(); updateComposePgpUi();
scheduleDraftSave(); scheduleDraftSave();
updateComposeMessageSize();
})); }));
$("#compose-subject").addEventListener("input", scheduleDraftSave); $("#compose-subject").addEventListener("input", () => { scheduleDraftSave(); updateComposeMessageSize(); });
["#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 () => { ["#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()); if (selector === "#compose-pgp-attach-recipient-keys" && $(selector).checked) await checkComposePgpRecipients(false, composePgpVisibleRecipients());
updateComposePgpUi(); updateComposePgpUi();
scheduleDraftSave(); scheduleDraftSave();
updateComposeMessageSize();
})); }));
$("#compose-editor").addEventListener("input", () => { if (state.composeSelectedImage && !state.composeSelectedImage.isConnected) setSelectedInlineImage(null); scheduleDraftSave(); scheduleUnusedInlineAttachmentCleanup(); }); $("#compose-editor").addEventListener("input", () => { if (state.composeSelectedImage && !state.composeSelectedImage.isConnected) setSelectedInlineImage(null); scheduleDraftSave(); scheduleUnusedInlineAttachmentCleanup(); updateComposeMessageSize(); });
$("#compose-editor").addEventListener("click", (event) => { $("#compose-editor").addEventListener("click", (event) => {
const image = event.target.closest?.("img[data-rustbox-cid]"); const image = event.target.closest?.("img[data-rustbox-cid]");
setSelectedInlineImage(image || null); setSelectedInlineImage(image || null);
@@ -145,8 +182,16 @@ function bindEvents() {
$("#flag-btn").addEventListener("click", toggleFlagged); $("#flag-btn").addEventListener("click", toggleFlagged);
$("#archive-btn").addEventListener("click", archiveCurrent); $("#archive-btn").addEventListener("click", archiveCurrent);
$("#spam-btn").addEventListener("click", junkCurrent); $("#spam-btn").addEventListener("click", junkCurrent);
$("#move-select").addEventListener("change", async (event) => { const target = event.target.value; event.target.value = ""; if (target) await moveCurrentTo(target); }); $("#reader-move-action").addEventListener("click", async () => {
$("#copy-select").addEventListener("change", async (event) => { const target = event.target.value; event.target.value = ""; if (target) await copyCurrentTo(target); }); $("#message-transfer-menu").open = false;
const target = await promptFolder("Move to folder", state.currentDetail?.folder || state.folder);
if (target) await moveCurrentTo(target);
});
$("#reader-copy-action").addEventListener("click", async () => {
$("#message-transfer-menu").open = false;
const target = await promptFolder("Copy to folder", state.currentDetail?.folder || state.folder);
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-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-read").addEventListener("click", () => performBulk("read"));
$("#bulk-unread").addEventListener("click", () => performBulk("unread")); $("#bulk-unread").addEventListener("click", () => performBulk("unread"));
@@ -221,11 +266,13 @@ function bindEvents() {
$("#rename-folder-btn").addEventListener("click", renameFolder); $("#rename-folder-btn").addEventListener("click", renameFolder);
$("#delete-folder-btn").addEventListener("click", deleteFolder); $("#delete-folder-btn").addEventListener("click", deleteFolder);
$("#subscribe-folder-btn").addEventListener("click", toggleCurrentFolderSubscription); $("#subscribe-folder-btn").addEventListener("click", toggleCurrentFolderSubscription);
$("#headers-btn").addEventListener("click", showHeaders); $("#headers-btn").addEventListener("click", () => { $("#message-details-menu").open = false; showHeaders(); });
$("#source-btn").addEventListener("click", showMessageSource); $("#source-btn").addEventListener("click", async () => { $("#message-details-menu").open = false; await showMessageSource(); });
$("#quick-filter-btn").addEventListener("click", () => createFilterFromCurrentMessage("sender")); $("#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("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); } }); $("#reader-from").addEventListener("keydown", (event) => { if (["Enter", " "].includes(event.key)) { event.preventDefault(); const rect = event.currentTarget.getBoundingClientRect(); openCurrentSenderMenu(rect.left, rect.bottom + 4); } });
$("#reader-to").addEventListener("click", toggleReaderAddressExpansion);
$("#reader-to").addEventListener("keydown", (event) => { if (["Enter", " "].includes(event.key)) { event.preventDefault(); toggleReaderAddressExpansion(); } });
$("#print-btn").addEventListener("click", printCurrentMessage); $("#print-btn").addEventListener("click", printCurrentMessage);
$("#pgp-reader-process").addEventListener("click", processCurrentPgpMessage); $("#pgp-reader-process").addEventListener("click", processCurrentPgpMessage);
$("#pgp-reader-discover").addEventListener("click", discoverCurrentPgpSigner); $("#pgp-reader-discover").addEventListener("click", discoverCurrentPgpSigner);
@@ -250,6 +297,7 @@ function bindEvents() {
$$(".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(); } })); $$(".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-theme").addEventListener("change", (event) => savePreference("theme", event.target.value));
$("#pref-interface-skin").addEventListener("change", (event) => savePreference("interfaceSkin", event.target.value)); $("#pref-interface-skin").addEventListener("change", (event) => savePreference("interfaceSkin", event.target.value));
$("#pref-preset-scale-unlocked").addEventListener("change", (event) => savePreference("presetScaleUnlocked", event.target.checked));
$("#pref-density").addEventListener("change", (event) => savePreference("density", event.target.value)); $("#pref-density").addEventListener("change", (event) => savePreference("density", event.target.value));
$("#pref-preview").addEventListener("change", (event) => savePreference("preview", event.target.value)); $("#pref-preview").addEventListener("change", (event) => savePreference("preview", event.target.value));
$("#pref-scale").addEventListener("change", (event) => savePreference("scale", event.target.value)); $("#pref-scale").addEventListener("change", (event) => savePreference("scale", event.target.value));
@@ -396,7 +444,10 @@ function bindEvents() {
$("#sieve-deactivate").addEventListener("click", deactivateSieve); $("#sieve-deactivate").addEventListener("click", deactivateSieve);
$("#sieve-delete").addEventListener("click", deleteSieve); $("#sieve-delete").addEventListener("click", deleteSieve);
bindEditor(); bindEditor();
document.addEventListener("click", (event) => { if (!event.target.closest("#context-menu")) hideContextMenu(); }); document.addEventListener("click", (event) => {
if (!event.target.closest("#context-menu")) hideContextMenu();
if (!event.target.closest(".toolbar-menu")) $$(".toolbar-menu[open]").forEach((menu) => { menu.open = false; });
});
window.addEventListener("blur", hideContextMenu); window.addEventListener("blur", hideContextMenu);
document.addEventListener("scroll", hideContextMenu, true); document.addEventListener("scroll", hideContextMenu, true);
document.addEventListener("keydown", (event) => { document.addEventListener("keydown", (event) => {
@@ -438,7 +489,10 @@ function bindEvents() {
else closeReader(); else closeReader();
} }
}); });
matchMedia("(prefers-color-scheme: light)").addEventListener("change", () => { if (state.prefs.theme === "system") applyPreferences(); }); matchMedia("(prefers-color-scheme: light)").addEventListener("change", () => {
if (!$("#login-view").classList.contains("hidden")) showLogin();
else if (state.prefs.theme === "system") applyPreferences();
});
window.addEventListener("resize", () => { if (innerWidth > 820) setFolderDrawerOpen(false); }); window.addEventListener("resize", () => { if (innerWidth > 820) setFolderDrawerOpen(false); });
} }
@@ -447,7 +501,7 @@ async function boot() {
bindEvents(); bindEvents();
await loadAuthPolicy(); await loadAuthPolicy();
const authenticated = await loadAccounts(); 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); } if (authenticated) { await loadPreferences(); await loadMailPreferences(); await loadOpenPgp(); await loadIdentities(); await loadResponseTemplates(); await loadFolders(true); await loadSpecialFolders(); await loadQuota(); await loadTags(); await loadNotificationPreferences(); await loadTrustedSenders(); startCalendarReminderWatcher(); connectRealtime(); } else { await loadTranslations(state.prefs.language); }
} }
boot(); boot();
+40 -5
View File
@@ -31,10 +31,11 @@ def test_schema_and_response_templates():
migrations = sorted(ROOT.joinpath("migrations").glob("V*.sql")) migrations = sorted(ROOT.joinpath("migrations").glob("V*.sql"))
for migration in migrations: for migration in migrations:
conn.executescript(migration.read_text()) conn.executescript(migration.read_text())
assert int(migrations[-1].name[1:4]) == 45 assert int(migrations[-1].name[1:4]) == 46
display_columns = {row[1] for row in conn.execute("pragma table_info(display_preferences)")} 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)")} response_columns = {row[1] for row in conn.execute("pragma table_info(response_templates)")}
assert "interface_skin" in display_columns assert "interface_skin" in display_columns
assert "preset_scale_unlocked" in display_columns
assert "format" in response_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 (?,?,?)", ("Text", "Hello", "text"))
conn.execute("insert into response_templates(name,body,format) values (?,?,?)", ("HTML", "<b>Hello</b>", "html")) conn.execute("insert into response_templates(name,body,format) values (?,?,?)", ("HTML", "<b>Hello</b>", "html"))
@@ -51,8 +52,14 @@ def test_html_contract():
assert "#icon-arrow-left" in source assert "#icon-arrow-left" in source
assert "pref-interface-skin" in source assert "pref-interface-skin" in source
assert "compose-response-template" in source assert "compose-response-template" in source
assert 'data-default-theme="__RUSTBOX_DEFAULT_THEME__"' in source
assert 'id="folder-quota"' in source
assert 'id="folder-quota-bar"' in source
assert 'id="reader-to"' in source and 'aria-expanded="false"' in source
for control_id in ["message-details-menu", "message-transfer-menu", "reader-move-action", "reader-copy-action", "compose-mode"]:
assert f'id="{control_id}"' in source
assert "data-appearance-preset" not in source assert "data-appearance-preset" not in source
for value in ["rustbox", "roundcube-classic", "roundcube-larry", "roundcube-elastic", "clean", "graphite", "high-contrast"]: for value in ["rustbox", "roundcube-classic", "roundcube-larry", "roundcube-elastic", "clean", "gmail", "proton", "zoho", "outlook", "minimal", "graphite", "high-contrast", "fastmail", "apple-mail", "yahoo", "hey", "nord"]:
assert f'value="{value}"' in source assert f'value="{value}"' in source
@@ -71,7 +78,7 @@ def test_roundcube_palette_contract():
'roundcube-elastic"][data-theme="dark', 'roundcube-elastic"][data-theme="dark',
]: ]:
assert selector in classic_elastic assert selector in classic_elastic
for selector in ["roundcube-larry", "clean", "graphite", "high-contrast"]: for selector in ["roundcube-larry", "clean", "gmail", "proton", "zoho", "outlook", "minimal", "graphite", "high-contrast"]:
assert f'data-interface-skin="{selector}"' in extra_styles assert f'data-interface-skin="{selector}"' in extra_styles
manifest = (ROOT / "src/web/css/manifest.txt").read_text() manifest = (ROOT / "src/web/css/manifest.txt").read_text()
assert "55-roundcube-skins.css" in manifest assert "55-roundcube-skins.css" in manifest
@@ -89,6 +96,12 @@ def test_modular_js_contract():
assert '"roundcube-elastic"' in app assert '"roundcube-elastic"' in app
assert "loadResponseTemplates" in app assert "loadResponseTemplates" in app
assert "insertSavedResponse" in app assert "insertSavedResponse" in app
assert "loadQuota();" in app
assert "toggleReaderAddressExpansion" in app
assert "translateScopedUiText" in app
assert "presetScaleUnlocked" in app
assert "positionToolbarMenu" in app
assert "progressToast" in app
assert "appearancePresets" not in app assert "appearancePresets" not in app
assert "applyAppearancePreset" not in app assert "applyAppearancePreset" not in app
assert "src/web/js/manifest.txt" in (ROOT / "README.md").read_text() assert "src/web/js/manifest.txt" in (ROOT / "README.md").read_text()
@@ -100,20 +113,42 @@ def test_modular_js_contract():
def test_backend_skin_contract_and_version(): def test_backend_skin_contract_and_version():
db_source = (ROOT / "src/db/display_preferences.rs").read_text() db_source = (ROOT / "src/db/display_preferences.rs").read_text()
for value in ["rustbox", "roundcube-classic", "roundcube-larry", "roundcube-elastic", "clean", "graphite", "high-contrast"]: for value in ["rustbox", "roundcube-classic", "roundcube-larry", "roundcube-elastic", "clean", "gmail", "proton", "zoho", "outlook", "minimal", "graphite", "high-contrast", "fastmail", "apple-mail", "yahoo", "hey", "nord"]:
assert f'"{value}"' in db_source assert f'"{value}"' in db_source
cargo = (ROOT / "Cargo.toml").read_text() cargo = (ROOT / "Cargo.toml").read_text()
assert 'version = "0.0.2"' in cargo assert 'version = "0.0.3"' in cargo
config = (ROOT / "src/config.rs").read_text()
assert "pub default_theme: String" in config
assert '"system" | "dark" | "light"' in config
ui = (ROOT / "src/ui.rs").read_text()
assert 'replace("__RUSTBOX_DEFAULT_THEME__", &config.default_theme)' in ui
assert "V044__interface_skin.sql" in (ROOT / "src/db/pool.rs").read_text() 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() assert "V045__response_template_format.sql" in (ROOT / "src/db/pool.rs").read_text()
assert "V046__preset_scale_unlock.sql" in (ROOT / "src/db/pool.rs").read_text()
for path in ROOT.glob("src/web/i18n/*.json"): for path in ROOT.glob("src/web/i18n/*.json"):
json.loads(path.read_text()) json.loads(path.read_text())
def test_mobile_003_contract():
responsive = (ROOT / "src/web/css/60-responsive.css").read_text()
assert '.sieve-builder .checkbox-row input[type="checkbox"]' in responsive
assert ".dialog > .dialog-card" in responsive
assert "grid-template-rows: auto minmax(0, 1fr)" in responsive
assert ".calendar-toolbar" in responsive
assert ".message-meta.addresses-expanded" in responsive
pl = json.loads((ROOT / "src/web/i18n/pl.json").read_text())
assert len(pl.get("_text", {})) >= 450
for key in ["Rule enabled", "Stop after this rule", "Calendar", "Message headers", "Message source", "Forward as attachment", "Show headers", "Show source", "Write a message"]:
assert key in pl["_text"]
for key in ["reply", "reply_all", "forward", "message_details", "move_copy", "move_to", "copy_to"]:
assert key in pl
if __name__ == "__main__": if __name__ == "__main__":
test_schema_and_response_templates() test_schema_and_response_templates()
test_html_contract() test_html_contract()
test_roundcube_palette_contract() test_roundcube_palette_contract()
test_modular_js_contract() test_modular_js_contract()
test_backend_skin_contract_and_version() test_backend_skin_contract_and_version()
test_mobile_003_contract()
print("roundcube-skin-contract: OK") print("roundcube-skin-contract: OK")