v0.2.27
RustBox CI / rustbox (push) Canceled after 0s

This commit is contained in:
Mateusz Gruszczyński committed 2026-11-06 11:15:43 +01:00
1 parent 53f4812adc
commit 20d78ee7be
20 files changed
+545 -52

No files matched your search

+3
View File
@@ -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(),
}
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+31 -2
View File
@@ -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() || "?";
}
+39 -13
View File
@@ -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;
+20 -2
View File
@@ -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) {
+132
View File
@@ -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");
+5 -1
View File
@@ -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));
+1
View File
@@ -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";
})();