1 parent
53f4812adc
commit
20d78ee7be
20 files changed
+545
-52
No files matched your search
@@ -1373,6 +1373,9 @@ impl ImapClient for RealImapClient {
|
||||
}
|
||||
Err(_) => {
|
||||
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(),
|
||||
}
|
||||
|
||||
@@ -582,6 +582,7 @@ pub fn create_router_with_storage(config: Arc<AppConfig>, services: RouterServic
|
||||
delete(calendar::delete_meeting_template),
|
||||
)
|
||||
.route("/quota", get(quota::get_quota))
|
||||
.route("/folders/{name}/size", get(quota::get_folder_size))
|
||||
.route("/settings/reset", post(settings::reset_all_settings));
|
||||
|
||||
let diagnostics_routes = Router::new()
|
||||
|
||||
+42
-8
@@ -1,6 +1,7 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::extract::Path;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::{Extension, Json};
|
||||
use serde::Serialize;
|
||||
@@ -11,6 +12,46 @@ use crate::db;
|
||||
use crate::error::AppError;
|
||||
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)]
|
||||
struct QuotaResponse {
|
||||
usage_bytes: Option<u64>,
|
||||
@@ -23,14 +64,7 @@ pub async fn get_quota(
|
||||
Extension(config): Extension<Arc<AppConfig>>,
|
||||
Extension(imap_client): Extension<Arc<dyn ImapClient>>,
|
||||
) -> Result<Response, AppError> {
|
||||
let creds = 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(),
|
||||
};
|
||||
let creds = session_credentials(&session);
|
||||
|
||||
let quota = imap_client.get_quota(&creds).await.unwrap_or(None);
|
||||
|
||||
|
||||
+43
-4
@@ -882,8 +882,9 @@
|
||||
}
|
||||
|
||||
.message-icons {
|
||||
position: relative;
|
||||
align-items: center;
|
||||
min-width: 40px;
|
||||
min-width: 0;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
@@ -894,11 +895,20 @@
|
||||
}
|
||||
|
||||
.message-quick-actions {
|
||||
position: absolute;
|
||||
right: calc(100% + 4px);
|
||||
top: 50%;
|
||||
z-index: 2;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 1px;
|
||||
padding: 1px;
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--hover);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: translateY(-50%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.message-row:hover .message-quick-actions,
|
||||
@@ -1327,6 +1337,36 @@ body.reader-fullscreen-open {
|
||||
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 {
|
||||
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) {
|
||||
html[data-compact-wide-rows="true"] .message-row {
|
||||
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);
|
||||
column-gap: 8px;
|
||||
row-gap: 0;
|
||||
@@ -1476,7 +1515,7 @@ body.reader-fullscreen-open {
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
+147
-5
@@ -1565,6 +1565,150 @@
|
||||
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 {
|
||||
width: min(520px, calc(100vw - 28px));
|
||||
}
|
||||
@@ -2686,6 +2830,7 @@ kbd {
|
||||
justify-content: flex-end;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.multi-account-notifications.hidden {
|
||||
display: none;
|
||||
}
|
||||
@@ -2699,8 +2844,6 @@ kbd {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
|
||||
/* Theme schedule, account colour, contrast hint, list skeleton, theme cross-fade. */
|
||||
.theme-schedule-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
@@ -2716,7 +2859,7 @@ kbd {
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.account-color-row > span {
|
||||
.account-color-row>span {
|
||||
margin-bottom: 5px;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
@@ -2748,7 +2891,6 @@ kbd {
|
||||
grid-column: 1 / -1;
|
||||
margin: 0;
|
||||
padding: 6px 9px;
|
||||
/* Fixed colours: must stay readable with any custom palette. */
|
||||
border: 1px solid #d9a93a;
|
||||
border-radius: var(--radius-control);
|
||||
background: #fff4d6;
|
||||
@@ -2855,4 +2997,4 @@ html[data-reduce-motion="true"] :is(.skeleton-dot, .skeleton-line) {
|
||||
html.theme-transition * {
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
+16
-1
@@ -1279,6 +1279,21 @@
|
||||
"private + public": "prywatny + publiczny",
|
||||
"public": "publiczny",
|
||||
"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"
|
||||
},
|
||||
"fixed_interface_style_note": "gotowy styl interfejsu. Włącz edycję poniżej, aby dopasować jego układ i typografię.",
|
||||
@@ -1310,4 +1325,4 @@
|
||||
"account_color_note": "Pasek u góry aplikacji i kropka przy adresie pokazują, które konto jest aktywne.",
|
||||
"contrast_warning": "Niski kontrast",
|
||||
"edited_interface_style_note": "gotowy styl interfejsu z Twoim układem. Zmiana koloru tworzy własną kopię RustBox."
|
||||
}
|
||||
}
|
||||
+25
-2
@@ -120,7 +120,8 @@
|
||||
<use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-plus"></use>
|
||||
</svg></button></div>
|
||||
<nav id="tag-list" class="folder-list tag-list" aria-label="Message labels"></nav>
|
||||
<div 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-track"><span id="folder-quota-bar"></span></div>
|
||||
</div>
|
||||
@@ -592,6 +593,27 @@
|
||||
</div>
|
||||
</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>
|
||||
<div class="dialog-card settings-card">
|
||||
<header class="dialog-header"><strong>Settings</strong><button type="button" class="icon-button close-dialog"
|
||||
@@ -1044,7 +1066,8 @@
|
||||
</div>
|
||||
<p class="muted">Sent, Trash, Spam/Junk and Archive are used directly by mail actions. The spam destination
|
||||
resolves in this order: account override → browser default → IMAP Junk/Spam auto-detection.</p>
|
||||
<div id="quota-summary" class="quota-summary"><strong>Mailbox storage</strong><span>Loading quota…</span>
|
||||
<div 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>
|
||||
<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 presetColorPreferenceKeys = new Set([
|
||||
"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) {
|
||||
return skinDepths.includes(prefs.skinDepth) ? prefs.skinDepth : "standard";
|
||||
}
|
||||
@@ -785,7 +801,10 @@ async function promotePresetToCustom(key, value) {
|
||||
const currentTheme = resolveTheme(state.prefs.theme);
|
||||
const pinTheme = state.prefs.theme !== currentTheme;
|
||||
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") });
|
||||
if (!confirmed) {
|
||||
applyPreferences();
|
||||
@@ -815,6 +834,7 @@ async function promotePresetToCustom(key, value) {
|
||||
if (["accentColor", "backgroundColor", "panelColor", "textColor"].includes(key)) state.prefs.colorPreset = "custom";
|
||||
presetPromotionActive = true;
|
||||
applyPreferences();
|
||||
refreshAdaptiveEmailPreviewForPreference(key);
|
||||
schedulePromotedAppearanceSave();
|
||||
return true;
|
||||
}
|
||||
@@ -839,11 +859,13 @@ function savePreference(key, value) {
|
||||
if (presetPromotionActive && presetEditablePreferenceKeys.has(key)) {
|
||||
if (["accentColor", "backgroundColor", "panelColor", "textColor"].includes(key)) state.prefs.colorPreset = "custom";
|
||||
applyPreferences();
|
||||
refreshAdaptiveEmailPreviewForPreference(key);
|
||||
schedulePromotedAppearanceSave();
|
||||
return;
|
||||
}
|
||||
if (["accentColor", "backgroundColor", "panelColor", "textColor"].includes(key)) state.prefs.colorPreset = "custom";
|
||||
applyPreferences();
|
||||
refreshAdaptiveEmailPreviewForPreference(key);
|
||||
if (key === "language") loadTranslations(value).then(applyPreferences);
|
||||
const previousTimer = preferenceTimers.get(key);
|
||||
if (previousTimer) clearTimeout(previousTimer);
|
||||
@@ -901,6 +923,13 @@ function formatBytes(value) {
|
||||
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) {
|
||||
return (value || "?").trim().charAt(0).toUpperCase() || "?";
|
||||
}
|
||||
|
||||
@@ -365,7 +365,10 @@ function renderMessages() {
|
||||
checkbox.addEventListener("change", () => setMessageSelected(message, checkbox.checked));
|
||||
row.innerHTML = `<span class="message-state" aria-hidden="true"></span><div class="message-main"><span class="message-from"></span><time class="message-date">${escapeHtml(formatDate(message.date))}</time></div><div class="message-subject-line"><span class="message-subject"></span><span class="message-snippet"></span></div><span class="message-icons"></span>`;
|
||||
renderHighlightedText(row.querySelector(".message-from"), message.from_name || message.from_address || "Unknown", highlightTerms);
|
||||
renderHighlightedText(row.querySelector(".message-subject"), message.subject || "(no subject)", highlightTerms);
|
||||
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);
|
||||
const icons = row.querySelector(".message-icons");
|
||||
if (message.has_attachments) {
|
||||
@@ -395,17 +398,16 @@ function renderMessages() {
|
||||
const currentFlagged = normalizedMessageFlags(message.flags).includes("\\Flagged");
|
||||
quickUpdateMessageFlag(message, "\\Flagged", !currentFlagged, currentFlagged ? "Flag removed" : "Message flagged");
|
||||
});
|
||||
const fullscreenButton = el("button", "message-quick-action message-fullscreen-action");
|
||||
fullscreenButton.type = "button";
|
||||
fullscreenButton.title = trText("Open full screen");
|
||||
fullscreenButton.setAttribute("aria-label", fullscreenButton.title);
|
||||
fullscreenButton.innerHTML = svgIcon("external-link");
|
||||
fullscreenButton.addEventListener("click", async (event) => {
|
||||
const newWindowButton = el("button", "message-quick-action message-fullscreen-action");
|
||||
newWindowButton.type = "button";
|
||||
newWindowButton.title = trText("Open in new window");
|
||||
newWindowButton.setAttribute("aria-label", newWindowButton.title);
|
||||
newWindowButton.innerHTML = svgIcon("external-link");
|
||||
newWindowButton.addEventListener("click", (event) => {
|
||||
event.stopPropagation();
|
||||
await openMessage(folder, message.uid);
|
||||
enterReaderFullscreen();
|
||||
openMessageInPopup(folder, message.uid);
|
||||
});
|
||||
quick.append(readButton, flagButton, fullscreenButton);
|
||||
quick.append(readButton, flagButton, newWindowButton);
|
||||
icons.append(quick);
|
||||
if (Array.isArray(message.tags) && message.tags.length) {
|
||||
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.
|
||||
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) {
|
||||
@@ -517,7 +526,7 @@ function openMessageInPopup(folder, uid) {
|
||||
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
|
||||
// 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() {
|
||||
@@ -1015,6 +1024,23 @@ function adaptiveEmailReadableColor(color, background, preferred) {
|
||||
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) {
|
||||
const targetDark = targetTheme === "dark";
|
||||
const rootStyles = getComputedStyle(root);
|
||||
@@ -1096,7 +1122,7 @@ function messagePreviewBodyPresentation(html) {
|
||||
}
|
||||
|
||||
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 palette = adaptiveEmailThemePalette(body, targetTheme);
|
||||
const surface = palette.surface;
|
||||
|
||||
@@ -890,7 +890,7 @@ function importEmlToFolder(folderName) {
|
||||
input.click();
|
||||
}
|
||||
|
||||
function showFolderProperties(folder) {
|
||||
async function showFolderProperties(folder) {
|
||||
const roles = Object.entries(state.specialFolders || {}).filter(([, value]) => value === folder.name).map(([key]) => key);
|
||||
const dialog = document.createElement("dialog");
|
||||
dialog.className = "dialog";
|
||||
@@ -909,10 +909,28 @@ function showFolderProperties(folder) {
|
||||
["Special role", roles.length ? roles.join(", ") : "None"],
|
||||
["Attributes", Array.isArray(folder.attributes) ? folder.attributes.join(" ") : String(folder.attributes || "None")],
|
||||
];
|
||||
for (const [label, value] of rows) { const row = el("div", "folder-property-row"); row.append(el("span", "muted", label), el("strong", "", value)); body.append(row); }
|
||||
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);
|
||||
dialog.addEventListener("close", () => dialog.remove(), { once: true });
|
||||
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) {
|
||||
|
||||
@@ -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() {
|
||||
const summary = $("#quota-summary span");
|
||||
const bar = $("#quota-bar");
|
||||
|
||||
@@ -387,6 +387,10 @@ function bindEvents() {
|
||||
localStorage.setItem("rustbox.threadExpanded", String(state.threadExpanded));
|
||||
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);
|
||||
$("#create-folder-btn").addEventListener("click", createFolder);
|
||||
$("#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-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(); } }));
|
||||
$("#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-skin-depth").addEventListener("change", (event) => savePreference("skinDepth", event.target.value));
|
||||
$("#pref-skin-gradients").addEventListener("change", (event) => savePreference("skinGradients", event.target.checked));
|
||||
|
||||
@@ -5,4 +5,5 @@
|
||||
? (matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark")
|
||||
: configured;
|
||||
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