From 93762fe9efe889c7fbcc59a2c2ca2c0df36bbf85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Fri, 30 Oct 2026 00:12:09 +0100 Subject: [PATCH] v0.0.3 --- CHANGELOG.md | 24 + Cargo.lock | 2 +- Cargo.toml | 2 +- NOTICE.md | 2 +- README.md | 26 +- RELEASE-MANIFEST.txt | 17 +- config.dev.toml | 21 +- config.example.toml | 28 +- docs/IMPLEMENTATION_STATUS.md | 4 +- docs/ROADMAP_GAPS.md | 4 +- docs/releases/v0.0.3.md | 14 + migrations/V046__preset_scale_unlock.sql | 1 + src/auth/imap_auth.rs | 13 +- src/auth/session.rs | 24 + src/config.rs | 154 +- src/db/display_preferences.rs | 35 +- src/db/pool.rs | 4 + src/error.rs | 16 +- src/imap/connection.rs | 4 +- src/imap/mock.rs | 2 + src/imap/pool.rs | 3 + src/imap/types.rs | 1 + src/main.rs | 7 +- src/pgp/discovery.rs | 2 +- src/realtime/idle.rs | 1 + src/realtime/ws.rs | 1 + src/routes/attachments.rs | 9 +- src/routes/auth.rs | 2 + src/routes/calendar.rs | 2 +- src/routes/diagnostics.rs | 3 + src/routes/display_preferences.rs | 2 + src/routes/drafts.rs | 1 + src/routes/folder_mgmt.rs | 1 + src/routes/folders.rs | 1 + src/routes/messages/mod.rs | 9 + src/routes/mod.rs | 4 +- src/routes/pgp.rs | 1 + src/routes/quota.rs | 1 + src/routes/send.rs | 14 +- src/routes/tags.rs | 1 + src/routes/unsubscribe.rs | 3 +- src/sieve/error.rs | 2 +- src/smtp/client.rs | 80 +- src/smtp/error.rs | 6 + src/smtp/mock.rs | 26 +- src/smtp/types.rs | 3 + src/ui.rs | 6 + src/web/css/00-tokens.css | 185 +- src/web/css/30-mail.css | 63 + src/web/css/40-compose.css | 55 +- src/web/css/50-settings.css | 47 +- src/web/css/56-interface-styles.css | 629 +++++- src/web/css/60-responsive.css | 325 ++++ src/web/i18n/en.json | 21 +- src/web/i18n/pl.json | 595 +++++- src/web/index.html | 131 +- src/web/js/00-bootstrap.js | 2 +- src/web/js/10-core.js | 675 ++++--- src/web/js/20-preferences-auth.js | 1133 ++++++----- src/web/js/40-messages-reader.js | 1373 ++++++------- src/web/js/50-compose.js | 2225 +++++++++++----------- src/web/js/60-mail-actions-search.js | 8 +- src/web/js/80-settings-realtime.js | 37 +- src/web/js/95-bindings-boot.js | 74 +- tests/roundcube_skin_contract.py | 45 +- 65 files changed, 5420 insertions(+), 2792 deletions(-) create mode 100644 docs/releases/v0.0.3.md create mode 100644 migrations/V046__preset_scale_unlock.sql diff --git a/CHANGELOG.md b/CHANGELOG.md index d407c08..9aaec7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,28 @@ # 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 - 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. diff --git a/Cargo.lock b/Cargo.lock index 013dfeb..7134646 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2957,7 +2957,7 @@ dependencies = [ [[package]] name = "rustbox" -version = "0.0.2" +version = "0.0.3" dependencies = [ "async-imap", "async-native-tls", diff --git a/Cargo.toml b/Cargo.toml index 9b47a0b..e9cae15 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustbox" -version = "0.0.2" +version = "0.0.3" edition = "2024" rust-version = "1.88" default-run = "rustbox" diff --git a/NOTICE.md b/NOTICE.md index e7d86a9..98391bc 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -1,6 +1,6 @@ # 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` - Base snapshot: `7f1507c486b78cf33e54beb701dd460d3bd2bd5d` diff --git a/README.md b/README.md index b0600e4..49d66ba 100644 --- a/README.md +++ b/README.md @@ -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`. @@ -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: ```toml -imap_host = "mail.example.com" -imap_port = 993 -smtp_host = "mail.example.com" -smtp_port = 587 +default_theme = "system" # system | dark | light +default_interface_skin = "rustbox" # rustbox | gmail | proton | zoho | outlook | fastmail | apple-mail | yahoo | hey | nord | ... +# Mail hosts may be omitted: IMAP defaults to localhost:993 and SMTP to localhost:587. +# imap_host = "localhost" +# smtp_host = "localhost" 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. # 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`. @@ -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. -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. @@ -133,7 +141,7 @@ Initialize Git after extracting the release archive: ```bash git init git add . -git commit -m "RustBox 0.0.2 release" +git commit -m "RustBox 0.0.3 release" ./scripts/setup-upstream.sh ``` diff --git a/RELEASE-MANIFEST.txt b/RELEASE-MANIFEST.txt index 3ae7105..4a695ac 100644 --- a/RELEASE-MANIFEST.txt +++ b/RELEASE-MANIFEST.txt @@ -1,4 +1,4 @@ -RustBox 0.0.2 source release +RustBox 0.0.3 source release Runtime/source model: - one root Cargo package @@ -19,9 +19,19 @@ Oxi tracking: - Oxi frontend changes are reviewed and manually ported to src/web/ - docs/UI.md documents the browser UI/build conventions -Current 0.0.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 -- 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 - persisted special folders and mailbox behavior preferences - 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 - exact-token thread matching and expanded keyboard selection/navigation shortcuts - 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 - 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 diff --git a/config.dev.toml b/config.dev.toml index a500e7b..1c53b76 100644 --- a/config.dev.toml +++ b/config.dev.toml @@ -2,17 +2,23 @@ host = "127.0.0.1" port = 3001 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" session_timeout_hours = 24 -# Mail server configuration is administrator-owned. The login screen only asks -# for the user's email address and password. -# Set these values before starting RustBox. -imap_host = "" -imap_port = 993 -smtp_host = "" -smtp_port = 587 +# Mail server configuration is administrator-owned. If omitted, development +# also defaults to localhost:993 (IMAP) and localhost:587 (SMTP). +# Certificate verification is disabled automatically only for loopback hosts. 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. sieve_port = 4190 @@ -21,3 +27,4 @@ sieve_allow_insecure_auth = false sieve_timeout_secs = 10 trusted_proxies = "127.0.0.1,::1" + diff --git a/config.example.toml b/config.example.toml index 20c1f7f..1f5d4ff 100644 --- a/config.example.toml +++ b/config.example.toml @@ -1,18 +1,34 @@ -# RustBox 0.0.2 +# RustBox 0.0.3 host = "127.0.0.1" port = 3001 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" session_timeout_hours = 24 remember_me_enabled = true remember_me_days = 30 -# Required mail profile. Users only enter email + password on the login page. -imap_host = "mail.example.com" -imap_port = 993 -smtp_host = "mail.example.com" -smtp_port = 587 +# Mail profile. When hosts/ports are omitted RustBox defaults to: +# IMAP localhost:993 and SMTP localhost:587 with TLS enabled. +# SMTP host falls back to the IMAP host. +# imap_host = "localhost" +# imap_port = 993 +# smtp_host = "localhost" +# smtp_port = 587 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". # base_path = "/mail" diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 7feab46..1900a85 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -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 diff --git a/docs/ROADMAP_GAPS.md b/docs/ROADMAP_GAPS.md index 7c9eb1d..aa8beac 100644 --- a/docs/ROADMAP_GAPS.md +++ b/docs/ROADMAP_GAPS.md @@ -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. @@ -74,7 +74,7 @@ OpenPGP is functionally complete for the scope defined in `RUSTBOX_PLAN.md` §9: ### Search / notifications / diagnostics — 4 - [ ] Complete attachment content/metadata indexing policy and limits. -- [ ] Background/service-worker notifications while the page is inactive, if retained for 0.0.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. - [ ] Background task/queue status plus recent-error/repair workflows. diff --git a/docs/releases/v0.0.3.md b/docs/releases/v0.0.3.md new file mode 100644 index 0000000..d663313 --- /dev/null +++ b/docs/releases/v0.0.3.md @@ -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. diff --git a/migrations/V046__preset_scale_unlock.sql b/migrations/V046__preset_scale_unlock.sql new file mode 100644 index 0000000..1429cc3 --- /dev/null +++ b/migrations/V046__preset_scale_unlock.sql @@ -0,0 +1 @@ +ALTER TABLE display_preferences ADD COLUMN preset_scale_unlocked INTEGER NOT NULL DEFAULT 0; diff --git a/src/auth/imap_auth.rs b/src/auth/imap_auth.rs index 94e0620..11ee44b 100644 --- a/src/auth/imap_auth.rs +++ b/src/auth/imap_auth.rs @@ -22,11 +22,12 @@ pub async fn validate_imap_credentials( host: &str, port: u16, tls: bool, + tls_verify: bool, email: &str, password: &str, ) -> AuthResult { if tls { - validate_tls(host, port, email, password).await + validate_tls(host, port, tls_verify, email, password).await } else { 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 /// `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 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()), }; - 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 { Ok(s) => s, Err(e) => return AuthResult::ServerUnreachable(e.to_string()), @@ -120,7 +123,7 @@ mod tests { #[tokio::test] async fn unreachable_server_returns_server_unreachable() { let result = - validate_imap_credentials("127.0.0.1", 19999, false, "user@test.com", "pass").await; + validate_imap_credentials("127.0.0.1", 19999, false, false, "user@test.com", "pass").await; assert!( matches!(result, AuthResult::ServerUnreachable(_)), @@ -132,7 +135,7 @@ mod tests { #[tokio::test] async fn unreachable_server_tls_returns_server_unreachable() { let result = - validate_imap_credentials("127.0.0.1", 19999, true, "user@test.com", "pass").await; + validate_imap_credentials("127.0.0.1", 19999, true, false, "user@test.com", "pass").await; assert!( matches!(result, AuthResult::ServerUnreachable(_)), diff --git a/src/auth/session.rs b/src/auth/session.rs index cdc6954..db6c209 100644 --- a/src/auth/session.rs +++ b/src/auth/session.rs @@ -23,11 +23,13 @@ pub struct AccountSession { pub imap_host: String, pub imap_port: u16, pub imap_tls: bool, + pub imap_tls_verify: bool, pub smtp_host: String, #[allow(dead_code)] pub smtp_port: u16, #[allow(dead_code)] pub smtp_tls: bool, + pub smtp_tls_verify: bool, pub last_accessed: Instant, pub timeout_override: Option, pub created_at_unix: u64, @@ -49,6 +51,7 @@ pub struct SessionStore { account_to_session: Arc>, account_to_browser: Arc>, timeout: Duration, + tls_verify_override: Option, } impl SessionStore { @@ -60,15 +63,28 @@ impl SessionStore { } 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) -> Self { Self { sessions: Arc::new(DashMap::new()), browsers: Arc::new(DashMap::new()), account_to_session: Arc::new(DashMap::new()), account_to_browser: Arc::new(DashMap::new()), timeout, + 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 { let mut bytes = [0u8; 32]; rand::rng().fill_bytes(&mut bytes); @@ -126,6 +142,8 @@ impl SessionStore { let account_id = Self::generate_id(); 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 { account_id: account_id.clone(), email, @@ -134,9 +152,11 @@ impl SessionStore { imap_host, imap_port, imap_tls, + imap_tls_verify, smtp_host, smtp_port, smtp_tls, + smtp_tls_verify, last_accessed: Instant::now(), timeout_override, created_at_unix: now_unix, @@ -333,9 +353,11 @@ impl SessionStore { imap_host: String::new(), imap_port: 993, imap_tls: true, + imap_tls_verify: true, smtp_host: String::new(), smtp_port: 587, smtp_tls: true, + smtp_tls_verify: true, last_accessed: Instant::now(), timeout_override, created_at_unix: now_unix, @@ -505,9 +527,11 @@ mod tests { imap_host: "imap.test.com".to_string(), imap_port: 993, imap_tls: true, + imap_tls_verify: true, smtp_host: "smtp.test.com".to_string(), smtp_port: 587, smtp_tls: true, + smtp_tls_verify: true, last_accessed: Instant::now(), timeout_override: None, created_at_unix: SessionStore::unix_now(), diff --git a/src/config.rs b/src/config.rs index afdc5f9..6ce4ca1 100644 --- a/src/config.rs +++ b/src/config.rs @@ -24,8 +24,8 @@ pub struct AppConfig { #[serde(default = "default_port")] pub port: u16, - /// IMAP server hostname. Required at startup. - #[serde(default)] + /// IMAP server hostname. Defaults to localhost when omitted. + #[serde(default = "default_imap_host")] pub imap_host: Option, /// IMAP server port. @@ -44,6 +44,20 @@ pub struct AppConfig { #[serde(default = "default_tls_enabled")] 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, + + /// 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. #[serde(default = "default_data_dir")] pub data_dir: String, @@ -64,6 +78,16 @@ pub struct AppConfig { #[serde(default = "default_environment")] 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. #[serde(default)] pub base_path: Option, @@ -158,6 +182,10 @@ fn default_port() -> u16 { 3001 } +fn default_imap_host() -> Option { + Some("localhost".to_string()) +} + fn default_imap_port() -> u16 { 993 } @@ -170,6 +198,14 @@ fn default_tls_enabled() -> bool { true } +fn default_max_attachment_size_mb() -> usize { + 10 +} + +fn default_max_message_size_mb() -> usize { + 50 +} + fn default_data_dir() -> String { "/data".to_string() } @@ -189,6 +225,14 @@ fn default_environment() -> String { "development".to_string() } +fn default_theme() -> String { + "system".to_string() +} + +fn default_interface_skin() -> String { + "rustbox".to_string() +} + fn default_sieve_port() -> u16 { 4190 } @@ -219,16 +263,21 @@ impl Default for AppConfig { Self { host: default_host(), port: default_port(), - imap_host: None, + imap_host: default_imap_host(), imap_port: default_imap_port(), smtp_host: None, smtp_port: default_smtp_port(), 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(), session_timeout_hours: default_session_timeout_hours(), remember_me_enabled: default_remember_me_enabled(), remember_me_days: default_remember_me_days(), environment: default_environment(), + default_theme: default_theme(), + default_interface_skin: default_interface_skin(), base_path: None, sieve_host: None, sieve_port: default_sieve_port(), @@ -261,6 +310,27 @@ impl AppConfig { .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. /// Logs warnings for unparseable entries. pub fn parsed_trusted_proxies(&self) -> Vec { @@ -370,12 +440,46 @@ impl AppConfig { if self.port == 0 || self.imap_port == 0 || self.smtp_port == 0 || self.sieve_port == 0 { return Err("network ports must be greater than zero".to_string()); } + if self.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 { return Err("SESSION_TIMEOUT_HOURS must be between 1 and 8760".to_string()); } if self.remember_me_days == 0 || self.remember_me_days > 365 { return Err("REMEMBER_ME_DAYS must be between 1 and 365".to_string()); } + if !matches!(self.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!( self.sieve_tls_mode.as_str(), "starttls" | "implicit" | "none" @@ -417,16 +521,23 @@ mod tests { assert_eq!(config.host, "0.0.0.0"); 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!(config.smtp_host.is_none()); assert_eq!(config.smtp_port, 587); 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.session_timeout_hours, 24); assert!(config.remember_me_enabled); assert_eq!(config.remember_me_days, 30); 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_eq!(config.sieve_port, 4190); assert_eq!(config.sieve_tls_mode, "starttls"); @@ -454,11 +565,16 @@ mod tests { .merge(("smtp_host", "smtp.example.com")) .merge(("smtp_port", 465u16)) .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(("session_timeout_hours", 48u64)) .merge(("remember_me_enabled", false)) .merge(("remember_me_days", 14u64)) .merge(("environment", "production")) + .merge(("default_theme", "light")) + .merge(("default_interface_skin", "gmail")) .merge(("sieve_host", "sieve.example.com")) .merge(("sieve_port", 4191u16)) .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_port, 465); 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.session_timeout_hours, 48); assert!(!config.remember_me_enabled); assert_eq!(config.remember_me_days, 14); 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_port, 4191); 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] fn outgoing_headers_are_parsed_and_structural_headers_rejected() { let config = AppConfig { diff --git a/src/db/display_preferences.rs b/src/db/display_preferences.rs index 7de9bd1..9591a52 100644 --- a/src/db/display_preferences.rs +++ b/src/db/display_preferences.rs @@ -54,6 +54,7 @@ pub struct DisplayPreferences { pub forward_mode: String, pub spellcheck_enabled: bool, pub interface_skin: String, + pub preset_scale_unlocked: bool, pub updated_at: String, } @@ -111,6 +112,7 @@ pub struct UpdateDisplayPreferences { pub forward_mode: Option, pub spellcheck_enabled: Option, pub interface_skin: Option, + pub preset_scale_unlocked: Option, } fn deserialize_animation_mode_field<'de, D>( @@ -175,14 +177,15 @@ fn defaults() -> DisplayPreferences { quote_style: "standard".into(), forward_mode: "inline".into(), spellcheck_enabled: true, - interface_skin: "rustbox".into(), + interface_skin: "default".into(), + preset_scale_unlocked: false, updated_at: String::new(), } } pub fn get_preferences(conn: &Connection) -> Result { let result = conn.query_row( - "SELECT density, theme, language, compose_format, deep_index, animation_mode, preview_pane, ui_scale, color_preset, accent_color, background_color, panel_color, text_color, font_family, font_size, row_height, folder_pane_width, message_pane_width, border_radius, contrast, show_avatars, show_attachment_icons, show_flags, reduce_motion, date_format, time_format, compose_autosave_ms, default_priority, request_mdn_default, attachment_reminder, icon_size, toolbar_density, sidebar_item_height, reader_max_width, composer_width, selection_color, unread_font_weight, unread_badge_style, compact_headers, show_borders, scrollbar_density, monochrome, folder_background, message_background, reader_background, compose_font_family, reply_position, quote_style, forward_mode, spellcheck_enabled, 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 { 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 compose_autosave_ms: row.get(26)?, default_priority: row.get(27)?, request_mdn_default: row.get::<_, i32>(28)? != 0, attachment_reminder: row.get::<_, i32>(29)? != 0, icon_size: row.get(30)?, toolbar_density: row.get(31)?, sidebar_item_height: row.get(32)?, reader_max_width: row.get(33)?, composer_width: row.get(34)?, selection_color: row.get(35)?, unread_font_weight: row.get(36)?, unread_badge_style: row.get(37)?, compact_headers: row.get::<_, i32>(38)? != 0, show_borders: row.get::<_, i32>(39)? != 0, - scrollbar_density: row.get(40)?, monochrome: row.get::<_, i32>(41)? != 0, folder_background: row.get(42)?, message_background: row.get(43)?, reader_background: row.get(44)?, compose_font_family: row.get(45)?, reply_position: row.get(46)?, quote_style: row.get(47)?, forward_mode: row.get(48)?, spellcheck_enabled: row.get::<_, i32>(49)? != 0, 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 { @@ -340,13 +343,24 @@ pub fn update_preferences( "interface_skin", v, &[ + "default", "rustbox", "roundcube-classic", "roundcube-larry", "roundcube-elastic", "clean", + "gmail", + "proton", + "zoho", + "outlook", + "minimal", "graphite", "high-contrast", + "fastmail", + "apple-mail", + "yahoo", + "hey", + "nord", ], )?; } @@ -479,6 +493,7 @@ pub fn update_preferences( set_text!(forward_mode, "forward_mode"); set_bool!(spellcheck_enabled, "spellcheck_enabled"); set_text!(interface_skin, "interface_skin"); + set_bool!(preset_scale_unlocked, "preset_scale_unlocked"); if sets.is_empty() { return get_preferences(conn); @@ -509,7 +524,7 @@ mod tests { let update: UpdateDisplayPreferences = serde_json::from_value(serde_json::json!({ "theme":"dark", "language":"pl", "color_preset":"blue", "accent_color":"#336699", "font_size":15, "row_height":64, "folder_pane_width":250, "message_pane_width":480, - "show_avatars":false, "reduce_motion":true, "interface_skin":"roundcube-elastic" + "show_avatars":false, "reduce_motion":true, "interface_skin":"roundcube-elastic", "preset_scale_unlocked":true })) .unwrap(); let prefs = update_preferences(&conn, &update).unwrap(); @@ -518,18 +533,30 @@ mod tests { assert_eq!(prefs.font_size, 15); assert!(!prefs.show_avatars); assert_eq!(prefs.interface_skin, "roundcube-elastic"); + assert!(prefs.preset_scale_unlocked); } #[test] fn built_in_interface_skins_are_accepted() { for skin in [ + "default", "rustbox", "roundcube-classic", "roundcube-larry", "roundcube-elastic", "clean", + "gmail", + "proton", + "zoho", + "outlook", + "minimal", "graphite", "high-contrast", + "fastmail", + "apple-mail", + "yahoo", + "hey", + "nord", ] { let conn = open_test_db(); let update: UpdateDisplayPreferences = diff --git a/src/db/pool.rs b/src/db/pool.rs index e71b640..083d66c 100644 --- a/src/db/pool.rs +++ b/src/db/pool.rs @@ -193,6 +193,10 @@ const MIGRATIONS: &[(u32, &str)] = &[ 45, 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. diff --git a/src/error.rs b/src/error.rs index ff702cf..700c186 100644 --- a/src/error.rs +++ b/src/error.rs @@ -33,6 +33,8 @@ pub enum AppError { Forbidden(String), /// Service unavailable (503). ServiceUnavailable(String), + /// Request payload is larger than the configured mail limit (413). + PayloadTooLarge(String), } impl AppError { @@ -45,6 +47,7 @@ impl AppError { AppError::BadRequest(_) => StatusCode::BAD_REQUEST, AppError::Forbidden(_) => StatusCode::FORBIDDEN, AppError::ServiceUnavailable(_) => StatusCode::SERVICE_UNAVAILABLE, + AppError::PayloadTooLarge(_) => StatusCode::PAYLOAD_TOO_LARGE, } } @@ -57,6 +60,7 @@ impl AppError { AppError::BadRequest(_) => "BAD_REQUEST", AppError::Forbidden(_) => "FORBIDDEN", AppError::ServiceUnavailable(_) => "SERVICE_UNAVAILABLE", + AppError::PayloadTooLarge(_) => "PAYLOAD_TOO_LARGE", } } @@ -68,7 +72,8 @@ impl AppError { | AppError::Unauthorized(msg) | AppError::BadRequest(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); } + #[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] async fn service_unavailable_returns_503() { let (status, json) = error_to_json(AppError::ServiceUnavailable("IMAP down".into())).await; diff --git a/src/imap/connection.rs b/src/imap/connection.rs index 12475f8..f9ad693 100644 --- a/src/imap/connection.rs +++ b/src/imap/connection.rs @@ -43,7 +43,9 @@ pub(crate) async fn connect( .map_err(|e| ImapError::ConnectionFailed(e.to_string()))?; 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 .connect(&creds.host, tcp) .await diff --git a/src/imap/mock.rs b/src/imap/mock.rs index 7308c83..2ac95b4 100644 --- a/src/imap/mock.rs +++ b/src/imap/mock.rs @@ -452,6 +452,7 @@ mod tests { host: "imap.example.com".to_string(), port: 993, tls: true, + tls_verify: true, email: "user@example.com".to_string(), password: "hunter2".to_string(), } @@ -785,6 +786,7 @@ mod tests { host, port, tls, + tls_verify: true, email, password, }) diff --git a/src/imap/pool.rs b/src/imap/pool.rs index 6f6ef90..52e41bc 100644 --- a/src/imap/pool.rs +++ b/src/imap/pool.rs @@ -54,6 +54,7 @@ pub(super) fn identity(creds: &ImapCredentials) -> [u8; 32] { creds.host.as_bytes(), &creds.port.to_be_bytes(), &[u8::from(creds.tls)], + &[u8::from(creds.tls_verify)], creds.email.as_bytes(), creds.password.as_bytes(), ] { @@ -264,6 +265,7 @@ mod tests { host: "127.0.0.1".into(), port: listener.local_addr().unwrap().port(), tls: false, + tls_verify: false, email: "test@example.com".into(), password: "secret".into(), }; @@ -450,6 +452,7 @@ mod tests { host: std::env::var("TEST_IMAP_HOST").expect("TEST_IMAP_HOST required"), port: 993, tls: true, + tls_verify: true, email: std::env::var("TEST_IMAP_EMAIL").expect("TEST_IMAP_EMAIL required"), password: std::env::var("TEST_IMAP_PASSWORD").expect("TEST_IMAP_PASSWORD required"), }; diff --git a/src/imap/types.rs b/src/imap/types.rs index f5d74a2..0803428 100644 --- a/src/imap/types.rs +++ b/src/imap/types.rs @@ -88,6 +88,7 @@ pub struct ImapCredentials { pub host: String, pub port: u16, pub tls: bool, + pub tls_verify: bool, pub email: String, pub password: String, } diff --git a/src/main.rs b/src/main.rs index 6044a72..35d3080 100644 --- a/src/main.rs +++ b/src/main.rs @@ -54,9 +54,10 @@ async fn main() -> Result<(), Box> { let config = Arc::new(loaded_config); // Create the in-memory session store with the configured timeout. - let store = Arc::new(SessionStore::new(Duration::from_secs( - config.session_timeout_hours * 3600, - ))); + let store = Arc::new(SessionStore::new_with_tls_verify( + Duration::from_secs(config.session_timeout_hours * 3600), + config.tls_verify, + )); // Spawn a background task that periodically purges expired sessions. { diff --git a/src/pgp/discovery.rs b/src/pgp/discovery.rs index 7eeee47..9754678 100644 --- a/src/pgp/discovery.rs +++ b/src/pgp/discovery.rs @@ -126,7 +126,7 @@ async fn https_get_address( format!("{host}:{port}") }; let request = format!( - "GET {target} HTTP/1.1\r\nHost: {host_header}\r\nUser-Agent: RustBox/0.0.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())) .await diff --git a/src/realtime/idle.rs b/src/realtime/idle.rs index af50367..5ed931d 100644 --- a/src/realtime/idle.rs +++ b/src/realtime/idle.rs @@ -927,6 +927,7 @@ mod tests { host: "localhost".into(), port: 143, tls: false, + tls_verify: false, email: "test@example.com".into(), password: "test".into(), }; diff --git a/src/realtime/ws.rs b/src/realtime/ws.rs index 3ba0353..ae2e141 100644 --- a/src/realtime/ws.rs +++ b/src/realtime/ws.rs @@ -116,6 +116,7 @@ async fn handle_socket_multi_account( host: session.imap_host.clone(), port: session.imap_port, tls: session.imap_tls, + tls_verify: session.imap_tls_verify, email: session.email.clone(), password: session.password.clone(), }; diff --git a/src/routes/attachments.rs b/src/routes/attachments.rs index 037cf11..4cf842b 100644 --- a/src/routes/attachments.rs +++ b/src/routes/attachments.rs @@ -14,8 +14,6 @@ use crate::config::AppConfig; use crate::db; use crate::error::AppError; -/// Maximum attachment file size: 25 MB. -const MAX_ATTACHMENT_SIZE: usize = 25 * 1024 * 1024; const STALE_PART_MAX_AGE: Duration = Duration::from_secs(24 * 60 * 60); async fn cleanup_stale_upload_parts(dir: &Path) { @@ -92,6 +90,7 @@ pub async fn upload_attachment( cleanup_stale_upload_parts(&att_dir).await; let mut uploaded = Vec::new(); + let max_attachment_size = config.max_attachment_size_bytes(); while let Some(mut field) = multipart .next_field() @@ -124,12 +123,12 @@ pub async fn upload_attachment( }; let Some(chunk) = chunk else { break }; size = size.saturating_add(chunk.len()); - if size > MAX_ATTACHMENT_SIZE { + if size > max_attachment_size { drop(file); let _ = tokio::fs::remove_file(&part_path).await; return Err(AppError::BadRequest(format!( - "File '{}' exceeds maximum size of 25 MB", - filename + "File '{}' exceeds maximum size of {} MiB", + filename, config.max_attachment_size_mb ))); } if let Err(e) = file.write_all(&chunk).await { diff --git a/src/routes/auth.rs b/src/routes/auth.rs index 1e12c3b..eb477fa 100644 --- a/src/routes/auth.rs +++ b/src/routes/auth.rs @@ -197,6 +197,7 @@ pub async fn login( let imap_port = config.imap_port; 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_tls = config.tls_enabled; @@ -204,6 +205,7 @@ pub async fn login( &imap_host, imap_port, imap_tls, + imap_tls_verify, &body.email, &body.password, ) diff --git a/src/routes/calendar.rs b/src/routes/calendar.rs index f24a007..8f4de96 100644 --- a/src/routes/calendar.rs +++ b/src/routes/calendar.rs @@ -192,7 +192,7 @@ pub async fn export_ics( .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; let mut body = String::from( - "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//RustBox//Calendar 0.0.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 { append_ics_event(&mut body, event); diff --git a/src/routes/diagnostics.rs b/src/routes/diagnostics.rs index a5e1922..88e5ac0 100644 --- a/src/routes/diagnostics.rs +++ b/src/routes/diagnostics.rs @@ -82,6 +82,7 @@ pub async fn imap_diagnostics( host: session.imap_host.clone(), port: session.imap_port, tls: session.imap_tls, + tls_verify: session.imap_tls_verify, email: session.email.clone(), password: session.password.clone(), }; @@ -139,6 +140,7 @@ pub async fn smtp_diagnostics( host: session.smtp_host.clone(), port: session.smtp_port, tls: session.smtp_tls, + tls_verify: session.smtp_tls_verify, email: session.email.clone(), password: session.password.clone(), }; @@ -169,6 +171,7 @@ pub async fn reconnect_imap( host: session.imap_host.clone(), port: session.imap_port, tls: session.imap_tls, + tls_verify: session.imap_tls_verify, email: session.email.clone(), password: session.password.clone(), }; diff --git a/src/routes/display_preferences.rs b/src/routes/display_preferences.rs index ef90a87..fe03dd9 100644 --- a/src/routes/display_preferences.rs +++ b/src/routes/display_preferences.rs @@ -89,9 +89,11 @@ mod tests { imap_host: "imap.example.com".to_string(), imap_port: 993, imap_tls: true, + imap_tls_verify: true, smtp_host: "smtp.example.com".to_string(), smtp_port: 587, smtp_tls: true, + smtp_tls_verify: true, last_accessed: Instant::now(), timeout_override: None, created_at_unix: 0, diff --git a/src/routes/drafts.rs b/src/routes/drafts.rs index 6160d72..ae325e1 100644 --- a/src/routes/drafts.rs +++ b/src/routes/drafts.rs @@ -245,6 +245,7 @@ fn session_imap_credentials(session: &SessionState) -> ImapCredentials { host: session.imap_host.clone(), port: session.imap_port, tls: session.imap_tls, + tls_verify: session.imap_tls_verify, email: session.email.clone(), password: session.password.clone(), } diff --git a/src/routes/folder_mgmt.rs b/src/routes/folder_mgmt.rs index 96795d3..4894f5a 100644 --- a/src/routes/folder_mgmt.rs +++ b/src/routes/folder_mgmt.rs @@ -79,6 +79,7 @@ fn build_creds(session: &SessionState, _config: &AppConfig) -> Result Result { + 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(); // 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(), port: session.imap_port, tls: session.imap_tls, + tls_verify: session.imap_tls_verify, email: session.email.clone(), password: session.password.clone(), }; diff --git a/src/routes/tags.rs b/src/routes/tags.rs index f51fd4b..4acd39c 100644 --- a/src/routes/tags.rs +++ b/src/routes/tags.rs @@ -110,6 +110,7 @@ fn imap_credentials(session: &SessionState) -> ImapCredentials { host: session.imap_host.clone(), port: session.imap_port, tls: session.imap_tls, + tls_verify: session.imap_tls_verify, email: session.email.clone(), password: session.password.clone(), } diff --git a/src/routes/unsubscribe.rs b/src/routes/unsubscribe.rs index 8139ef6..d46ef70 100644 --- a/src/routes/unsubscribe.rs +++ b/src/routes/unsubscribe.rs @@ -86,6 +86,7 @@ async fn load_raw_headers( host: session.imap_host.clone(), port: session.imap_port, tls: session.imap_tls, + tls_verify: session.imap_tls_verify, email: session.email.clone(), password: session.password.clone(), }; @@ -229,7 +230,7 @@ async fn post_one_click_to_address( format!("{host}:{port}") }; let request = format!( - "POST {path} HTTP/1.1\r\nHost: {host_header}\r\nUser-Agent: RustBox/0.0.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() ); diff --git a/src/sieve/error.rs b/src/sieve/error.rs index 48c9854..5c50603 100644 --- a/src/sieve/error.rs +++ b/src/sieve/error.rs @@ -29,7 +29,7 @@ impl fmt::Display for SieveError { write!(f, "ManageSieve server rejected the command: {msg}") } Self::Timeout => write!(f, "ManageSieve operation timed out"), - Self::ScriptTooLarge => write!(f, "Sieve script exceeds the RustBox 0.0.2 size limit"), + Self::ScriptTooLarge => write!(f, "Sieve script exceeds the RustBox 0.0.3 size limit"), } } } diff --git a/src/smtp/client.rs b/src/smtp/client.rs index 1981133..6611cfa 100644 --- a/src/smtp/client.rs +++ b/src/smtp/client.rs @@ -43,6 +43,34 @@ pub trait SmtpClient: Send + Sync { /// performs the operation, and disconnects. pub struct RealSmtpClient; +fn smtp_transport( + creds: &SmtpCredentials, +) -> Result, 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::::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] impl SmtpClient for RealSmtpClient { async fn send_message( @@ -50,8 +78,7 @@ impl SmtpClient for RealSmtpClient { creds: &SmtpCredentials, message: &SendableMessage, ) -> Result { - use lettre::transport::smtp::authentication::Credentials; - use lettre::{AsyncSmtpTransport, AsyncTransport, Tokio1Executor}; + use lettre::AsyncTransport; let message_id = format!( "<{}.{}@{}>", @@ -62,26 +89,14 @@ impl SmtpClient for RealSmtpClient { let email = crate::smtp::builder::build_message(message, &message_id) .map_err(SmtpError::SendFailed)?; 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: AsyncSmtpTransport = if creds.tls && creds.port == 587 { - AsyncSmtpTransport::::starttls_relay(&creds.host) - .map_err(|e| SmtpError::ConnectionFailed(e.to_string()))? - .port(creds.port) - .credentials(smtp_creds) - .build() - } else if creds.tls { - AsyncSmtpTransport::::relay(&creds.host) - .map_err(|e| SmtpError::ConnectionFailed(e.to_string()))? - .port(creds.port) - .credentials(smtp_creds) - .build() - } else { - AsyncSmtpTransport::::builder_dangerous(&creds.host) - .port(creds.port) - .credentials(smtp_creds) - .build() - }; + let transport = smtp_transport(creds)?; transport.send(email).await.map_err(|e| { let msg = e.to_string(); @@ -107,28 +122,7 @@ impl SmtpClient for RealSmtpClient { } async fn check_connection(&self, creds: &SmtpCredentials) -> Result<(), SmtpError> { - use lettre::transport::smtp::authentication::Credentials; - use lettre::{AsyncSmtpTransport, Tokio1Executor}; - - let smtp_creds = Credentials::new(creds.email.clone(), creds.password.clone()); - let transport: AsyncSmtpTransport = if creds.tls && creds.port == 587 { - AsyncSmtpTransport::::starttls_relay(&creds.host) - .map_err(|e| SmtpError::ConnectionFailed(e.to_string()))? - .port(creds.port) - .credentials(smtp_creds) - .build() - } else if creds.tls { - AsyncSmtpTransport::::relay(&creds.host) - .map_err(|e| SmtpError::ConnectionFailed(e.to_string()))? - .port(creds.port) - .credentials(smtp_creds) - .build() - } else { - AsyncSmtpTransport::::builder_dangerous(&creds.host) - .port(creds.port) - .credentials(smtp_creds) - .build() - }; + let transport = smtp_transport(creds)?; let accepted = transport.test_connection().await.map_err(|e| { let message = e.to_string(); diff --git a/src/smtp/error.rs b/src/smtp/error.rs index 9ba2bd1..178a383 100644 --- a/src/smtp/error.rs +++ b/src/smtp/error.rs @@ -12,6 +12,8 @@ pub enum SmtpError { AuthenticationFailed, /// The message could not be sent. SendFailed(String), + /// The fully rendered RFC822/MIME payload exceeds the configured MTA limit. + MessageTooLarge { actual: usize, limit: usize }, } impl fmt::Display for SmtpError { @@ -20,6 +22,10 @@ impl fmt::Display for SmtpError { SmtpError::ConnectionFailed(msg) => write!(f, "Connection failed: {msg}"), SmtpError::AuthenticationFailed => write!(f, "Authentication failed"), 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)" + ), } } } diff --git a/src/smtp/mock.rs b/src/smtp/mock.rs index 75d13df..cf3e4bd 100644 --- a/src/smtp/mock.rs +++ b/src/smtp/mock.rs @@ -55,6 +55,10 @@ fn clone_error(err: &SmtpError) -> SmtpError { SmtpError::ConnectionFailed(msg) => SmtpError::ConnectionFailed(msg.clone()), SmtpError::AuthenticationFailed => SmtpError::AuthenticationFailed, 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() { return Err(clone_error(err)); } - self.sent_messages.lock().unwrap().push(message.clone()); let message_id = format!("", uuid::Uuid::new_v4()); let rfc822 = crate::smtp::builder::build_rfc822(message, &message_id) .map_err(SmtpError::SendFailed)?; + 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 }) } } @@ -90,6 +100,7 @@ mod tests { host: "smtp.example.com".to_string(), port: 587, tls: true, + tls_verify: true, email: "user@example.com".to_string(), password: "hunter2".to_string(), } @@ -114,6 +125,7 @@ mod tests { extra_headers: vec![], attachments: vec![], pgp: None, + max_message_size_bytes: 50 * 1024 * 1024, } } @@ -127,6 +139,16 @@ mod tests { 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] async fn mock_send_captures_message() { let mock = MockSmtpClient::new(); @@ -152,6 +174,7 @@ mod tests { content_id: None, }], pgp: None, + max_message_size_bytes: 50 * 1024 * 1024, }; let result = mock.send_message(&test_creds(), &msg).await; @@ -205,6 +228,7 @@ mod tests { host: "invalid.host.test".to_string(), port: 587, tls: true, + tls_verify: true, email: "user@invalid.host.test".to_string(), password: "password".to_string(), }; diff --git a/src/smtp/types.rs b/src/smtp/types.rs index 76214af..ff53794 100644 --- a/src/smtp/types.rs +++ b/src/smtp/types.rs @@ -7,6 +7,7 @@ pub struct SmtpCredentials { pub host: String, pub port: u16, pub tls: bool, + pub tls_verify: bool, pub email: String, pub password: String, } @@ -52,6 +53,8 @@ pub struct SendableMessage { pub attachments: Vec, /// Optional OpenPGP transformation applied to the MIME entity. pub pgp: Option, + /// Hard limit for the final RFC822/MIME payload submitted to SMTP. + pub max_message_size_bytes: usize, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/src/ui.rs b/src/ui.rs index b5427d8..1bfcc46 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -95,6 +95,12 @@ pub async fn serve_ui(uri: Uri, Extension(config): Extension>) -> .replace("__RUSTBOX_ASSET_PREFIX__", asset_prefix) .replace("__RUSTBOX_ASSET_ID__", ASSET_ID) .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()); ( StatusCode::OK, diff --git a/src/web/css/00-tokens.css b/src/web/css/00-tokens.css index 5c31f50..3931582 100644 --- a/src/web/css/00-tokens.css +++ b/src/web/css/00-tokens.css @@ -37,6 +37,7 @@ --field-pad-x: 8px; --field-pad-y: 5px; } + html[data-theme="light"] { color-scheme: light; --bg: #f2f4f1; @@ -61,32 +62,137 @@ html[data-theme="light"] { --shadow: rgba(31, 39, 33, .16); } -/* User personalization */ :root { --folder-pane-width: 228px; --message-pane-width: 420px; } -html[data-color-preset="blue"] { --accent: #6f96c9; --accent-strong: #86a9d5; --selected: color-mix(in srgb, var(--accent) 20%, var(--panel)); } -html[data-color-preset="slate"] { --accent: #8e9aa8; --accent-strong: #aab4bf; --selected: color-mix(in srgb, var(--accent) 20%, var(--panel)); } -html[data-color-preset="amber"] { --accent: #c69a55; --accent-strong: #d7ad6b; --selected: color-mix(in srgb, var(--accent) 20%, var(--panel)); } -html[data-color-preset="violet"] { --accent: #9b88c9; --accent-strong: #ad9bd5; --selected: color-mix(in srgb, var(--accent) 20%, var(--panel)); } -html[data-color-preset="rose"] { --accent: #c98591; --accent-strong: #d79aa5; --selected: color-mix(in srgb, var(--accent) 20%, var(--panel)); } -html[data-color-preset="custom"] { --bg: var(--user-bg, #121513); --panel: var(--user-panel, #181c19); --panel-2: color-mix(in srgb, var(--panel) 88%, var(--text)); --panel-3: color-mix(in srgb, var(--panel) 80%, var(--text)); --sidebar: color-mix(in srgb, var(--bg) 88%, black); --text: var(--user-text, #edf0ec); --muted: color-mix(in srgb, var(--text) 66%, var(--panel)); --subtle: color-mix(in srgb, var(--text) 52%, var(--panel)); --field: color-mix(in srgb, var(--bg) 82%, var(--panel)); --line: color-mix(in srgb, var(--text) 18%, var(--panel)); --line-strong: color-mix(in srgb, var(--text) 30%, var(--panel)); --hover: color-mix(in srgb, var(--text) 8%, var(--panel)); --accent: var(--user-accent, #6f9c86); --accent-strong: color-mix(in srgb, var(--accent) 82%, white); --selected: color-mix(in srgb, var(--accent) 22%, var(--panel)); } -html[data-font-family="inter"] { --font-sans: Inter, ui-sans-serif, system-ui, sans-serif; } -html[data-font-family="humanist"] { --font-sans: "Segoe UI", Candara, Calibri, ui-sans-serif, system-ui, sans-serif; } -html[data-font-family="mono"] { --font-sans: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace; } -html[data-contrast="soft"] { --line: color-mix(in srgb, var(--line) 70%, transparent); --line-strong: color-mix(in srgb, var(--line-strong) 78%, transparent); } -html[data-contrast="high"] { --line: color-mix(in srgb, var(--text) 28%, var(--panel)); --line-strong: color-mix(in srgb, var(--text) 48%, var(--panel)); --muted: color-mix(in srgb, var(--text) 74%, var(--panel)); } -html[data-hide-avatars="true"] .avatar { display: none !important; } -html[data-hide-attachments="true"] .message-attachment, html[data-hide-attachments="true"] .attachment-indicator { display: none !important; } -html[data-hide-flags="true"] .message-flag, html[data-hide-flags="true"] .flag-indicator { display: none !important; } -html[data-reduce-motion="true"] * { scroll-behavior: auto !important; transition: none !important; animation: none !important; } -html[data-color-preset="teal"] { --accent:#4f9d93; --accent-strong:#68b0a5; --selected:color-mix(in srgb,var(--accent) 20%,var(--panel)); } -html[data-color-preset="cyan"] { --accent:#4c9fba; --accent-strong:#68b4cb; --selected:color-mix(in srgb,var(--accent) 20%,var(--panel)); } -html[data-color-preset="indigo"] { --accent:#7184c5; --accent-strong:#8b9bd2; --selected:color-mix(in srgb,var(--accent) 20%,var(--panel)); } -html[data-color-preset="graphite"] { --accent:#9aa19c; --accent-strong:#b0b6b2; --selected:color-mix(in srgb,var(--accent) 18%,var(--panel)); } -html[data-color-preset="orange"] { --accent:#c9824e; --accent-strong:#d69a6d; --selected:color-mix(in srgb,var(--accent) 20%,var(--panel)); } -html[data-color-preset="red"] { --accent:#bd6662; --accent-strong:#cf7e7a; --selected:color-mix(in srgb,var(--accent) 20%,var(--panel)); } + +html[data-color-preset="blue"] { + --accent: #6f96c9; + --accent-strong: #86a9d5; + --selected: color-mix(in srgb, var(--accent) 20%, var(--panel)); +} + +html[data-color-preset="slate"] { + --accent: #8e9aa8; + --accent-strong: #aab4bf; + --selected: color-mix(in srgb, var(--accent) 20%, var(--panel)); +} + +html[data-color-preset="amber"] { + --accent: #c69a55; + --accent-strong: #d7ad6b; + --selected: color-mix(in srgb, var(--accent) 20%, var(--panel)); +} + +html[data-color-preset="violet"] { + --accent: #9b88c9; + --accent-strong: #ad9bd5; + --selected: color-mix(in srgb, var(--accent) 20%, var(--panel)); +} + +html[data-color-preset="rose"] { + --accent: #c98591; + --accent-strong: #d79aa5; + --selected: color-mix(in srgb, var(--accent) 20%, var(--panel)); +} + +html[data-color-preset="custom"] { + --bg: var(--user-bg, #121513); + --panel: var(--user-panel, #181c19); + --panel-2: color-mix(in srgb, var(--panel) 88%, var(--text)); + --panel-3: color-mix(in srgb, var(--panel) 80%, var(--text)); + --sidebar: color-mix(in srgb, var(--bg) 88%, black); + --text: var(--user-text, #edf0ec); + --muted: color-mix(in srgb, var(--text) 66%, var(--panel)); + --subtle: color-mix(in srgb, var(--text) 52%, var(--panel)); + --field: color-mix(in srgb, var(--bg) 82%, var(--panel)); + --line: color-mix(in srgb, var(--text) 18%, var(--panel)); + --line-strong: color-mix(in srgb, var(--text) 30%, var(--panel)); + --hover: color-mix(in srgb, var(--text) 8%, var(--panel)); + --accent: var(--user-accent, #6f9c86); + --accent-strong: color-mix(in srgb, var(--accent) 82%, white); + --selected: color-mix(in srgb, var(--accent) 22%, var(--panel)); +} + +html[data-font-family="inter"] { + --font-sans: Inter, ui-sans-serif, system-ui, sans-serif; +} + +html[data-font-family="humanist"] { + --font-sans: "Segoe UI", Candara, Calibri, ui-sans-serif, system-ui, sans-serif; +} + +html[data-font-family="mono"] { + --font-sans: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace; +} + +html[data-contrast="soft"] { + --line: color-mix(in srgb, var(--line) 70%, transparent); + --line-strong: color-mix(in srgb, var(--line-strong) 78%, transparent); +} + +html[data-contrast="high"] { + --line: color-mix(in srgb, var(--text) 28%, var(--panel)); + --line-strong: color-mix(in srgb, var(--text) 48%, var(--panel)); + --muted: color-mix(in srgb, var(--text) 74%, var(--panel)); +} + +html[data-hide-avatars="true"] .avatar { + display: none !important; +} + +html[data-hide-attachments="true"] .message-attachment, +html[data-hide-attachments="true"] .attachment-indicator { + display: none !important; +} + +html[data-hide-flags="true"] .message-flag, +html[data-hide-flags="true"] .flag-indicator { + display: none !important; +} + +html[data-reduce-motion="true"] * { + scroll-behavior: auto !important; + transition: none !important; + animation: none !important; +} + +html[data-color-preset="teal"] { + --accent: #4f9d93; + --accent-strong: #68b0a5; + --selected: color-mix(in srgb, var(--accent) 20%, var(--panel)); +} + +html[data-color-preset="cyan"] { + --accent: #4c9fba; + --accent-strong: #68b4cb; + --selected: color-mix(in srgb, var(--accent) 20%, var(--panel)); +} + +html[data-color-preset="indigo"] { + --accent: #7184c5; + --accent-strong: #8b9bd2; + --selected: color-mix(in srgb, var(--accent) 20%, var(--panel)); +} + +html[data-color-preset="graphite"] { + --accent: #9aa19c; + --accent-strong: #b0b6b2; + --selected: color-mix(in srgb, var(--accent) 18%, var(--panel)); +} + +html[data-color-preset="orange"] { + --accent: #c9824e; + --accent-strong: #d69a6d; + --selected: color-mix(in srgb, var(--accent) 20%, var(--panel)); +} + +html[data-color-preset="red"] { + --accent: #bd6662; + --accent-strong: #cf7e7a; + --selected: color-mix(in srgb, var(--accent) 20%, var(--panel)); +} /* Advanced per-user interface controls. */ :root { @@ -99,11 +205,32 @@ html[data-color-preset="red"] { --accent:#bd6662; --accent-strong:#cf7e7a; --sel --message-bg: var(--panel); --reader-bg: var(--panel); } -html[data-monochrome="true"] { --accent:#929a95; --accent-strong:#aeb4b0; --danger:#9f7775; --warning:#9b8e75; --success:#879482; } -html[data-scrollbar-density="normal"] * { scrollbar-width:auto; } -html[data-scrollbar-density="thin"] * { scrollbar-width:thin; } -html[data-scrollbar-density="hidden"] * { scrollbar-width:none; } -html[data-scrollbar-density="hidden"] *::-webkit-scrollbar { width:0; height:0; } + +html[data-monochrome="true"] { + --accent: #929a95; + --accent-strong: #aeb4b0; + --danger: #9f7775; + --warning: #9b8e75; + --success: #879482; +} + +html[data-scrollbar-density="normal"] * { + scrollbar-width: auto; +} + +html[data-scrollbar-density="thin"] * { + scrollbar-width: thin; +} + +html[data-scrollbar-density="hidden"] * { + scrollbar-width: none; +} + +html[data-scrollbar-density="hidden"] *::-webkit-scrollbar { + width: 0; + height: 0; +} + html[data-show-borders="false"] .folder-pane, html[data-show-borders="false"] .message-pane, html[data-show-borders="false"] .pane-toolbar, @@ -111,4 +238,6 @@ html[data-show-borders="false"] .reader-toolbar, html[data-show-borders="false"] .message-header, html[data-show-borders="false"] .thread-section, html[data-show-borders="false"] .attachment-list, -html[data-show-borders="false"] .message-row { border-color:transparent !important; } +html[data-show-borders="false"] .message-row { + border-color: transparent !important; +} \ No newline at end of file diff --git a/src/web/css/30-mail.css b/src/web/css/30-mail.css index f318524..df837d1 100644 --- a/src/web/css/30-mail.css +++ b/src/web/css/30-mail.css @@ -1130,4 +1130,67 @@ html[data-toolbar-density="spacious"] .reader-toolbar { height: 100%; max-height: 72dvh; 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; } \ No newline at end of file diff --git a/src/web/css/40-compose.css b/src/web/css/40-compose.css index 23424f4..4b804ae 100644 --- a/src/web/css/40-compose.css +++ b/src/web/css/40-compose.css @@ -213,7 +213,7 @@ } .compose-editor:empty::before { - content: "Write a message"; + content: attr(data-placeholder); color: var(--subtle); } @@ -295,11 +295,54 @@ } .compose-mode-control { - margin-left: auto; + flex: 0 0 auto; + justify-self: start; } .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 { @@ -386,8 +429,6 @@ font-size: 10px; } - -/* Inline image editing */ .compose-editor img[data-rustbox-cid] { max-width: 100%; height: auto; @@ -673,7 +714,6 @@ } -/* Signature/footer editor */ .signature-editor-shell { overflow: hidden; border: 1px solid var(--line-strong); @@ -800,6 +840,7 @@ overflow: auto; } } + .compose-response-control { min-width: 118px; max-width: 190px; @@ -809,4 +850,4 @@ min-height: 28px; padding-top: 2px; padding-bottom: 2px; -} +} \ No newline at end of file diff --git a/src/web/css/50-settings.css b/src/web/css/50-settings.css index d32db16..5805e32 100644 --- a/src/web/css/50-settings.css +++ b/src/web/css/50-settings.css @@ -1165,7 +1165,6 @@ margin: 6px 0; } -/* Diagnostics and mailbox behavior */ .diagnostics-grid { display: grid; gap: 12px; @@ -1363,7 +1362,6 @@ min-width: 0; } -/* Calendar views */ .calendar-toolbar { display: flex; flex-wrap: wrap; @@ -1521,7 +1519,6 @@ } } -/* 0.0.1 UI reliability pass: viewport-safe dialogs, toasts and calendar layout */ .dialog { max-width: calc(100vw - 16px); max-height: calc(100dvh - 16px); @@ -1539,9 +1536,6 @@ scrollbar-gutter: auto; } -/* Grid dialogs own their internal scroll regions; simple/legacy dialogs fall - back to scrolling the complete card so actions can never be trapped below - the viewport. */ .compose-card, .settings-card, .identity-card, @@ -1830,6 +1824,41 @@ 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) { .contacts-card, @@ -2291,7 +2320,6 @@ kbd { } } -/* OpenPGP settings */ .pgp-settings-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); @@ -2393,4 +2421,9 @@ kbd { .response-template-meta span { font-size: 10px; +} + +.diagnostic-imap-actions { + justify-content: flex-end; + margin-top: 10px; } \ No newline at end of file diff --git a/src/web/css/56-interface-styles.css b/src/web/css/56-interface-styles.css index 26f3035..8ad263d 100644 --- a/src/web/css/56-interface-styles.css +++ b/src/web/css/56-interface-styles.css @@ -1,6 +1,3 @@ -/* Fixed interface styles for RustBox 0.0.2. - These styles intentionally ignore RustBox custom palette variables. */ - /* ---------- Roundcube Larry ---------- */ html[data-interface-skin="roundcube-larry"] { --font-sans: "Lucida Grande", "Segoe UI", Arial, Helvetica, sans-serif; @@ -360,3 +357,629 @@ html[data-interface-skin="high-contrast"] .button.primary { 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); +} \ No newline at end of file diff --git a/src/web/css/60-responsive.css b/src/web/css/60-responsive.css index 0587fe2..2e301ea 100644 --- a/src/web/css/60-responsive.css +++ b/src/web/css/60-responsive.css @@ -269,4 +269,329 @@ html[data-preview="bottom"] .reader-pane { transition: 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; + } } \ No newline at end of file diff --git a/src/web/i18n/en.json b/src/web/i18n/en.json index 85cf93c..6b8eec7 100644 --- a/src/web/i18n/en.json +++ b/src/web/i18n/en.json @@ -36,5 +36,24 @@ "print_message": "Print message", "create_filter_sender": "Create filter from sender", "filter_draft_created": "Filter draft created from message", - "select_all_results": "Select all results" + "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…" } diff --git a/src/web/i18n/pl.json b/src/web/i18n/pl.json index 67050da..17483fc 100644 --- a/src/web/i18n/pl.json +++ b/src/web/i18n/pl.json @@ -36,5 +36,598 @@ "print_message": "Drukuj wiadomość", "create_filter_sender": "Utwórz filtr z nadawcy", "filter_draft_created": "Utworzono szkic filtra z wiadomości", - "select_all_results": "Zaznacz wszystkie wyniki" + "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…" } diff --git a/src/web/index.html b/src/web/index.html index f2b6b2d..caa45bc 100644 --- a/src/web/index.html +++ b/src/web/index.html @@ -1,5 +1,5 @@ - + @@ -7,6 +7,16 @@ RustBox + @@ -19,7 +29,7 @@ RustBox -