0.2.33
RustBox CI / rustbox (push) Waiting to run

This commit is contained in:
Mateusz Gruszczyński committed 2026-11-09 14:52:54 +01:00
1 parent 167fdfcb04
commit ad8981e38d
14 files changed
+233 -12

No files matched your search

+4
View File
@@ -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.
Generated
+1 -1
View File
@@ -3534,7 +3534,7 @@ dependencies = [
[[package]]
name = "rustbox"
version = "0.2.32"
version = "0.2.33"
dependencies = [
"aes-gcm",
"async-imap",
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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.
+6 -3
View File
@@ -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
+6 -3
View File
@@ -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
+8 -2
View File
@@ -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
+87
View File
@@ -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;
}
+2
View File
@@ -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",
+1
View File
@@ -33,6 +33,7 @@
<symbol id="icon-user-round" viewBox="0 0 24 24"><path d="M18 20a6 6 0 0 0-12 0"/><circle cx="12" cy="10" r="4"/></symbol>
<symbol id="icon-calendar-days" viewBox="0 0 24 24"><path d="M8 2v4M16 2v4M3 10h18"/><rect x="3" y="4" width="18" height="18" rx="2"/></symbol>
<symbol id="icon-bell" viewBox="0 0 24 24"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 7-3 9h18c0-2-3-2-3-9"/><path d="M10 21h4"/></symbol>
<symbol id="icon-image" viewBox="0 0 24 24"><rect x="3" y="5" width="18" height="14" rx="2"/><circle cx="8.5" cy="10" r="1.5"/><path d="m21 15-5-5L5 19"/></symbol>
<symbol id="icon-sliders" viewBox="0 0 24 24"><path d="M4 6h10M18 6h2M4 12h2M10 12h10M4 18h7M15 18h5"/><circle cx="16" cy="6" r="2"/><circle cx="8" cy="12" r="2"/><circle cx="13" cy="18" r="2"/></symbol>
<symbol id="icon-plus" viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"/></symbol>

Before

Width:  |  Height:  |  Size: 7.0 KiB

After

Width:  |  Height:  |  Size: 7.1 KiB

+9
View File
@@ -77,6 +77,15 @@
<button id="calendar-btn" class="icon-button" type="button" title="Calendar" aria-label="Calendar"><svg>
<use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-calendar-days"></use>
</svg></button>
<details id="session-notifications" class="toolbar-menu session-notifications is-empty">
<summary id="session-notifications-toggle" class="icon-button" title="Notifications" aria-label="Notifications"
aria-disabled="true" tabindex="-1"><svg aria-hidden="true">
<use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-bell"></use>
</svg><span id="session-notifications-badge" class="session-notifications-badge hidden"
aria-hidden="true">0</span></summary>
<div id="session-notifications-menu" class="toolbar-menu-popover session-notifications-menu" role="menu"
aria-label="Notifications from other accounts"></div>
</details>
<button id="settings-btn" class="icon-button" type="button" title="Settings" aria-label="Settings"><svg>
<use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-settings"></use>
</svg></button>
+1
View File
@@ -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,
+11 -1
View File
@@ -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();
}
+95
View File
@@ -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);
}