From ad8981e38d6ecdaeee605f93fb3305c5306019a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Mon, 9 Nov 2026 14:52:54 +0100 Subject: [PATCH] 0.2.33 --- CHANGELOG.md | 4 ++ Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 2 +- RELEASE-MANIFEST.txt | 9 ++- RELEASE-VALIDATION.txt | 9 ++- docs/IMPLEMENTATION_STATUS.md | 10 +++- src/web/css/20-shell.css | 87 +++++++++++++++++++++++++++ src/web/i18n/pl.json | 2 + src/web/icons.svg | 1 + src/web/index.html | 9 +++ src/web/js/00-bootstrap.js | 1 + src/web/js/20-preferences-auth.js | 12 +++- src/web/js/80-settings-realtime.js | 95 ++++++++++++++++++++++++++++++ 14 files changed, 233 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d6f4158..503434d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ What changed in each RustBox release, written for the people who use and run it. Technical details are in the [documentation](docs/). +## 0.2.33 + +- Added a session notification bell for mail arriving on other signed-in accounts. The bell stays inactive when there are no pending notifications; when active, its dropdown lists the account, message subject/sender and time, and selecting an item switches to that account and opens the message. Notifications are cleared when the account is opened and the list is bounded so it cannot grow indefinitely. + ## 0.2.32 - Message composer formatting now preserves the selected text when the toolbar is clicked. Bold, italic, underline and the other formatting controls apply to the original selection instead of collapsing it; link, font, size and text colour use the same preserved range. diff --git a/Cargo.lock b/Cargo.lock index 24673bb..40fc966 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3534,7 +3534,7 @@ dependencies = [ [[package]] name = "rustbox" -version = "0.2.32" +version = "0.2.33" dependencies = [ "aes-gcm", "async-imap", diff --git a/Cargo.toml b/Cargo.toml index c583d43..23daf07 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustbox" -version = "0.2.32" +version = "0.2.33" edition = "2024" rust-version = "1.99.1" license = "AGPL-3.0-only" diff --git a/README.md b/README.md index a88c9fc..2f32fa4 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# RustBox 0.2.32 +# RustBox 0.2.33 **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. diff --git a/RELEASE-MANIFEST.txt b/RELEASE-MANIFEST.txt index 2c5bd9b..0842736 100644 --- a/RELEASE-MANIFEST.txt +++ b/RELEASE-MANIFEST.txt @@ -1,4 +1,7 @@ -RustBox 0.2.32 release manifest +RustBox 0.2.33 release manifest + +0.2.33 release changes: +- session notification bell collects new-mail events from other signed-in accounts, remains non-interactive when empty, shows a bounded dropdown with account/message details when notifications exist, and switches to the source account plus opens the selected message on click 0.2.32 release changes: - composer formatting toolbar preserves the active DOM selection before toolbar focus changes and restores it before applying formatting; bold/italic/underline and the remaining commands, links, font, size and colour now target the text the user selected @@ -143,14 +146,14 @@ Docker examples: Operations/docs: - standalone `scripts/migrate_roundcube.py` imports Roundcube contacts/identities using RustBox config.toml and supports SQLite/MySQL/PostgreSQL targets -- README.md documents 0.2.32 configuration +- README.md documents 0.2.33 configuration - docker/README.md documents Compose variants - 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 Build/release metadata: - `scripts/package-release.sh` runs the full Rust release gate before creating ZIP/SHA256 artifacts -- Cargo.toml version = 0.2.32 +- Cargo.toml version = 0.2.33 - rust-toolchain.toml pins Rust 1.99.1 - 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 diff --git a/RELEASE-VALIDATION.txt b/RELEASE-VALIDATION.txt index 3c3fbfb..26d1759 100644 --- a/RELEASE-VALIDATION.txt +++ b/RELEASE-VALIDATION.txt @@ -1,4 +1,7 @@ -RustBox 0.2.32 - final source validation +RustBox 0.2.33 - final source validation + +0.2.33 scope: +- session notification bell for new mail on other signed-in accounts; empty state is disabled, active state lists pending messages, and selecting an item switches accounts and opens that message 0.2.32 scope: - composer toolbar keeps the selected DOM Range stable across toolbar focus changes, so bold/italic/underline and the remaining formatting controls apply to the intended selection @@ -77,7 +80,7 @@ RustBox 0.2.32 - final source validation - 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 - V047-V052 are consolidated into compatibility baseline V052; V053 adds `skin_depth` and `skin_gradients`; V054 adds the theme schedule and account colour; V055 adds the CalDAV/CardDAV columns (`dav_name`, `dav_data`) and a non-unique contacts e-mail index; V056 is next -- version metadata identifies 0.2.32 +- version metadata identifies 0.2.33 Completed in this environment: - offline release validator: scripts/validate-release-static.py (468/468 checks passed) @@ -88,7 +91,7 @@ Completed in this environment: - all JSON files parse successfully - Python helper scripts compile successfully - shell scripts pass `bash -n` -- release metadata consistently identifies 0.2.32 +- release metadata consistently identifies 0.2.33 - 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 diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 3c9a387..51ea644 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,7 +1,13 @@ -# RustBox 0.2.32 - implementation status +# RustBox 0.2.33 - implementation status - 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.32` 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.33` 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.33 session notifications across signed-in accounts + +- The top-bar notification bell collects realtime new-mail events from other accounts that are active in the same browser session and enabled for combined notifications. +- With no pending items the bell is visually present but non-interactive. With pending items it shows a badge and dropdown containing the source account, subject/sender and arrival time. +- Selecting a notification removes it from the session list, switches to the source account and opens the target message. Opening/switching to an account clears its pending session notifications. The in-memory list is capped to prevent unbounded growth. ## 0.2.32 composer selection formatting diff --git a/src/web/css/20-shell.css b/src/web/css/20-shell.css index d6f29b0..3507210 100644 --- a/src/web/css/20-shell.css +++ b/src/web/css/20-shell.css @@ -405,3 +405,90 @@ background: var(--selected); color: var(--accent); } + +.session-notifications > summary { + position: relative; +} + +.session-notifications > summary::after { + display: none; +} + +.session-notifications.is-empty > summary { + opacity: .42; + cursor: default; + pointer-events: none; +} + +.session-notifications-badge { + position: absolute; + top: 1px; + right: 0; + min-width: 13px; + height: 13px; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0 3px; + border: 1px solid var(--panel); + border-radius: 999px; + background: var(--accent); + color: var(--accent-ink); + font-size: 8px; + font-weight: 700; + line-height: 1; +} + +#session-notifications-menu { + width: min(360px, calc(100vw - 16px)); + max-height: min(420px, calc(100dvh - 64px)); + overflow-y: auto; +} + +#session-notifications-menu button.session-notification-item { + display: grid; + gap: 3px; + padding: 8px 9px; + white-space: normal; +} + +.session-notification-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 10px; +} + +.session-notification-account, +.session-notification-subject, +.session-notification-meta { + overflow: hidden; + text-overflow: ellipsis; +} + +.session-notification-account { + color: var(--text); + font-weight: 650; + white-space: nowrap; +} + +.session-notification-time, +.session-notification-meta { + color: var(--muted); + font-size: 10px; +} + +.session-notification-time { + flex: 0 0 auto; +} + +.session-notification-subject { + color: var(--text); + font-size: 11px; + font-weight: 600; + white-space: nowrap; +} + +.session-notification-meta { + white-space: nowrap; +} diff --git a/src/web/i18n/pl.json b/src/web/i18n/pl.json index 71c36d9..87fdde7 100644 --- a/src/web/i18n/pl.json +++ b/src/web/i18n/pl.json @@ -112,6 +112,8 @@ "Composing": "Redagowanie", "Identities & signatures": "Tożsamości i podpisy", "Notifications": "Powiadomienia", + "Notifications from other accounts": "Powiadomienia z innych kont", + "{count} new messages": "{count} nowych wiadomości", "Filters": "Filtry", "Diagnostics": "Diagnostyka", "Account": "Konto", diff --git a/src/web/icons.svg b/src/web/icons.svg index 5d8389b..6ffc0aa 100644 --- a/src/web/icons.svg +++ b/src/web/icons.svg @@ -33,6 +33,7 @@ + diff --git a/src/web/index.html b/src/web/index.html index f6829fa..2e4a19d 100644 --- a/src/web/index.html +++ b/src/web/index.html @@ -77,6 +77,15 @@ +
+ + +
diff --git a/src/web/js/00-bootstrap.js b/src/web/js/00-bootstrap.js index b183f5a..0a8dc5f 100644 --- a/src/web/js/00-bootstrap.js +++ b/src/web/js/00-bootstrap.js @@ -52,6 +52,7 @@ notificationPrefs: { enabled: true, sound: false, folders: ["INBOX"], dnd_enabled: false, dnd_start: "22:00", dnd_end: "07:00" }, notificationPrefsByAccount: new Map(), notificationAccountIds: new Set(), + sessionNotifications: [], mailPrefs: { messages_per_page: 300, message_loading_mode: "pagination", mark_read_delay_ms: 0, remote_images: "ask", preferred_body: "auto", thread_mode: "threaded", collapse_quotes: true, show_snippets: true, default_sort: "date_desc", flagged_on_top: false, sync_imap_keywords: false, compact_wide_rows: true }, currentFolderFlaggedOnTop: false, markReadTimer: null, diff --git a/src/web/js/20-preferences-auth.js b/src/web/js/20-preferences-auth.js index a1d27e6..c2f8201 100644 --- a/src/web/js/20-preferences-auth.js +++ b/src/web/js/20-preferences-auth.js @@ -308,6 +308,7 @@ async function loadTranslations(language) { $$('[data-i18n-title]').forEach((node) => { const key = node.dataset.i18nTitle; if (state.translations[key]) node.title = state.translations[key]; }); $$('[data-i18n-aria-label]').forEach((node) => { const key = node.dataset.i18nAriaLabel; if (state.translations[key]) node.setAttribute('aria-label', state.translations[key]); }); translateScopedUiText(locale); + if (typeof renderSessionNotifications === "function") renderSessionNotifications(); } function tr(key, fallback = key) { @@ -1093,7 +1094,10 @@ function renderAccountNotificationSettings() { input.checked = state.notificationAccountIds.has(account.id); input.addEventListener("change", () => { if (input.checked) state.notificationAccountIds.add(account.id); - else state.notificationAccountIds.delete(account.id); + else { + state.notificationAccountIds.delete(account.id); + if (typeof clearSessionNotificationsForAccount === "function") clearSessionNotificationsForAccount(account.id); + } saveNotificationAccountSelection(); if (input.checked && typeof loadNotificationPreferencesForAccount === "function") void loadNotificationPreferencesForAccount(account.id); }); @@ -1131,6 +1135,8 @@ async function loadAccounts() { state.folders = []; state.notificationAccountIds.clear(); state.notificationPrefsByAccount.clear(); + state.sessionNotifications = []; + if (typeof renderSessionNotifications === "function") renderSessionNotifications(); updateUnreadAppBadge(); showLogin(); return false; @@ -1144,6 +1150,7 @@ async function loadAccounts() { syncNotificationAccountSelection(); renderAccountSwitcher(); renderAccountNotificationSettings(); + if (typeof pruneSessionNotifications === "function") pruneSessionNotifications(); showApp(); return true; } catch (error) { @@ -1274,6 +1281,7 @@ async function switchAccount(accountId) { if (account) $("#account-email").textContent = account.email; renderAccountSwitcher(); renderAccountNotificationSettings(); + if (typeof pruneSessionNotifications === "function") pruneSessionNotifications(); try { await reloadActiveAccount(); } catch (error) { toast(error.message, "error"); } } @@ -1368,6 +1376,8 @@ async function logout() { state.accounts = []; state.notificationAccountIds.clear(); state.notificationPrefsByAccount.clear(); + state.sessionNotifications = []; + if (typeof renderSessionNotifications === "function") renderSessionNotifications(); localStorage.removeItem("rustbox.activeAccount"); showLogin(); } diff --git a/src/web/js/80-settings-realtime.js b/src/web/js/80-settings-realtime.js index ba57e6a..d13572e 100644 --- a/src/web/js/80-settings-realtime.js +++ b/src/web/js/80-settings-realtime.js @@ -518,6 +518,100 @@ async function openRealtimeNotificationTarget(accountId, data) { if (Number.isFinite(uid) && uid > 0) await openMessage(folder, uid); } +const SESSION_NOTIFICATION_LIMIT = 40; + +function sessionNotificationKey(accountId, data) { + const folder = String(data?.folder || "INBOX"); + const uid = Number(data?.latest_uid); + if (Number.isFinite(uid) && uid > 0) return `${accountId}|${folder}|${uid}`; + return `${accountId}|${folder}|${String(data?.latest_subject || "")}|${String(data?.latest_sender || "")}`; +} + +function renderSessionNotifications() { + const panel = $("#session-notifications"); + const toggle = $("#session-notifications-toggle"); + const badge = $("#session-notifications-badge"); + const menu = $("#session-notifications-menu"); + if (!panel || !toggle || !badge || !menu) return; + + const activeAccounts = new Set(state.accounts.map((account) => account.id)); + state.sessionNotifications = state.sessionNotifications.filter((item) => activeAccounts.has(item.accountId) && item.accountId !== state.accountId); + const count = state.sessionNotifications.length; + const enabled = count > 0; + + panel.classList.toggle("is-empty", !enabled); + if (!enabled) panel.open = false; + toggle.setAttribute("aria-disabled", enabled ? "false" : "true"); + toggle.tabIndex = enabled ? 0 : -1; + const toggleLabel = enabled ? `${trText("Notifications")} (${count})` : trText("Notifications"); + toggle.title = toggleLabel; + toggle.setAttribute("aria-label", toggleLabel); + badge.classList.toggle("hidden", !enabled); + badge.textContent = count > 99 ? "99+" : String(count); + menu.setAttribute("aria-label", trText("Notifications from other accounts")); + menu.replaceChildren(); + + for (const item of state.sessionNotifications) { + const account = state.accounts.find((entry) => entry.id === item.accountId); + if (!account) continue; + const data = item.data || {}; + const countForEvent = Math.max(1, Number(data.count) || 1); + const button = el("button", "session-notification-item"); + button.type = "button"; + button.setAttribute("role", "menuitem"); + + const head = el("span", "session-notification-head"); + head.append( + el("span", "session-notification-account", account.email || trText("Account")), + el("span", "session-notification-time", new Date(item.createdAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })), + ); + + const subject = data.latest_subject || (countForEvent === 1 ? trText("New message") : fmtText("{count} new messages", { count: countForEvent })); + const metaParts = [data.folder || "INBOX"]; + if (data.latest_sender) metaParts.push(data.latest_sender); + if (countForEvent > 1) metaParts.push(fmtText("{count} new messages", { count: countForEvent })); + button.append(head, el("span", "session-notification-subject", subject), el("span", "session-notification-meta", metaParts.join(" · "))); + button.setAttribute("aria-label", `${account.email || trText("Account")}: ${subject}`); + button.addEventListener("click", async () => { + state.sessionNotifications = state.sessionNotifications.filter((entry) => entry.key !== item.key); + panel.open = false; + renderSessionNotifications(); + await openRealtimeNotificationTarget(item.accountId, data); + }); + menu.append(button); + } +} + +function addSessionNotification(data, accountId) { + if (!accountId || accountId === state.accountId) return; + if (!state.accounts.some((account) => account.id === accountId)) return; + const key = sessionNotificationKey(accountId, data); + state.sessionNotifications = state.sessionNotifications.filter((item) => item.key !== key); + state.sessionNotifications.unshift({ + key, + accountId, + createdAt: Date.now(), + data: { + folder: data?.folder || "INBOX", + count: Math.max(1, Number(data?.count) || 1), + latest_uid: data?.latest_uid, + latest_sender: data?.latest_sender || "", + latest_subject: data?.latest_subject || "", + }, + }); + if (state.sessionNotifications.length > SESSION_NOTIFICATION_LIMIT) state.sessionNotifications.length = SESSION_NOTIFICATION_LIMIT; + renderSessionNotifications(); +} + +function clearSessionNotificationsForAccount(accountId) { + state.sessionNotifications = state.sessionNotifications.filter((item) => item.accountId !== accountId); + renderSessionNotifications(); +} + +function pruneSessionNotifications() { + renderSessionNotifications(); +} + function notifyNewMail(data, accountId) { const prefs = notificationPreferencesForAccount(accountId) || {}; if (!prefs.enabled || dndActive(prefs)) return; @@ -764,6 +858,7 @@ function connectRealtime() { const data = event.data || {}; const duplicate = isDuplicateNewMailNotification(data, payload.accountId); if (!duplicate && notificationAccountEnabled(payload.accountId) && !realtimeNewMailNotificationsSuppressed(data, payload.accountId)) { + addSessionNotification(data, payload.accountId); toastNewMail(data, payload.accountId); notifyNewMail(data, payload.accountId); }