1 parent
53f4812adc
commit
20d78ee7be
20 files changed
+542
-49
No files matched your search
@@ -2,6 +2,16 @@
|
|||||||
|
|
||||||
What changed in each RustBox release, written for the people who use and run it. Technical details are in the [documentation](docs/).
|
What changed in each RustBox release, written for the people who use and run it. Technical details are in the [documentation](docs/).
|
||||||
|
|
||||||
|
## 0.2.27
|
||||||
|
|
||||||
|
- Monochrome mode now creates a custom copy when editing a predefined interface style, just like direct colour changes.
|
||||||
|
- Wide message rows give more room to the subject and show the complete subject in a tooltip.
|
||||||
|
- Read/unread, flag and open actions no longer reserve hidden horizontal space; they appear to the left on hover.
|
||||||
|
- Shift-click message popups now open as a reader-only embedded view instead of briefly showing the full mailbox.
|
||||||
|
- Opening a message in a new window or tab now uses the same reader-only embedded view as the popup instead of the full mailbox/fullscreen layout.
|
||||||
|
- Adaptive HTML mail colours now derive light/dark mode from the actual reader background, so custom palettes stay readable even when their saved theme mode does not match their visual brightness.
|
||||||
|
- Mailbox storage is now clickable: folder properties can show IMAP-reported size in MB, and a storage dialog visualizes per-folder usage with progress bars.
|
||||||
|
|
||||||
## 0.2.26
|
## 0.2.26
|
||||||
|
|
||||||
- RustBox now has a license: the GNU AGPL v3, plus an optional paid commercial license for companies that prefer different terms. The About page explains both.
|
- RustBox now has a license: the GNU AGPL v3, plus an optional paid commercial license for companies that prefer different terms. The About page explains both.
|
||||||
|
|||||||
Generated
+1
-1
@@ -3506,7 +3506,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustbox"
|
name = "rustbox"
|
||||||
version = "0.2.26"
|
version = "0.2.27"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
"async-imap",
|
"async-imap",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "rustbox"
|
name = "rustbox"
|
||||||
version = "0.2.26"
|
version = "0.2.27"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
rust-version = "1.99.1"
|
rust-version = "1.99.1"
|
||||||
license = "AGPL-3.0-only"
|
license = "AGPL-3.0-only"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# RustBox 0.2.26
|
# RustBox 0.2.27
|
||||||
|
|
||||||
**Fast, self-hosted webmail written in Rust.** RustBox connects to any IMAP/SMTP server and ships as one executable with the whole interface built in, so there is no Node.js, PHP or separate frontend to deploy.
|
**Fast, self-hosted webmail written in Rust.** RustBox connects to any IMAP/SMTP server and ships as one executable with the whole interface built in, so there is no Node.js, PHP or separate frontend to deploy.
|
||||||
|
|
||||||
|
|||||||
+11
-3
@@ -1,4 +1,12 @@
|
|||||||
RustBox 0.2.26 release manifest
|
RustBox 0.2.27 release manifest
|
||||||
|
|
||||||
|
0.2.27 release changes:
|
||||||
|
- predefined styles promote to a custom copy when monochrome mode is changed
|
||||||
|
- wide message rows allocate more space to subjects and expose the complete subject as a tooltip
|
||||||
|
- message quick actions overlay to the left on hover instead of reserving hidden width
|
||||||
|
- Shift-click message popups use a reader-only embedded layout
|
||||||
|
- adaptive HTML mail colors derive light/dark behavior from the actual reader background for custom palettes
|
||||||
|
- mailbox storage opens a graphical per-folder IMAP size view; folder properties show measured size in MB when available
|
||||||
|
|
||||||
0.2.26 release changes:
|
0.2.26 release changes:
|
||||||
- licensing: GNU AGPL v3 (`LICENSE`) with an optional paid commercial license (`LICENSING.md`); `NOTICE.md` keeps the MIT notice for parts originating from Oxi; Cargo metadata declares `AGPL-3.0-only`
|
- licensing: GNU AGPL v3 (`LICENSE`) with an optional paid commercial license (`LICENSING.md`); `NOTICE.md` keeps the MIT notice for parts originating from Oxi; Cargo metadata declares `AGPL-3.0-only`
|
||||||
@@ -118,14 +126,14 @@ Docker examples:
|
|||||||
|
|
||||||
Operations/docs:
|
Operations/docs:
|
||||||
- standalone `scripts/migrate_roundcube.py` imports Roundcube contacts/identities using RustBox config.toml and supports SQLite/MySQL/PostgreSQL targets
|
- standalone `scripts/migrate_roundcube.py` imports Roundcube contacts/identities using RustBox config.toml and supports SQLite/MySQL/PostgreSQL targets
|
||||||
- README.md documents 0.2.26 configuration
|
- README.md documents 0.2.27 configuration
|
||||||
- docker/README.md documents Compose variants
|
- docker/README.md documents Compose variants
|
||||||
- docs/STORAGE_SEARCH_OPERATIONS.md documents failure modes, shared-storage requirements and migration behavior
|
- docs/STORAGE_SEARCH_OPERATIONS.md documents failure modes, shared-storage requirements and migration behavior
|
||||||
- TODO.md leaves live SILO/MinIO/AWS/Ceph and real multi-node acceptance validation explicitly pending
|
- TODO.md leaves live SILO/MinIO/AWS/Ceph and real multi-node acceptance validation explicitly pending
|
||||||
|
|
||||||
Build/release metadata:
|
Build/release metadata:
|
||||||
- `scripts/package-release.sh` runs the full Rust release gate before creating ZIP/SHA256 artifacts
|
- `scripts/package-release.sh` runs the full Rust release gate before creating ZIP/SHA256 artifacts
|
||||||
- Cargo.toml version = 0.2.26
|
- Cargo.toml version = 0.2.27
|
||||||
- rust-toolchain.toml pins Rust 1.99.1
|
- rust-toolchain.toml pins Rust 1.99.1
|
||||||
- Cargo.lock is retained for reproducible application builds; the release gate generates it only when missing
|
- Cargo.lock is retained for reproducible application builds; the release gate generates it only when missing
|
||||||
- CI and scripts/build-release.sh use the retained Cargo.lock (generating it only if missing), then run locked tests/Clippy/release build
|
- CI and scripts/build-release.sh use the retained Cargo.lock (generating it only if missing), then run locked tests/Clippy/release build
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
RustBox 0.2.26 - final source validation
|
RustBox 0.2.27 - final source validation
|
||||||
|
|
||||||
0.2.26 scope:
|
0.2.27 scope:
|
||||||
- AGPL v3 + commercial dual licensing, Oxi notice, removal of the Oxi sync tooling, redesigned About page, feature documentation with screenshots, user-facing changelog, comment cleanup
|
- monochrome preset promotion, wider subject presentation with tooltip, non-reserving hover actions, reader-only popup embedding, adaptive HTML-mail light/dark inference from the actual reader surface, graphical per-folder IMAP storage usage, plus the 0.2.26 licensing/documentation baseline
|
||||||
|
|
||||||
0.2.25 scope:
|
0.2.25 scope:
|
||||||
- filters: apply now on existing messages (`POST /api/filters/match`, IMAP UID SEARCH bounds + cache), exceptions, new conditions and actions, templates, missing-folder creation, "always move from this sender"
|
- filters: apply now on existing messages (`POST /api/filters/match`, IMAP UID SEARCH bounds + cache), exceptions, new conditions and actions, templates, missing-folder creation, "always move from this sender"
|
||||||
@@ -67,7 +67,7 @@ RustBox 0.2.26 - final source validation
|
|||||||
- Composing shows only compact attachment/message size-limit values on one line
|
- Composing shows only compact attachment/message size-limit values on one line
|
||||||
- 0.2.9 realtime notification gating, browser shortcut preservation and 300-message defaults remain intact
|
- 0.2.9 realtime notification gating, browser shortcut preservation and 300-message defaults remain intact
|
||||||
- V047-V052 are consolidated into compatibility baseline V052; V053 adds `skin_depth` and `skin_gradients`; V054 adds the theme schedule and account colour; V055 is next
|
- V047-V052 are consolidated into compatibility baseline V052; V053 adds `skin_depth` and `skin_gradients`; V054 adds the theme schedule and account colour; V055 is next
|
||||||
- version metadata identifies 0.2.26
|
- version metadata identifies 0.2.27
|
||||||
|
|
||||||
Completed in this environment:
|
Completed in this environment:
|
||||||
- offline release validator: scripts/validate-release-static.py (455/455 checks passed)
|
- offline release validator: scripts/validate-release-static.py (455/455 checks passed)
|
||||||
@@ -75,7 +75,7 @@ Completed in this environment:
|
|||||||
- all JSON files parse successfully
|
- all JSON files parse successfully
|
||||||
- Python helper scripts compile successfully
|
- Python helper scripts compile successfully
|
||||||
- shell scripts pass `bash -n`
|
- shell scripts pass `bash -n`
|
||||||
- release metadata consistently identifies 0.2.26
|
- release metadata consistently identifies 0.2.27
|
||||||
- V052 baseline consolidation plus the V053 skin-depth migration are validated offline
|
- V052 baseline consolidation plus the V053 skin-depth migration are validated offline
|
||||||
- 0.2.25 filter UI (apply now, run now, always move from sender), context submenus and preset editing exercised in Playwright against a mocked API; the 0.2.24 styles rendered at standard and immersive depth with gradients on and off; earlier: all 37 interface styles rendered in light and dark at the immersive depth against a mocked API (Playwright), with no page errors
|
- 0.2.25 filter UI (apply now, run now, always move from sender), context submenus and preset editing exercised in Playwright against a mocked API; the 0.2.24 styles rendered at standard and immersive depth with gradients on and off; earlier: all 37 interface styles rendered in light and dark at the immersive depth against a mocked API (Playwright), with no page errors
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
# RustBox 0.2.26 - implementation status
|
# RustBox 0.2.27 - implementation status
|
||||||
|
|
||||||
- Draft saves are realtime-silent: Drafts folder/list updates continue, but saving a draft does not surface as a new-mail notification.
|
- Draft saves are realtime-silent: Drafts folder/list updates continue, but saving a draft does not surface as a new-mail notification.
|
||||||
This file tracks the `0.2.26` source snapshot. Storage/search scope and outstanding acceptance work are tracked in `TODO.md`. 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 `0.2.27` source snapshot. Storage/search scope and outstanding acceptance work are tracked in `TODO.md`. A feature is only listed as implemented when working code exists in this source tree; partial items are called out explicitly.
|
||||||
|
|
||||||
|
## 0.2.27 mail-list and appearance polish
|
||||||
|
|
||||||
|
- Monochrome changes on a predefined style promote it to a custom copy before applying the change.
|
||||||
|
- Wide message rows give the subject more horizontal space and expose its full value in a tooltip.
|
||||||
|
- Read/unread, flag and open actions appear to the left on hover without reserving hidden row width.
|
||||||
|
- Shift-click popups use a reader-only embedded layout from initial render.
|
||||||
|
|
||||||
|
|
||||||
## 0.2.26 licensing and documentation
|
## 0.2.26 licensing and documentation
|
||||||
|
|
||||||
@@ -145,7 +153,7 @@ This file tracks the `0.2.26` source snapshot. Storage/search scope and outstand
|
|||||||
- After moving/deleting the currently opened message (including Spam, Not spam, Archive and Move), the reader automatically opens the next list item and falls back to the previous item when the removed message was last.
|
- After moving/deleting the currently opened message (including Spam, Not spam, Archive and Move), the reader automatically opens the next list item and falls back to the previous item when the removed message was last.
|
||||||
- Message reading, collapsible thread navigation, attachments, raw headers, full RFC822 `.eml` download via `BODY.PEEK[]` and `.eml` import into a selected IMAP folder via APPEND.
|
- Message reading, collapsible thread navigation, attachments, raw headers, full RFC822 `.eml` download via `BODY.PEEK[]` and `.eml` import into a selected IMAP folder via APPEND.
|
||||||
- Message/folder context menus with common Roundcube-style actions, including folder properties, full message source, Message-ID/address copy and mailing-list unsubscribe; the reader sender line also exposes compose/contact/search/filter actions.
|
- Message/folder context menus with common Roundcube-style actions, including folder properties, full message source, Message-ID/address copy and mailing-list unsubscribe; the reader sender line also exposes compose/contact/search/filter actions.
|
||||||
- Mailbox quota display where the IMAP server exposes quota, including warning/danger states at 80%/95%.
|
- Mailbox quota display where the IMAP server exposes quota, including warning/danger states at 80%/95%. The storage summary opens a per-folder usage view backed by IMAP message sizes, and folder properties expose the measured size when the server permits the read.
|
||||||
- Per-account mailbox behavior: 20/50/100/200 messages per page, date/sender/subject/size sorting in both directions, configurable mark-read delay, threaded/flat reader mode, HTML/plain preference, remote-image policy including Contacts-only loading, quote limiting, optional list snippets and a default “flagged on top” mode. Each folder can inherit, force-enable or force-disable flagged-first ordering; sorting is applied before pagination.
|
- Per-account mailbox behavior: 20/50/100/200 messages per page, date/sender/subject/size sorting in both directions, configurable mark-read delay, threaded/flat reader mode, HTML/plain preference, remote-image policy including Contacts-only loading, quote limiting, optional list snippets and a default “flagged on top” mode. Each folder can inherit, force-enable or force-disable flagged-first ordering; sorting is applied before pagination.
|
||||||
- IMAP capability diagnostics for IDLE, MOVE, UIDPLUS, CONDSTORE, QRESYNC, SORT, THREAD, NAMESPACE, SPECIAL-USE, QUOTA, ACL, LIST-EXTENDED and UTF8=ACCEPT.
|
- IMAP capability diagnostics for IDLE, MOVE, UIDPLUS, CONDSTORE, QRESYNC, SORT, THREAD, NAMESPACE, SPECIAL-USE, QUOTA, ACL, LIST-EXTENDED and UTF8=ACCEPT.
|
||||||
- Forward-as-attachment using `message/rfc822`.
|
- Forward-as-attachment using `message/rfc822`.
|
||||||
|
|||||||
@@ -1373,6 +1373,9 @@ impl ImapClient for RealImapClient {
|
|||||||
}
|
}
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
tracing::warn!(folder = %folder, "fetch_folder_size timed out after 60s");
|
tracing::warn!(folder = %folder, "fetch_folder_size timed out after 60s");
|
||||||
|
return Err(ImapError::ConnectionFailed(
|
||||||
|
"folder-size FETCH timed out after 60s".into(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
Ok(Ok(())) => session.recycle(),
|
Ok(Ok(())) => session.recycle(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -582,6 +582,7 @@ pub fn create_router_with_storage(config: Arc<AppConfig>, services: RouterServic
|
|||||||
delete(calendar::delete_meeting_template),
|
delete(calendar::delete_meeting_template),
|
||||||
)
|
)
|
||||||
.route("/quota", get(quota::get_quota))
|
.route("/quota", get(quota::get_quota))
|
||||||
|
.route("/folders/{name}/size", get(quota::get_folder_size))
|
||||||
.route("/settings/reset", post(settings::reset_all_settings));
|
.route("/settings/reset", post(settings::reset_all_settings));
|
||||||
|
|
||||||
let diagnostics_routes = Router::new()
|
let diagnostics_routes = Router::new()
|
||||||
|
|||||||
+42
-8
@@ -1,6 +1,7 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use axum::extract::Path;
|
||||||
use axum::response::{IntoResponse, Response};
|
use axum::response::{IntoResponse, Response};
|
||||||
use axum::{Extension, Json};
|
use axum::{Extension, Json};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
@@ -11,6 +12,46 @@ use crate::db;
|
|||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use crate::imap::client::{ImapClient, ImapCredentials};
|
use crate::imap::client::{ImapClient, ImapCredentials};
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct FolderSizeResponse {
|
||||||
|
size_bytes: Option<u64>,
|
||||||
|
available: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn session_credentials(session: &SessionState) -> ImapCredentials {
|
||||||
|
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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_folder_size(
|
||||||
|
Path(name): Path<String>,
|
||||||
|
Extension(session): Extension<SessionState>,
|
||||||
|
Extension(imap_client): Extension<Arc<dyn ImapClient>>,
|
||||||
|
) -> Result<Response, AppError> {
|
||||||
|
let creds = session_credentials(&session);
|
||||||
|
let result = crate::imap::pool::background(imap_client.fetch_folder_size(&creds, &name)).await;
|
||||||
|
let response = match result {
|
||||||
|
Ok(size_bytes) => FolderSizeResponse {
|
||||||
|
size_bytes: Some(size_bytes),
|
||||||
|
available: true,
|
||||||
|
},
|
||||||
|
Err(error) => {
|
||||||
|
tracing::debug!(folder = %name, error = %error, "Folder size is unavailable from IMAP");
|
||||||
|
FolderSizeResponse {
|
||||||
|
size_bytes: None,
|
||||||
|
available: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Ok(Json(response).into_response())
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
struct QuotaResponse {
|
struct QuotaResponse {
|
||||||
usage_bytes: Option<u64>,
|
usage_bytes: Option<u64>,
|
||||||
@@ -23,14 +64,7 @@ pub async fn get_quota(
|
|||||||
Extension(config): Extension<Arc<AppConfig>>,
|
Extension(config): Extension<Arc<AppConfig>>,
|
||||||
Extension(imap_client): Extension<Arc<dyn ImapClient>>,
|
Extension(imap_client): Extension<Arc<dyn ImapClient>>,
|
||||||
) -> Result<Response, AppError> {
|
) -> Result<Response, AppError> {
|
||||||
let creds = ImapCredentials {
|
let creds = session_credentials(&session);
|
||||||
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(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let quota = imap_client.get_quota(&creds).await.unwrap_or(None);
|
let quota = imap_client.get_quota(&creds).await.unwrap_or(None);
|
||||||
|
|
||||||
|
|||||||
+43
-4
@@ -882,8 +882,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.message-icons {
|
.message-icons {
|
||||||
|
position: relative;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
min-width: 40px;
|
min-width: 0;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -894,11 +895,20 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.message-quick-actions {
|
.message-quick-actions {
|
||||||
|
position: absolute;
|
||||||
|
right: calc(100% + 4px);
|
||||||
|
top: 50%;
|
||||||
|
z-index: 2;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 1px;
|
gap: 1px;
|
||||||
|
padding: 1px;
|
||||||
|
border-radius: var(--radius-control);
|
||||||
|
background: var(--hover);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-row:hover .message-quick-actions,
|
.message-row:hover .message-quick-actions,
|
||||||
@@ -1327,6 +1337,36 @@ body.reader-fullscreen-open {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Popup message view: render only the reader surface, without briefly exposing the full mailbox shell. */
|
||||||
|
html[data-mail-embed="true"] .app-view {
|
||||||
|
grid-template-rows: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-mail-embed="true"] :is(.appbar, .offline-banner, .folder-pane, .message-pane, .folder-drawer-scrim) {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-mail-embed="true"] .mail-shell {
|
||||||
|
display: block;
|
||||||
|
min-height: 0;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-mail-embed="true"] .reader-pane {
|
||||||
|
display: block !important;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-mail-embed="true"] .reader-content {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-mail-embed="true"] #reader-fullscreen {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.reader-pane.reader-fullscreen .reader-content {
|
.reader-pane.reader-fullscreen .reader-content {
|
||||||
height: 100dvh;
|
height: 100dvh;
|
||||||
}
|
}
|
||||||
@@ -1416,11 +1456,10 @@ body.reader-fullscreen-open {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Single-line rows when the message pane is wide enough. */
|
|
||||||
@container message-pane (min-width: 520px) {
|
@container message-pane (min-width: 520px) {
|
||||||
html[data-compact-wide-rows="true"] .message-row {
|
html[data-compact-wide-rows="true"] .message-row {
|
||||||
min-height: min(var(--row-height), 36px);
|
min-height: min(var(--row-height), 36px);
|
||||||
grid-template-columns: 19px minmax(110px, .34fr) minmax(0, 1fr) auto auto;
|
grid-template-columns: 19px minmax(110px, .24fr) minmax(0, 1fr) auto auto;
|
||||||
grid-template-rows: minmax(24px, auto);
|
grid-template-rows: minmax(24px, auto);
|
||||||
column-gap: 8px;
|
column-gap: 8px;
|
||||||
row-gap: 0;
|
row-gap: 0;
|
||||||
@@ -1476,7 +1515,7 @@ body.reader-fullscreen-open {
|
|||||||
}
|
}
|
||||||
|
|
||||||
html[data-compact-wide-rows="true"] .message-list.selection-mode .message-row {
|
html[data-compact-wide-rows="true"] .message-list.selection-mode .message-row {
|
||||||
grid-template-columns: 18px 19px minmax(110px, .34fr) minmax(0, 1fr) auto auto;
|
grid-template-columns: 18px 19px minmax(110px, .24fr) minmax(0, 1fr) auto auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
html[data-compact-wide-rows="true"] .message-list.selection-mode .message-row .message-select {
|
html[data-compact-wide-rows="true"] .message-list.selection-mode .message-row .message-select {
|
||||||
|
|||||||
+145
-3
@@ -1565,6 +1565,150 @@
|
|||||||
align-self: center;
|
align-self: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.storage-open-trigger {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-open-trigger:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quota-summary.storage-open-trigger:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-card {
|
||||||
|
width: min(720px, calc(100vw - 28px));
|
||||||
|
max-height: min(760px, calc(100dvh - 28px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-overview {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 16px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
background: var(--panel-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-overview-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-overview-head>div {
|
||||||
|
display: grid;
|
||||||
|
gap: 3px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-overview-head strong {
|
||||||
|
font-size: 16px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-overview-total {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-overview-track,
|
||||||
|
.storage-folder-track {
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--panel-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-overview-track {
|
||||||
|
height: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-overview-track span,
|
||||||
|
.storage-folder-track span {
|
||||||
|
display: block;
|
||||||
|
width: 0;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: inherit;
|
||||||
|
background: var(--accent);
|
||||||
|
transition: width .2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-folders-section {
|
||||||
|
display: grid;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 14px 16px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-folders-heading {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: baseline;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-folder-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
max-height: min(480px, 55dvh);
|
||||||
|
overflow: auto;
|
||||||
|
padding-right: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-folder-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: 6px 12px;
|
||||||
|
padding: 9px 10px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-control);
|
||||||
|
background: var(--field);
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-folder-name {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 7px;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-folder-name svg {
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-folder-name span {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-folder-value {
|
||||||
|
color: var(--muted);
|
||||||
|
font: 11px var(--font-mono);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-folder-track {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-folder-row[data-state="unavailable"] .storage-folder-track span {
|
||||||
|
width: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-note {
|
||||||
|
margin: 0;
|
||||||
|
padding: 8px 16px 15px;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
.folder-properties-card {
|
.folder-properties-card {
|
||||||
width: min(520px, calc(100vw - 28px));
|
width: min(520px, calc(100vw - 28px));
|
||||||
}
|
}
|
||||||
@@ -2686,6 +2830,7 @@ kbd {
|
|||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.multi-account-notifications.hidden {
|
.multi-account-notifications.hidden {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@@ -2699,8 +2844,6 @@ kbd {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/* Theme schedule, account colour, contrast hint, list skeleton, theme cross-fade. */
|
|
||||||
.theme-schedule-row {
|
.theme-schedule-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
@@ -2748,7 +2891,6 @@ kbd {
|
|||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 6px 9px;
|
padding: 6px 9px;
|
||||||
/* Fixed colours: must stay readable with any custom palette. */
|
|
||||||
border: 1px solid #d9a93a;
|
border: 1px solid #d9a93a;
|
||||||
border-radius: var(--radius-control);
|
border-radius: var(--radius-control);
|
||||||
background: #fff4d6;
|
background: #fff4d6;
|
||||||
|
|||||||
@@ -1279,6 +1279,21 @@
|
|||||||
"private + public": "prywatny + publiczny",
|
"private + public": "prywatny + publiczny",
|
||||||
"public": "publiczny",
|
"public": "publiczny",
|
||||||
"import": "import",
|
"import": "import",
|
||||||
|
"View mailbox storage": "Pokaż wykorzystanie pamięci skrzynki",
|
||||||
|
"Server quota": "Limit serwera",
|
||||||
|
"Measured folders": "Zmierzono w folderach",
|
||||||
|
"Share of measured folder storage": "Udział w zmierzonej pamięci folderów",
|
||||||
|
"Folder sizes are calculated from message sizes reported by IMAP. Server quota can include additional data, so the totals may differ.": "Rozmiary folderów są obliczane z rozmiarów wiadomości raportowanych przez IMAP. Limit serwera może obejmować dodatkowe dane, dlatego sumy mogą się różnić.",
|
||||||
|
"Measuring…": "Obliczanie…",
|
||||||
|
"No folders available": "Brak dostępnych folderów",
|
||||||
|
"Size (IMAP)": "Rozmiar (IMAP)",
|
||||||
|
"Subscribed": "Subskrybowany",
|
||||||
|
"Messages": "Wiadomości",
|
||||||
|
"Delimiter": "Separator",
|
||||||
|
"Special role": "Rola specjalna",
|
||||||
|
"Attributes": "Atrybuty",
|
||||||
|
"Yes": "Tak",
|
||||||
|
"No": "Nie",
|
||||||
"default": "domyślna"
|
"default": "domyślna"
|
||||||
},
|
},
|
||||||
"fixed_interface_style_note": "gotowy styl interfejsu. Włącz edycję poniżej, aby dopasować jego układ i typografię.",
|
"fixed_interface_style_note": "gotowy styl interfejsu. Włącz edycję poniżej, aby dopasować jego układ i typografię.",
|
||||||
|
|||||||
+25
-2
@@ -120,7 +120,8 @@
|
|||||||
<use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-plus"></use>
|
<use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-plus"></use>
|
||||||
</svg></button></div>
|
</svg></button></div>
|
||||||
<nav id="tag-list" class="folder-list tag-list" aria-label="Message labels"></nav>
|
<nav id="tag-list" class="folder-list tag-list" aria-label="Message labels"></nav>
|
||||||
<div id="folder-quota" class="folder-quota" aria-live="polite">
|
<div id="folder-quota" class="folder-quota storage-open-trigger" aria-live="polite" role="button" tabindex="0"
|
||||||
|
title="View mailbox storage" aria-label="View mailbox storage">
|
||||||
<div class="folder-quota-label"><span>Mailbox storage</span><span id="folder-quota-text">Loading…</span></div>
|
<div class="folder-quota-label"><span>Mailbox storage</span><span id="folder-quota-text">Loading…</span></div>
|
||||||
<div class="folder-quota-track"><span id="folder-quota-bar"></span></div>
|
<div class="folder-quota-track"><span id="folder-quota-bar"></span></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -592,6 +593,27 @@
|
|||||||
</div>
|
</div>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
||||||
|
<dialog id="storage-dialog" class="dialog" data-i18n-scope>
|
||||||
|
<div class="dialog-card storage-card">
|
||||||
|
<header class="dialog-header"><strong>Mailbox storage</strong><button type="button" class="icon-button close-dialog"
|
||||||
|
aria-label="Close"><svg>
|
||||||
|
<use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-x"></use>
|
||||||
|
</svg></button></header>
|
||||||
|
<section class="storage-overview">
|
||||||
|
<div class="storage-overview-head">
|
||||||
|
<div><span class="muted">Server quota</span><strong id="storage-quota-text">Loading…</strong></div>
|
||||||
|
<div class="storage-overview-total"><span class="muted">Measured folders</span><strong id="storage-measured-total">0.0 MB</strong></div>
|
||||||
|
</div>
|
||||||
|
<div class="storage-overview-track" aria-hidden="true"><span id="storage-quota-bar"></span></div>
|
||||||
|
</section>
|
||||||
|
<section class="storage-folders-section">
|
||||||
|
<div class="storage-folders-heading"><strong>Folders</strong><span class="muted">Share of measured folder storage</span></div>
|
||||||
|
<div id="storage-folder-list" class="storage-folder-list" aria-live="polite"></div>
|
||||||
|
</section>
|
||||||
|
<p class="storage-note muted">Folder sizes are calculated from message sizes reported by IMAP. Server quota can include additional data, so the totals may differ.</p>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
<dialog id="settings-dialog" class="dialog settings-dialog" data-i18n-scope>
|
<dialog id="settings-dialog" class="dialog settings-dialog" data-i18n-scope>
|
||||||
<div class="dialog-card settings-card">
|
<div class="dialog-card settings-card">
|
||||||
<header class="dialog-header"><strong>Settings</strong><button type="button" class="icon-button close-dialog"
|
<header class="dialog-header"><strong>Settings</strong><button type="button" class="icon-button close-dialog"
|
||||||
@@ -1044,7 +1066,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<p class="muted">Sent, Trash, Spam/Junk and Archive are used directly by mail actions. The spam destination
|
<p class="muted">Sent, Trash, Spam/Junk and Archive are used directly by mail actions. The spam destination
|
||||||
resolves in this order: account override → browser default → IMAP Junk/Spam auto-detection.</p>
|
resolves in this order: account override → browser default → IMAP Junk/Spam auto-detection.</p>
|
||||||
<div id="quota-summary" class="quota-summary"><strong>Mailbox storage</strong><span>Loading quota…</span>
|
<div id="quota-summary" class="quota-summary storage-open-trigger" role="button" tabindex="0"
|
||||||
|
title="View mailbox storage" aria-label="View mailbox storage"><strong>Mailbox storage</strong><span>Loading quota…</span>
|
||||||
<div class="quota-track"><span id="quota-bar"></span></div>
|
<div class="quota-track"><span id="quota-bar"></span></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="inline-actions"><button id="special-folders-save" class="button primary" type="button">Save
|
<div class="inline-actions"><button id="special-folders-save" class="button primary" type="button">Save
|
||||||
|
|||||||
@@ -224,9 +224,25 @@ function activeInterfaceStyle(prefs = state.prefs) {
|
|||||||
const skinDepths = ["colors", "standard", "immersive"];
|
const skinDepths = ["colors", "standard", "immersive"];
|
||||||
const presetColorPreferenceKeys = new Set([
|
const presetColorPreferenceKeys = new Set([
|
||||||
"colorPreset", "accentColor", "backgroundColor", "panelColor", "textColor", "selectionColor",
|
"colorPreset", "accentColor", "backgroundColor", "panelColor", "textColor", "selectionColor",
|
||||||
"folderBackground", "messageBackground", "readerBackground",
|
"folderBackground", "messageBackground", "readerBackground", "monochrome",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const adaptiveEmailAppearanceKeys = new Set([
|
||||||
|
"theme", "interfaceSkin", "skinDepth", "colorPreset", "backgroundColor", "panelColor",
|
||||||
|
"textColor", "readerBackground", "monochrome",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function refreshAdaptiveEmailPreviewForPreference(key) {
|
||||||
|
if (!adaptiveEmailAppearanceKeys.has(key) || !state.currentDetail || !state.prefs.adaptiveEmailColors) return;
|
||||||
|
cancelAnimationFrame(refreshAdaptiveEmailPreviewForPreference.frame || 0);
|
||||||
|
refreshAdaptiveEmailPreviewForPreference.frame = requestAnimationFrame(() => {
|
||||||
|
refreshAdaptiveEmailPreviewForPreference.frame = 0;
|
||||||
|
if (state.currentDetail && state.prefs.adaptiveEmailColors && typeof renderMessageDetail === "function") {
|
||||||
|
renderMessageDetail(state.currentDetail);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function resolvedSkinDepth(prefs = state.prefs) {
|
function resolvedSkinDepth(prefs = state.prefs) {
|
||||||
return skinDepths.includes(prefs.skinDepth) ? prefs.skinDepth : "standard";
|
return skinDepths.includes(prefs.skinDepth) ? prefs.skinDepth : "standard";
|
||||||
}
|
}
|
||||||
@@ -785,7 +801,10 @@ async function promotePresetToCustom(key, value) {
|
|||||||
const currentTheme = resolveTheme(state.prefs.theme);
|
const currentTheme = resolveTheme(state.prefs.theme);
|
||||||
const pinTheme = state.prefs.theme !== currentTheme;
|
const pinTheme = state.prefs.theme !== currentTheme;
|
||||||
const label = trText(paletteStyle.label);
|
const label = trText(paletteStyle.label);
|
||||||
const message = `${trText("Colours of a predefined style cannot be changed in place.")}\n\n${trText("RustBox will switch to its custom style with a copy of the current colours, layout and typography of")} “${label}”.${pinTheme ? `\n${trText("The theme will be fixed to the current mode")} (${trText(currentTheme === "light" ? "Light" : "Dark")}), ${trText("because the copied colours belong to it.")}` : ""}\n\n${trText("Shapes, fonts, app bar and gradients specific to the style are not part of the copy.")}`;
|
const copyReason = key === "monochrome"
|
||||||
|
? trText("Monochrome mode cannot be changed on a predefined style in place.")
|
||||||
|
: trText("Colours of a predefined style cannot be changed in place.");
|
||||||
|
const message = `${copyReason}\n\n${trText("RustBox will switch to its custom style with a copy of the current colours, layout and typography of")} “${label}”.${pinTheme ? `\n${trText("The theme will be fixed to the current mode")} (${trText(currentTheme === "light" ? "Light" : "Dark")}), ${trText("because the copied colours belong to it.")}` : ""}\n\n${trText("Shapes, fonts, app bar and gradients specific to the style are not part of the copy.")}`;
|
||||||
const confirmed = await appConfirm(message, { title: trText("Create a custom copy?"), confirmLabel: trText("Create custom copy") });
|
const confirmed = await appConfirm(message, { title: trText("Create a custom copy?"), confirmLabel: trText("Create custom copy") });
|
||||||
if (!confirmed) {
|
if (!confirmed) {
|
||||||
applyPreferences();
|
applyPreferences();
|
||||||
@@ -815,6 +834,7 @@ async function promotePresetToCustom(key, value) {
|
|||||||
if (["accentColor", "backgroundColor", "panelColor", "textColor"].includes(key)) state.prefs.colorPreset = "custom";
|
if (["accentColor", "backgroundColor", "panelColor", "textColor"].includes(key)) state.prefs.colorPreset = "custom";
|
||||||
presetPromotionActive = true;
|
presetPromotionActive = true;
|
||||||
applyPreferences();
|
applyPreferences();
|
||||||
|
refreshAdaptiveEmailPreviewForPreference(key);
|
||||||
schedulePromotedAppearanceSave();
|
schedulePromotedAppearanceSave();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -839,11 +859,13 @@ function savePreference(key, value) {
|
|||||||
if (presetPromotionActive && presetEditablePreferenceKeys.has(key)) {
|
if (presetPromotionActive && presetEditablePreferenceKeys.has(key)) {
|
||||||
if (["accentColor", "backgroundColor", "panelColor", "textColor"].includes(key)) state.prefs.colorPreset = "custom";
|
if (["accentColor", "backgroundColor", "panelColor", "textColor"].includes(key)) state.prefs.colorPreset = "custom";
|
||||||
applyPreferences();
|
applyPreferences();
|
||||||
|
refreshAdaptiveEmailPreviewForPreference(key);
|
||||||
schedulePromotedAppearanceSave();
|
schedulePromotedAppearanceSave();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (["accentColor", "backgroundColor", "panelColor", "textColor"].includes(key)) state.prefs.colorPreset = "custom";
|
if (["accentColor", "backgroundColor", "panelColor", "textColor"].includes(key)) state.prefs.colorPreset = "custom";
|
||||||
applyPreferences();
|
applyPreferences();
|
||||||
|
refreshAdaptiveEmailPreviewForPreference(key);
|
||||||
if (key === "language") loadTranslations(value).then(applyPreferences);
|
if (key === "language") loadTranslations(value).then(applyPreferences);
|
||||||
const previousTimer = preferenceTimers.get(key);
|
const previousTimer = preferenceTimers.get(key);
|
||||||
if (previousTimer) clearTimeout(previousTimer);
|
if (previousTimer) clearTimeout(previousTimer);
|
||||||
@@ -901,6 +923,13 @@ function formatBytes(value) {
|
|||||||
return `${(bytes / (1024 ** index)).toFixed(index ? 1 : 0)} ${units[index]}`;
|
return `${(bytes / (1024 ** index)).toFixed(index ? 1 : 0)} ${units[index]}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatMegabytes(value) {
|
||||||
|
const bytes = Number(value || 0);
|
||||||
|
if (!Number.isFinite(bytes) || bytes <= 0) return "0.0 MB";
|
||||||
|
const megabytes = bytes / (1024 ** 2);
|
||||||
|
return `${megabytes.toLocaleString(undefined, { minimumFractionDigits: 1, maximumFractionDigits: megabytes >= 1000 ? 0 : 1 })} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
function initial(value) {
|
function initial(value) {
|
||||||
return (value || "?").trim().charAt(0).toUpperCase() || "?";
|
return (value || "?").trim().charAt(0).toUpperCase() || "?";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -365,7 +365,10 @@ function renderMessages() {
|
|||||||
checkbox.addEventListener("change", () => setMessageSelected(message, checkbox.checked));
|
checkbox.addEventListener("change", () => setMessageSelected(message, checkbox.checked));
|
||||||
row.innerHTML = `<span class="message-state" aria-hidden="true"></span><div class="message-main"><span class="message-from"></span><time class="message-date">${escapeHtml(formatDate(message.date))}</time></div><div class="message-subject-line"><span class="message-subject"></span><span class="message-snippet"></span></div><span class="message-icons"></span>`;
|
row.innerHTML = `<span class="message-state" aria-hidden="true"></span><div class="message-main"><span class="message-from"></span><time class="message-date">${escapeHtml(formatDate(message.date))}</time></div><div class="message-subject-line"><span class="message-subject"></span><span class="message-snippet"></span></div><span class="message-icons"></span>`;
|
||||||
renderHighlightedText(row.querySelector(".message-from"), message.from_name || message.from_address || "Unknown", highlightTerms);
|
renderHighlightedText(row.querySelector(".message-from"), message.from_name || message.from_address || "Unknown", highlightTerms);
|
||||||
renderHighlightedText(row.querySelector(".message-subject"), message.subject || "(no subject)", highlightTerms);
|
const subjectText = message.subject || "(no subject)";
|
||||||
|
const subjectNode = row.querySelector(".message-subject");
|
||||||
|
renderHighlightedText(subjectNode, subjectText, highlightTerms);
|
||||||
|
subjectNode.title = subjectText;
|
||||||
renderHighlightedText(row.querySelector(".message-snippet"), state.mailPrefs.show_snippets !== false ? (message.snippet || "") : "", highlightTerms);
|
renderHighlightedText(row.querySelector(".message-snippet"), state.mailPrefs.show_snippets !== false ? (message.snippet || "") : "", highlightTerms);
|
||||||
const icons = row.querySelector(".message-icons");
|
const icons = row.querySelector(".message-icons");
|
||||||
if (message.has_attachments) {
|
if (message.has_attachments) {
|
||||||
@@ -395,17 +398,16 @@ function renderMessages() {
|
|||||||
const currentFlagged = normalizedMessageFlags(message.flags).includes("\\Flagged");
|
const currentFlagged = normalizedMessageFlags(message.flags).includes("\\Flagged");
|
||||||
quickUpdateMessageFlag(message, "\\Flagged", !currentFlagged, currentFlagged ? "Flag removed" : "Message flagged");
|
quickUpdateMessageFlag(message, "\\Flagged", !currentFlagged, currentFlagged ? "Flag removed" : "Message flagged");
|
||||||
});
|
});
|
||||||
const fullscreenButton = el("button", "message-quick-action message-fullscreen-action");
|
const newWindowButton = el("button", "message-quick-action message-fullscreen-action");
|
||||||
fullscreenButton.type = "button";
|
newWindowButton.type = "button";
|
||||||
fullscreenButton.title = trText("Open full screen");
|
newWindowButton.title = trText("Open in new window");
|
||||||
fullscreenButton.setAttribute("aria-label", fullscreenButton.title);
|
newWindowButton.setAttribute("aria-label", newWindowButton.title);
|
||||||
fullscreenButton.innerHTML = svgIcon("external-link");
|
newWindowButton.innerHTML = svgIcon("external-link");
|
||||||
fullscreenButton.addEventListener("click", async (event) => {
|
newWindowButton.addEventListener("click", (event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
await openMessage(folder, message.uid);
|
openMessageInPopup(folder, message.uid);
|
||||||
enterReaderFullscreen();
|
|
||||||
});
|
});
|
||||||
quick.append(readButton, flagButton, fullscreenButton);
|
quick.append(readButton, flagButton, newWindowButton);
|
||||||
icons.append(quick);
|
icons.append(quick);
|
||||||
if (Array.isArray(message.tags) && message.tags.length) {
|
if (Array.isArray(message.tags) && message.tags.length) {
|
||||||
const wrap = el("span", "message-tags");
|
const wrap = el("span", "message-tags");
|
||||||
@@ -506,7 +508,14 @@ function readerFullscreenUrl(detail = state.currentDetail) {
|
|||||||
|
|
||||||
// Ctrl/Cmd/Shift-click on a row outside selection mode.
|
// Ctrl/Cmd/Shift-click on a row outside selection mode.
|
||||||
function openMessageInNewTab(folder, uid) {
|
function openMessageInNewTab(folder, uid) {
|
||||||
window.open(messageDeepLinkUrl(folder, uid), "_blank", "noopener");
|
window.open(messageEmbedUrl(folder, uid), "_blank", "noopener");
|
||||||
|
}
|
||||||
|
|
||||||
|
function messageEmbedUrl(folder, uid) {
|
||||||
|
const url = messageDeepLinkUrl(folder, uid);
|
||||||
|
url.searchParams.delete("mail_fullscreen");
|
||||||
|
url.searchParams.set("mail_embed", "1");
|
||||||
|
return url;
|
||||||
}
|
}
|
||||||
|
|
||||||
function openMessageInPopup(folder, uid) {
|
function openMessageInPopup(folder, uid) {
|
||||||
@@ -517,7 +526,7 @@ function openMessageInPopup(folder, uid) {
|
|||||||
const features = `popup=1,noopener,width=${width},height=${height},left=${left},top=${top}`;
|
const features = `popup=1,noopener,width=${width},height=${height},left=${left},top=${top}`;
|
||||||
// A stable window name per message re-focuses the same popup on a second
|
// A stable window name per message re-focuses the same popup on a second
|
||||||
// shift-click instead of spawning duplicates, matching normal browser behavior.
|
// shift-click instead of spawning duplicates, matching normal browser behavior.
|
||||||
window.open(messageDeepLinkUrl(folder, uid), `rustbox-message-${folder}-${uid}`, features);
|
window.open(messageEmbedUrl(folder, uid), `rustbox-message-${folder}-${uid}`, features);
|
||||||
}
|
}
|
||||||
|
|
||||||
function restoreReaderScroll() {
|
function restoreReaderScroll() {
|
||||||
@@ -1015,6 +1024,23 @@ function adaptiveEmailReadableColor(color, background, preferred) {
|
|||||||
return adaptiveEmailContrast(white, background) >= adaptiveEmailContrast(black, background) ? white : black;
|
return adaptiveEmailContrast(white, background) >= adaptiveEmailContrast(black, background) ? white : black;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function adaptiveEmailTargetTheme(body) {
|
||||||
|
const rootStyles = getComputedStyle(root);
|
||||||
|
const bodyStyles = getComputedStyle(body);
|
||||||
|
const candidates = [
|
||||||
|
bodyStyles.backgroundColor,
|
||||||
|
rootStyles.getPropertyValue("--reader-bg"),
|
||||||
|
rootStyles.getPropertyValue("--panel"),
|
||||||
|
rootStyles.getPropertyValue("--bg"),
|
||||||
|
];
|
||||||
|
for (const raw of candidates) {
|
||||||
|
const color = parseAdaptiveEmailColor(raw);
|
||||||
|
if (!color || color.a < 0.5) continue;
|
||||||
|
return adaptiveEmailBrightness(color) < 0.5 ? "dark" : "light";
|
||||||
|
}
|
||||||
|
return ["dark", "light"].includes(root.dataset.theme) ? root.dataset.theme : resolveTheme(state.prefs.theme);
|
||||||
|
}
|
||||||
|
|
||||||
function adaptiveEmailThemePalette(body, targetTheme) {
|
function adaptiveEmailThemePalette(body, targetTheme) {
|
||||||
const targetDark = targetTheme === "dark";
|
const targetDark = targetTheme === "dark";
|
||||||
const rootStyles = getComputedStyle(root);
|
const rootStyles = getComputedStyle(root);
|
||||||
@@ -1096,7 +1122,7 @@ function messagePreviewBodyPresentation(html) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function adaptMessagePreviewColors(html, detail, body) {
|
function adaptMessagePreviewColors(html, detail, body) {
|
||||||
const targetTheme = ["dark", "light"].includes(root.dataset.theme) ? root.dataset.theme : resolveTheme(state.prefs.theme);
|
const targetTheme = adaptiveEmailTargetTheme(body);
|
||||||
const targetDark = targetTheme === "dark";
|
const targetDark = targetTheme === "dark";
|
||||||
const palette = adaptiveEmailThemePalette(body, targetTheme);
|
const palette = adaptiveEmailThemePalette(body, targetTheme);
|
||||||
const surface = palette.surface;
|
const surface = palette.surface;
|
||||||
|
|||||||
@@ -890,7 +890,7 @@ function importEmlToFolder(folderName) {
|
|||||||
input.click();
|
input.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
function showFolderProperties(folder) {
|
async function showFolderProperties(folder) {
|
||||||
const roles = Object.entries(state.specialFolders || {}).filter(([, value]) => value === folder.name).map(([key]) => key);
|
const roles = Object.entries(state.specialFolders || {}).filter(([, value]) => value === folder.name).map(([key]) => key);
|
||||||
const dialog = document.createElement("dialog");
|
const dialog = document.createElement("dialog");
|
||||||
dialog.className = "dialog";
|
dialog.className = "dialog";
|
||||||
@@ -909,10 +909,28 @@ function showFolderProperties(folder) {
|
|||||||
["Special role", roles.length ? roles.join(", ") : "None"],
|
["Special role", roles.length ? roles.join(", ") : "None"],
|
||||||
["Attributes", Array.isArray(folder.attributes) ? folder.attributes.join(" ") : String(folder.attributes || "None")],
|
["Attributes", Array.isArray(folder.attributes) ? folder.attributes.join(" ") : String(folder.attributes || "None")],
|
||||||
];
|
];
|
||||||
for (const [label, value] of rows) { const row = el("div", "folder-property-row"); row.append(el("span", "muted", label), el("strong", "", value)); body.append(row); }
|
for (const [label, value] of rows) {
|
||||||
|
const row = el("div", "folder-property-row");
|
||||||
|
const renderedValue = label === "Name" ? value : trText(value);
|
||||||
|
row.append(el("span", "muted", trText(label)), el("strong", "", renderedValue));
|
||||||
|
body.append(row);
|
||||||
|
}
|
||||||
|
const sizeRow = el("div", "folder-property-row");
|
||||||
|
const sizeValue = el("strong", "", folderCanReportSize(folder) ? trText("Measuring…") : trText("Unavailable"));
|
||||||
|
sizeRow.append(el("span", "muted", trText("Size (IMAP)")), sizeValue);
|
||||||
|
body.append(sizeRow);
|
||||||
card.append(header, body); dialog.append(card); document.body.append(dialog);
|
card.append(header, body); dialog.append(card); document.body.append(dialog);
|
||||||
dialog.addEventListener("close", () => dialog.remove(), { once: true });
|
dialog.addEventListener("close", () => dialog.remove(), { once: true });
|
||||||
dialog.showModal();
|
dialog.showModal();
|
||||||
|
|
||||||
|
if (!folderCanReportSize(folder)) return;
|
||||||
|
try {
|
||||||
|
const result = await getFolderSizeForUi(folder.name);
|
||||||
|
if (!dialog.isConnected) return;
|
||||||
|
sizeValue.textContent = result?.available ? formatMegabytes(result.sizeBytes) : trText("Unavailable");
|
||||||
|
} catch (_) {
|
||||||
|
if (dialog.isConnected) sizeValue.textContent = trText("Unavailable");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function markFolderAllRead(name) {
|
async function markFolderAllRead(name) {
|
||||||
|
|||||||
@@ -1,4 +1,136 @@
|
|||||||
|
|
||||||
|
const folderSizeUiCache = new Map();
|
||||||
|
const FOLDER_SIZE_UI_CACHE_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
function folderSizeCacheKey(name) {
|
||||||
|
return `${state.accountId || ""}\u0000${name}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function folderCanReportSize(folder) {
|
||||||
|
return !(folder?.attributes || []).some((attribute) => String(attribute).trim().toLowerCase() === "\\noselect");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getFolderSizeForUi(name, { force = false } = {}) {
|
||||||
|
const key = folderSizeCacheKey(name);
|
||||||
|
const cached = folderSizeUiCache.get(key);
|
||||||
|
if (!force && cached && Date.now() - cached.at < FOLDER_SIZE_UI_CACHE_MS) return cached;
|
||||||
|
const data = await request(`/folders/${encodeURIComponent(name)}/size`, { method: "GET" });
|
||||||
|
const result = {
|
||||||
|
available: data?.available === true && Number.isFinite(Number(data?.size_bytes)),
|
||||||
|
sizeBytes: data?.available === true && Number.isFinite(Number(data?.size_bytes)) ? Math.max(0, Number(data.size_bytes)) : null,
|
||||||
|
at: Date.now(),
|
||||||
|
};
|
||||||
|
folderSizeUiCache.set(key, result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStorageFolderShares(list) {
|
||||||
|
const rows = [...list.querySelectorAll(".storage-folder-row")];
|
||||||
|
const available = rows.filter((row) => row.dataset.state === "ready");
|
||||||
|
const total = available.reduce((sum, row) => sum + Math.max(0, Number(row.dataset.sizeBytes || 0)), 0);
|
||||||
|
const measured = $("#storage-measured-total");
|
||||||
|
if (measured) measured.textContent = available.length ? formatMegabytes(total) : trText("Measuring…");
|
||||||
|
for (const row of available) {
|
||||||
|
const size = Math.max(0, Number(row.dataset.sizeBytes || 0));
|
||||||
|
const percent = total > 0 ? (size / total) * 100 : 0;
|
||||||
|
const bar = row.querySelector(".storage-folder-track span");
|
||||||
|
const value = row.querySelector(".storage-folder-value");
|
||||||
|
if (bar) bar.style.width = `${Math.max(0, Math.min(100, percent))}%`;
|
||||||
|
if (value) value.textContent = `${formatMegabytes(size)} · ${percent.toFixed(1)}%`;
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStorageQuota(quota) {
|
||||||
|
const text = $("#storage-quota-text");
|
||||||
|
const bar = $("#storage-quota-bar");
|
||||||
|
const used = Number(quota?.usage_bytes || 0);
|
||||||
|
const limit = Number(quota?.limit_bytes || 0);
|
||||||
|
if (limit > 0) {
|
||||||
|
const percent = Math.max(0, Math.min(100, (used / limit) * 100));
|
||||||
|
if (text) text.textContent = `${formatMegabytes(used)} / ${formatMegabytes(limit)} · ${percent.toFixed(1)}%`;
|
||||||
|
if (bar) bar.style.width = `${percent}%`;
|
||||||
|
} else if (Number.isFinite(used) && used > 0) {
|
||||||
|
if (text) text.textContent = `${formatMegabytes(used)} ${tr("used", "used")}`;
|
||||||
|
if (bar) bar.style.width = "0%";
|
||||||
|
} else {
|
||||||
|
if (text) text.textContent = tr("quota_unavailable", "Quota unavailable");
|
||||||
|
if (bar) bar.style.width = "0%";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openMailboxStorageDialog() {
|
||||||
|
const dialog = $("#storage-dialog");
|
||||||
|
const list = $("#storage-folder-list");
|
||||||
|
if (!dialog || !list) return;
|
||||||
|
openMailboxStorageDialog.sequence = (openMailboxStorageDialog.sequence || 0) + 1;
|
||||||
|
const sequence = openMailboxStorageDialog.sequence;
|
||||||
|
list.replaceChildren();
|
||||||
|
$("#storage-measured-total").textContent = trText("Measuring…");
|
||||||
|
$("#storage-quota-text").textContent = trText("Loading…");
|
||||||
|
$("#storage-quota-bar").style.width = "0%";
|
||||||
|
if (!dialog.open) dialog.showModal();
|
||||||
|
|
||||||
|
const folders = orderedFolders().filter(folderCanReportSize);
|
||||||
|
if (!folders.length) {
|
||||||
|
list.append(el("div", "empty-list", trText("No folders available")));
|
||||||
|
$("#storage-measured-total").textContent = "0.0 MB";
|
||||||
|
}
|
||||||
|
|
||||||
|
const rowByName = new Map();
|
||||||
|
for (const folder of folders) {
|
||||||
|
const row = el("div", "storage-folder-row");
|
||||||
|
row.dataset.state = "pending";
|
||||||
|
row.dataset.folder = folder.name;
|
||||||
|
const name = el("div", "storage-folder-name");
|
||||||
|
name.innerHTML = `${svgIcon(folderIconName(folder))}<span title="${escapeHtml(folder.name)}">${escapeHtml(folder.name)}</span>`;
|
||||||
|
const value = el("span", "storage-folder-value", trText("Measuring…"));
|
||||||
|
const track = el("div", "storage-folder-track");
|
||||||
|
track.append(el("span"));
|
||||||
|
row.append(name, value, track);
|
||||||
|
list.append(row);
|
||||||
|
rowByName.set(folder.name, row);
|
||||||
|
}
|
||||||
|
|
||||||
|
request("/quota", { method: "GET" })
|
||||||
|
.then((quota) => { if (sequence === openMailboxStorageDialog.sequence && dialog.open) renderStorageQuota(quota); })
|
||||||
|
.catch(() => { if (sequence === openMailboxStorageDialog.sequence && dialog.open) renderStorageQuota(null); });
|
||||||
|
|
||||||
|
let cursor = 0;
|
||||||
|
const worker = async () => {
|
||||||
|
while (cursor < folders.length) {
|
||||||
|
const folder = folders[cursor++];
|
||||||
|
const row = rowByName.get(folder.name);
|
||||||
|
let result = null;
|
||||||
|
try { result = await getFolderSizeForUi(folder.name); } catch (_) { result = null; }
|
||||||
|
if (sequence !== openMailboxStorageDialog.sequence || !dialog.open) return;
|
||||||
|
if (result?.available) {
|
||||||
|
row.dataset.state = "ready";
|
||||||
|
row.dataset.sizeBytes = String(result.sizeBytes || 0);
|
||||||
|
} else {
|
||||||
|
row.dataset.state = "unavailable";
|
||||||
|
row.querySelector(".storage-folder-value").textContent = trText("Unavailable");
|
||||||
|
}
|
||||||
|
updateStorageFolderShares(list);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
await Promise.all(Array.from({ length: Math.min(2, folders.length) }, worker));
|
||||||
|
if (sequence !== openMailboxStorageDialog.sequence || !dialog.open) return;
|
||||||
|
|
||||||
|
const rows = [...list.querySelectorAll(".storage-folder-row")];
|
||||||
|
rows.sort((a, b) => {
|
||||||
|
const availableA = a.dataset.state === "ready";
|
||||||
|
const availableB = b.dataset.state === "ready";
|
||||||
|
if (availableA !== availableB) return availableA ? -1 : 1;
|
||||||
|
const sizeDiff = Number(b.dataset.sizeBytes || 0) - Number(a.dataset.sizeBytes || 0);
|
||||||
|
return sizeDiff || String(a.dataset.folder || "").localeCompare(String(b.dataset.folder || ""));
|
||||||
|
});
|
||||||
|
rows.forEach((row) => list.append(row));
|
||||||
|
const total = updateStorageFolderShares(list);
|
||||||
|
if (!rows.some((row) => row.dataset.state === "ready")) $("#storage-measured-total").textContent = trText("Unavailable");
|
||||||
|
else $("#storage-measured-total").textContent = formatMegabytes(total);
|
||||||
|
}
|
||||||
|
|
||||||
async function loadQuota() {
|
async function loadQuota() {
|
||||||
const summary = $("#quota-summary span");
|
const summary = $("#quota-summary span");
|
||||||
const bar = $("#quota-bar");
|
const bar = $("#quota-bar");
|
||||||
|
|||||||
@@ -387,6 +387,10 @@ function bindEvents() {
|
|||||||
localStorage.setItem("rustbox.threadExpanded", String(state.threadExpanded));
|
localStorage.setItem("rustbox.threadExpanded", String(state.threadExpanded));
|
||||||
if (state.currentDetail) renderThread(state.currentDetail);
|
if (state.currentDetail) renderThread(state.currentDetail);
|
||||||
});
|
});
|
||||||
|
$("#folder-quota").addEventListener("click", openMailboxStorageDialog);
|
||||||
|
$("#folder-quota").addEventListener("keydown", (event) => { if (["Enter", " "].includes(event.key)) { event.preventDefault(); openMailboxStorageDialog(); } });
|
||||||
|
$("#quota-summary").addEventListener("click", openMailboxStorageDialog);
|
||||||
|
$("#quota-summary").addEventListener("keydown", (event) => { if (["Enter", " "].includes(event.key)) { event.preventDefault(); openMailboxStorageDialog(); } });
|
||||||
$("#folder-manage-btn").addEventListener("click", openFolderDialog);
|
$("#folder-manage-btn").addEventListener("click", openFolderDialog);
|
||||||
$("#create-folder-btn").addEventListener("click", createFolder);
|
$("#create-folder-btn").addEventListener("click", createFolder);
|
||||||
$("#rename-folder-btn").addEventListener("click", renameFolder);
|
$("#rename-folder-btn").addEventListener("click", renameFolder);
|
||||||
@@ -421,7 +425,7 @@ function bindEvents() {
|
|||||||
$("#pgp-import-file-input").addEventListener("change", async (event) => { const file = event.target.files?.[0]; if (file) await importPgpFile(file); event.target.value = ""; });
|
$("#pgp-import-file-input").addEventListener("change", async (event) => { const file = event.target.files?.[0]; if (file) await importPgpFile(file); event.target.value = ""; });
|
||||||
$("#pgp-discover").addEventListener("click", () => discoverPgpEmail());
|
$("#pgp-discover").addEventListener("click", () => discoverPgpEmail());
|
||||||
$$(".settings-tab").forEach((button) => button.addEventListener("click", () => { selectSettingsTab(button.dataset.tab); if (button.dataset.tab === "sieve") loadSieve(); if (button.dataset.tab === "identities") { loadIdentities(); if (state.authPolicy.pgpEnabled !== false) loadOpenPgp(); } if (button.dataset.tab === "openpgp" && state.authPolicy.pgpEnabled !== false) loadOpenPgp(); if (button.dataset.tab === "notifications") loadNotificationPreferences(); if (button.dataset.tab === "mailbox") loadMailPreferences(); if (button.dataset.tab === "composing") loadResponseTemplates(); if (button.dataset.tab === "diagnostics") loadDiagnostics(); if (button.dataset.tab === "account") { renderAccountSettings(); loadActiveSessions(); } if (button.dataset.tab === "folders") { loadSpecialFolders(); loadQuota(); } }));
|
$$(".settings-tab").forEach((button) => button.addEventListener("click", () => { selectSettingsTab(button.dataset.tab); if (button.dataset.tab === "sieve") loadSieve(); if (button.dataset.tab === "identities") { loadIdentities(); if (state.authPolicy.pgpEnabled !== false) loadOpenPgp(); } if (button.dataset.tab === "openpgp" && state.authPolicy.pgpEnabled !== false) loadOpenPgp(); if (button.dataset.tab === "notifications") loadNotificationPreferences(); if (button.dataset.tab === "mailbox") loadMailPreferences(); if (button.dataset.tab === "composing") loadResponseTemplates(); if (button.dataset.tab === "diagnostics") loadDiagnostics(); if (button.dataset.tab === "account") { renderAccountSettings(); loadActiveSessions(); } if (button.dataset.tab === "folders") { loadSpecialFolders(); loadQuota(); } }));
|
||||||
$("#pref-theme").addEventListener("change", (event) => { savePreference("theme", event.target.value); if (state.currentDetail && state.prefs.adaptiveEmailColors) renderMessageDetail(state.currentDetail); });
|
$("#pref-theme").addEventListener("change", (event) => savePreference("theme", event.target.value));
|
||||||
$("#pref-interface-skin").addEventListener("change", (event) => savePreference("interfaceSkin", event.target.value));
|
$("#pref-interface-skin").addEventListener("change", (event) => savePreference("interfaceSkin", event.target.value));
|
||||||
$("#pref-skin-depth").addEventListener("change", (event) => savePreference("skinDepth", event.target.value));
|
$("#pref-skin-depth").addEventListener("change", (event) => savePreference("skinDepth", event.target.value));
|
||||||
$("#pref-skin-gradients").addEventListener("change", (event) => savePreference("skinGradients", event.target.checked));
|
$("#pref-skin-gradients").addEventListener("change", (event) => savePreference("skinGradients", event.target.checked));
|
||||||
|
|||||||
@@ -5,4 +5,5 @@
|
|||||||
? (matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark")
|
? (matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark")
|
||||||
: configured;
|
: configured;
|
||||||
root.dataset.interfaceSkin = root.dataset.defaultInterfaceSkin || "rustbox";
|
root.dataset.interfaceSkin = root.dataset.defaultInterfaceSkin || "rustbox";
|
||||||
|
if (new URLSearchParams(location.search).get("mail_embed") === "1") root.dataset.mailEmbed = "true";
|
||||||
})();
|
})();
|
||||||
Reference in new issue
Block a user