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

This commit is contained in:
Mateusz Gruszczyński committed 2026-11-05 08:33:13 +01:00
1 parent 648556f762
commit 99ee3385ac
69 files changed
+1423 -1365

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
# BACKEND KNOWLEDGE BASE
## OVERVIEW
Rust Axum application core for RustBox, forked from Oxi. It manages IMAP/SMTP connections, SQLite caching, and Tantivy search.
Rust Axum application core for RustBox (the first version was forked from Oxi; it is no longer synchronised). It manages IMAP/SMTP connections, SQLite caching, and Tantivy search.
## STRUCTURE
```
+2 -15
View File
@@ -1,18 +1,5 @@
//! Login brute-force protection.
//!
//! The per-IP token bucket (`tower_governor`, configured in `routes::mod`) caps the
//! raw request rate on `POST /api/auth/login`. This module adds failure-based
//! lockouts on top of it:
//!
//! * per account (case-insensitive e-mail, regardless of client IP) — stops slow,
//! distributed password guessing against one mailbox;
//! * per client IP (any account) — stops password spraying from one source.
//!
//! Consecutive lockouts of the same key double the lockout time up to
//! `max_lockout_secs`. A locked request is rejected before RustBox opens an IMAP
//! connection, so brute force does not reach (or trigger lockouts on) the mail
//! server. State is in memory and per node; see `[login_rate_limit]` in the
//! configuration for all knobs.
//! Login brute-force protection: per-account and per-IP failure lockouts with
//! exponential back-off, checked before any IMAP connection is opened.
use std::collections::HashMap;
use std::hash::Hash;
+2 -10
View File
@@ -332,10 +332,7 @@ pub struct AppConfig {
#[serde(default = "default_max_message_size_mb")]
pub max_message_size_mb: usize,
/// Maximum number of requests that may hold attachment data in memory at
/// the same time while a message is sent, an IMAP draft snapshot is saved
/// or a message is attached. Peak RAM for these operations is roughly
/// `max_concurrent_message_builds` x a few times `max_message_size_mb`.
/// Maximum concurrent requests holding attachment data in memory.
#[serde(default = "default_max_concurrent_message_builds")]
pub max_concurrent_message_builds: usize,
@@ -343,12 +340,7 @@ pub struct AppConfig {
#[serde(default = "default_message_build_wait_secs")]
pub message_build_wait_secs: u64,
/// How long a sent message is held back before actually reaching SMTP, so
/// the sender can cancel it ("Undo Send"). `0` disables the feature and
/// sends immediately, exactly as before this option existed. The built
/// message (including attachments) stays in memory for the whole window,
/// bounded by the same `max_concurrent_message_builds` slot it already
/// holds for an immediate send.
/// Seconds a sent message is held for "Undo Send" (0 = send immediately).
#[serde(default = "default_undo_send_window_secs")]
pub undo_send_window_secs: u64,
+1 -4
View File
@@ -1,10 +1,7 @@
use crate::db::pool::{Connection, DbValue, ToDbValue};
use serde::{Deserialize, Deserializer, Serialize};
/// Every predefined interface style. `default` (follow the server-wide
/// `default_interface_skin`) is accepted as a user preference in addition to
/// these, but is not itself a style. Shared with configuration validation so
/// the server default and the per-user preference can never drift apart.
/// Predefined interface styles, shared with `default_interface_skin` validation.
pub const INTERFACE_SKINS: &[&str] = &[
"rustbox",
"clean",
+1 -5
View File
@@ -1,8 +1,4 @@
//! Snoozing a message hides it from its folder listing until a chosen wake
//! time. This is purely local cache state: nothing is moved on the real IMAP
//! server, so snoozing never risks the actual mailbox. The client is
//! responsible for excluding a still-sleeping message from the folder it
//! renders and for showing the "Snoozed" view built from [`list_snoozed`].
//! Snooze: hide a message from its folder until a wake time (local cache only).
use std::time::{SystemTime, UNIX_EPOCH};
+2 -14
View File
@@ -1,17 +1,5 @@
//! Memory guards for assembling messages that carry attachments.
//!
//! Attachments are streamed to blob storage on upload, but sending a message or
//! saving an IMAP draft snapshot has to load every attachment into memory, and
//! the MIME builder then keeps several copies (raw bytes, base64 body, final
//! RFC 5322 text, the copy appended to the Sent folder). Two guards keep that
//! bounded:
//!
//! * [`ensure_attachments_fit`] rejects an attachment set that cannot fit
//! `max_message_size_mb` *before* any blob is read, using the sizes already
//! stored in the database (and while an upload is still streaming).
//! * [`MessageBuildLimiter`] caps how many requests may hold attachment data in
//! memory at the same time (`max_concurrent_message_builds`). Excess requests
//! wait up to `message_build_wait_secs` and then get `503`.
//! Memory guards for building messages with attachments: size check before
//! loading blobs and a limit on concurrent builds.
use std::sync::Arc;
use std::time::Duration;
+2 -11
View File
@@ -169,12 +169,7 @@ fn peer_ip(extensions: &axum::http::Extensions) -> IpAddr {
.unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST))
}
/// Whether issued/cleared session cookies carry the `Secure` attribute.
///
/// `cookie_secure = "auto"` (default) marks cookies `Secure` only when the
/// browser actually reached RustBox over HTTPS, so plain-HTTP installations
/// keep working: a `Secure` cookie sent over HTTP is discarded by browsers and
/// the user could never stay logged in.
/// Whether session cookies get `Secure` (`auto`: only when reached over HTTPS).
pub(crate) fn cookie_secure(
config: &AppConfig,
extensions: &axum::http::Extensions,
@@ -187,11 +182,7 @@ pub(crate) fn cookie_secure(
}
}
/// RustBox itself serves plain HTTP, so HTTPS can only be terminated in front
/// of it. A scheme reported by a trusted reverse proxy (`X-Forwarded-Proto`,
/// `Forwarded: proto=`, `X-Forwarded-Ssl`) wins; otherwise the scheme of the
/// browser-supplied `Origin`/`Referer` is used. A forged header can only change
/// the cookies issued to the client that forged it.
/// Request scheme: trusted proxy headers first, then the browser `Origin`/`Referer`.
fn request_is_https(
config: &AppConfig,
extensions: &axum::http::Extensions,
+1 -4
View File
@@ -846,10 +846,7 @@ fn build_draft_rfc822(
}
}
// Lettre requires at least one envelope destination even when merely serializing
// an RFC822 message. Drafts, however, are valid before the user chooses any
// recipient. Force a private build-only envelope in that case; it is not part
// of the formatted RFC822 headers and therefore does not alter the saved draft.
// Lettre needs an envelope recipient even to serialise; drafts may have none.
if valid_recipients == 0 {
let envelope = Envelope::new(Some(from_address.clone()), vec![from_address])
.map_err(|e| e.to_string())?;
+6 -23
View File
@@ -1,22 +1,8 @@
//! "Apply now" for mail filters: find the messages already in a folder that a
//! visual filter rule matches, so the client can apply the rule's actions to
//! existing mail (Sieve itself only ever runs on newly delivered messages).
//! "Apply now" for filters: find existing messages a rule matches.
//!
//! Matching runs on the IMAP server with `UID SEARCH`, so it covers the whole
//! folder regardless of what RustBox has cached. IMAP SEARCH only does
//! case-insensitive substring matching, though, while Sieve rules can also use
//! `:is` / `:matches`, sizes, attachments and non-ASCII values. Every rule is
//! therefore translated into two searches:
//!
//! * an *upper bound* — a superset of the real matches, and
//! * a *lower bound* — a subset that is certainly correct.
//!
//! Exact leaves contribute the same criteria to both bounds; inexact leaves
//! widen the upper bound to `ALL` (or a substring) and shrink the lower bound
//! to `NOT ALL`; `NOT` swaps the bounds. Messages in the lower bound match.
//! Messages only in the upper bound are decided exactly from the local message
//! cache when it holds what the rule needs; anything still undecided is
//! reported as `uncertain` and left alone.
//! The rule becomes two IMAP `UID SEARCH` criteria: an upper bound (superset)
//! and a lower bound (certain matches). Candidates in between are decided from
//! the local cache; undecidable ones are reported as `uncertain`.
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
@@ -260,11 +246,8 @@ fn bounds_for_test(test: &RuleTest) -> Bounds {
let Some(literal) = search_literal(test) else {
return inexact(ALL.to_string());
};
// IMAP SEARCH does case-insensitive substring matching on the whole
// header text. That is exact for Sieve `header :contains` tests;
// `:is` / `:matches` and Sieve `address` tests (which ignore the
// display name) are only a superset and are narrowed afterwards
// from the cache.
// IMAP SEARCH is a case-insensitive substring match: exact for `:contains`
// header tests, a superset for `:is`, `:matches` and address tests.
let substring = matches!(test.match_kind, MatchKind::Contains);
let q = quote(&literal);
match test.kind.as_str() {
+3 -9
View File
@@ -1830,11 +1830,8 @@ pub async fn delete_message_handler(
Ok(Json(serde_json::json!({ "status": "ok" })).into_response())
}
/// Snoozing is local cache state only: nothing moves on the real IMAP server.
/// `wake_at` and `snooze_for_secs` are mutually exclusive ways to say when
/// the message should reappear; the client is responsible for hiding a
/// still-sleeping message from the folder it renders (see
/// [`list_sleeping_uids`]) and for showing it again once `wake_at` passes.
/// Snooze a message (local cache only). `wake_at` and `snooze_for_secs` are
/// alternatives.
pub async fn snooze_message_handler(
Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>,
@@ -1893,10 +1890,7 @@ pub async fn list_snoozed_handler(
Ok(Json(serde_json::json!({ "messages": snoozed })).into_response())
}
/// The UIDs in `folder` that are still snoozed as of now, so the client can
/// hide those rows from the folder it is rendering. No IMAP or background
/// work is involved; a message simply stops being returned here once its
/// wake time passes.
/// UIDs in `folder` that are still snoozed.
pub async fn list_sleeping_uids(
Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>,
+2 -10
View File
@@ -255,10 +255,7 @@ fn validate_mailbox(label: &str, value: &str) -> Result<(), AppError> {
pub struct SendResponse {
pub status: String,
pub message_id: String,
/// Set only when the message was staged under "Undo Send" instead of
/// being dispatched immediately. Cancel it with
/// `DELETE /messages/send/{pending_send_id}` or poll
/// `GET /messages/send/{pending_send_id}` for the final result.
/// Set when the message is held for "Undo Send".
#[serde(skip_serializing_if = "Option::is_none")]
pub pending_send_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -597,12 +594,7 @@ pub async fn send_message_handler(
.into_response());
}
// Undo Send: stage the already-built message and let the client cancel
// it for `undo_window` seconds before anything is actually dispatched.
// The MessageBuildLimiter permit (if any) is moved into the delayed task
// below and held for the whole window, exactly as it is already held
// through an immediate send; this is what bounds how many undo-pending
// sends can hold attachment data in memory at once.
// Undo Send: hold the built message; the build permit is kept for the window.
let pending_id = undo_registry.stage();
let draft_id = req.draft_id.clone();
let dispatch_config = config.clone();
+1 -5
View File
@@ -86,11 +86,7 @@ fn test_config_with_smtp(_static_dir: &str, data_dir: &str) -> Arc<AppConfig> {
sieve_timeout_secs: 10,
trusted_proxies: None,
outgoing_headers: Vec::new(),
// Existing send tests assert on the immediate, synchronous send
// outcome (status/body), not the "Undo Send" staged-dispatch flow;
// keep that behavior disabled here so they keep testing what they
// were written to test. undo_send.rs/routes/send.rs have their own
// dedicated tests for the staged-dispatch path.
// Undo Send has its own tests; keep sends immediate here.
undo_send_window_secs: 0,
..AppConfig::default()
})
+1 -3
View File
@@ -13,7 +13,6 @@ const THEME_BOOTSTRAP_JS: &[u8] = include_bytes!("web/theme-bootstrap.js");
const ICONS: &[u8] = include_bytes!("web/icons.svg");
const I18N_EN: &[u8] = include_bytes!("web/i18n/en.json");
const I18N_PL: &[u8] = include_bytes!("web/i18n/pl.json");
const OXI_BASE: &str = include_str!("../UPSTREAM_BASE");
const ASSET_ID: &str = env!("RUSTBOX_ASSET_ID");
const FAVICON: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect x="9" y="14" width="46" height="36" rx="5" fill="none" stroke="#6f9c86" stroke-width="5"/><path d="m11 18 21 15 21-15" fill="none" stroke="#6f9c86" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/></svg>"##;
@@ -126,8 +125,7 @@ pub async fn serve_ui(uri: Uri, Extension(config): Extension<Arc<AppConfig>>) ->
.replace(
"__RUSTBOX_MAX_MESSAGE_MB__",
&config.max_message_size_mb.to_string(),
)
.replace("__RUSTBOX_OXI_BASE__", OXI_BASE.trim());
);
(
StatusCode::OK,
[
+3 -19
View File
@@ -1,18 +1,5 @@
//! In-memory staging for "Undo Send".
//!
//! When `undo_send_window_secs > 0`, a fully built outgoing message is held
//! in memory for that many seconds before it is actually dispatched to SMTP,
//! so the sender can cancel it. Nothing here is persisted: a server restart
//! during the window loses any message still staged, the same way a message
//! already queued by any mail server can be lost if the process is killed
//! mid-flight. The window is meant to be short (seconds, not minutes) for
//! exactly this reason.
//!
//! State transitions are `Pending -> Cancelled` (a user clicks "Undo") or
//! `Pending -> Dispatching -> Sent | Failed` (the window elapses). Both the
//! cancel handler and the delayed dispatch task race to leave `Pending`, but
//! only one of them can win because the transition happens under a single
//! mutex: whichever reaches it first decides the outcome.
//! In-memory staging for "Undo Send" (not persisted). A send is either
//! cancelled or dispatched; the transition happens under one mutex.
use std::collections::HashMap;
use std::sync::Mutex;
@@ -94,10 +81,7 @@ impl UndoSendRegistry {
}
}
/// Called by the delayed dispatch task once its wait is over, right
/// before it would contact SMTP. Returns `true` if this call is the one
/// that claimed the send (i.e. it was not cancelled in the meantime);
/// the caller must then actually dispatch and record the result.
/// Claims the send for dispatch; `false` if it was cancelled.
pub fn begin_dispatch(&self, id: &str) -> bool {
let mut entries = self.entries.lock().expect("undo-send registry lock");
match entries.get_mut(id) {
+1 -2
View File
@@ -127,8 +127,7 @@ html[data-font-family="mono"] {
--font-sans: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace;
}
/* Not derived from var(--line): a custom property referring to itself is a
cycle, which made --line empty (no borders) wherever no palette overrode it. */
/* Not var(--line) itself: a self-reference is invalid CSS. */
html[data-contrast="soft"] {
--line: color-mix(in srgb, var(--text) 11%, var(--panel));
--line-strong: color-mix(in srgb, var(--text) 20%, var(--panel));
+1 -2
View File
@@ -1416,8 +1416,7 @@ body.reader-fullscreen-open {
}
}
/* Optional wide-pane single-line message rows. The preference is on by default,
but the layout only activates once the message pane has enough horizontal room. */
/* 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);
+70 -7
View File
@@ -192,9 +192,74 @@
color: var(--muted);
}
.about-list code {
font: 10px var(--font-mono);
overflow-wrap: anywhere;
.about-list a,
.about-card a {
color: var(--accent);
}
.about-hero {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 16px;
}
.about-hero h2 {
margin: 0 0 2px;
}
.about-hero p {
margin: 0;
}
.about-logo {
flex: none;
width: 44px;
height: 44px;
padding: 9px;
border-radius: 12px;
background: color-mix(in srgb, var(--accent) 16%, var(--panel));
color: var(--accent);
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.about-version {
margin-left: 6px;
padding: 2px 8px;
border: 1px solid var(--line);
border-radius: 999px;
color: var(--muted);
font-size: 11px;
font-weight: 600;
vertical-align: middle;
}
.about-card {
display: grid;
gap: 6px;
margin-top: 16px;
padding: 12px 14px;
border: 1px solid var(--line);
border-radius: var(--radius-panel);
background: var(--panel-2);
}
.about-card p {
margin: 0;
line-height: 1.5;
}
.about-origin {
margin: 14px 0 0;
}
.about-copyright {
margin: 4px 0 0;
font-size: 11px;
}
.toast-stack {
@@ -2635,8 +2700,7 @@ kbd {
}
/* 0.2.24: theme schedule, account colour, contrast hint, list skeleton and
a short cross-fade when the theme or interface style changes. */
/* Theme schedule, account colour, contrast hint, list skeleton, theme cross-fade. */
.theme-schedule-row {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -2684,8 +2748,7 @@ kbd {
grid-column: 1 / -1;
margin: 0;
padding: 6px 9px;
/* Fixed colours on purpose: the warning must stay readable precisely when
the user's own text/panel colours are not. */
/* Fixed colours: must stay readable with any custom palette. */
border: 1px solid #d9a93a;
border-radius: var(--radius-control);
background: #fff4d6;
+2 -2
View File
@@ -1096,7 +1096,7 @@ html[data-interface-skin="notion"][data-theme="dark"] {
}
/* 0.2.23 predefined styles: light and dark palettes. */
/* Editor and atmospheric styles: light and dark palettes. */
html[data-interface-skin="dracula"][data-theme="light"] {
color-scheme: light;
--bg: #f4f1e6;
@@ -1855,7 +1855,7 @@ html[data-interface-skin="retro"][data-theme="dark"] {
/* 0.2.24 predefined styles: light and dark palettes. */
/* Retro styles: light and dark palettes. */
html[data-interface-skin="winxp"][data-theme="light"] {
color-scheme: light;
--bg: #ece9d8;
+2 -14
View File
@@ -1,17 +1,5 @@
/*
* RustBox interface-style standard (0.2.23).
*
* Every predefined style ships three layers:
* 1. light and dark palettes (56-interface-styles.css / 55-roundcube-skins.css);
* 2. a fixed layout profile (interfaceStyleProfiles in 20-preferences-auth.js);
* 3. the structural tokens below, consumed only when the user selects
* "Immersive" style depth (58-skin-immersive.css).
*
* Tokens here are always flat. Gradients live in 59-skin-gradients.css and
* only apply with the separate Appearance switch (skin_gradients).
*
* Generated from one table; keep every style listed here.
*/
/* Structural tokens per style, used at "Immersive" depth (58-skin-immersive.css).
Palettes: 55/56; layout profiles: 20-preferences-auth.js; gradients: 59. */
:root {
--skin-font: var(--font-sans);
+2 -11
View File
@@ -1,12 +1,4 @@
/*
* "Immersive" style depth: applies the structural tokens from 57-skin-tokens.css.
*
* Every rule is scoped to :root[data-skin-depth="immersive"][data-theme] so it
* outranks the per-style chrome rules (html[data-interface-skin=…][data-theme=…] …)
* without !important, and has no effect at the "colors" or "standard" depth.
* Inline custom properties written by applyPreferences() live on <html>, so the
* shared radius/font variables are re-pointed on <body> where they inherit down.
*/
/* "Immersive" depth: applies the tokens from 57-skin-tokens.css. */
:root[data-skin-depth="immersive"][data-theme] body {
--font-sans: var(--skin-font);
@@ -100,8 +92,7 @@
backdrop-filter: var(--skin-glass);
}
/* Floating panes only where the three-pane desktop layout is shown; the
phone/tablet drawer layout in 60-responsive.css stays untouched. */
/* Floating panes only in the desktop three-pane layout. */
@media (min-width: 821px) {
:root[data-skin-depth="immersive"][data-theme] .mail-shell {
gap: var(--skin-shell-gap);
+1 -8
View File
@@ -1,11 +1,4 @@
/*
* Style gradients (0.2.24): generated from one table.
*
* Active only with Appearance -> "Style gradients" on, at every style depth,
* for every predefined style except the RustBox custom style. Gradients are
* background-image layers over the colours chosen by the palette and depth;
* the defaults are translucent overlays, signature styles override them.
*/
/* Style gradients: only with the "Style gradients" switch on; not for the RustBox style. */
:root[data-skin-gradients="true"] {
--grad-backdrop: radial-gradient(1100px 560px at 92% -12%, color-mix(in srgb, var(--accent) 16%, transparent), transparent 62%);
+9 -6
View File
@@ -350,11 +350,6 @@
"Active sessions": "Aktywne sesje",
"Live RustBox sessions for this account, including other browsers while the server is running.": "Aktywne sesje RustBox dla tego konta, także w innych przeglądarkach, gdy serwer działa.",
"Revoke other sessions": "Unieważnij pozostałe sesje",
"Webmail based on the Oxi codebase, with a RustBox-owned interface compiled into the Rust executable.": "Webmail oparty na kodzie Oxi, z interfejsem RustBox kompilowanym bezpośrednio do programu Rust.",
"Upstream": "Projekt bazowy",
"Oxi base": "Baza Oxi",
"Frontend": "Frontend",
"HTML, CSS and vanilla JavaScript embedded at Cargo build time": "HTML, CSS i czysty JavaScript osadzane podczas kompilacji Cargo",
"Author": "Autor",
"Keyboard shortcuts": "Skróty klawiaturowe",
"Identity & signature": "Tożsamość i podpis",
@@ -788,7 +783,15 @@
"because the copied colours belong to it.": "bo skopiowane kolory należą do niego.",
"Shapes, fonts, app bar and gradients specific to the style are not part of the copy.": "Kształty, czcionki, pasek aplikacji i gradienty charakterystyczne dla stylu nie wchodzą do kopii.",
"Create a custom copy?": "Utworzyć własną kopię?",
"Create custom copy": "Utwórz własną kopię"
"Create custom copy": "Utwórz własną kopię",
"Fast, self-hosted webmail written in Rust.": "Szybki webmail do samodzielnego hostowania, napisany w Rust.",
"License": "Licencja",
"Technology": "Technologia",
"GNU AGPL v3 or commercial license": "GNU AGPL v3 lub licencja komercyjna",
"Rust server with an embedded HTML, CSS and JavaScript interface": "Serwer w Rust z wbudowanym interfejsem HTML, CSS i JavaScript",
"RustBox is free software under the GNU Affero General Public License v3: you may use, modify and share it, also in a company. If you run a modified version as a network service, you must offer its source code to its users.": "RustBox jest wolnym oprogramowaniem na licencji GNU Affero General Public License v3: możesz go używać, zmieniać i udostępniać, także w firmie. Jeśli uruchamiasz zmodyfikowaną wersję jako usługę sieciową, musisz udostępnić jej kod źródłowy jej użytkownikom.",
"Companies that prefer different terms — for example to keep their changes private — can obtain a paid commercial license. Contacting the author is voluntary: it is never required for use that follows the AGPL.": "Firmy, które wolą inne warunki — na przykład zachować swoje zmiany dla siebie — mogą uzyskać płatną licencję komercyjną. Kontakt z autorem jest dobrowolny: nie jest wymagany przy korzystaniu zgodnym z AGPL.",
"The first version of RustBox was created as a fork of the Oxi project (MIT License).": "Pierwsza wersja RustBox powstała jako fork projektu Oxi (licencja MIT)."
},
"fixed_interface_style_note": "gotowy styl interfejsu. Włącz edycję poniżej, aby dopasować jego układ i typografię.",
"custom_interface_style_note": "RustBox można w pełni dostosować: kolory, odstępy, panele, typografię i zaawansowane ustawienia interfejsu.",
+28 -9
View File
@@ -1307,21 +1307,40 @@
</div>
</section>
<section class="settings-panel hidden" data-panel="about">
<h2>RustBox __RUSTBOX_VERSION__</h2>
<p>Webmail based on the Oxi codebase, with a RustBox-owned interface compiled into the Rust executable.</p>
<div class="about-hero">
<svg class="about-logo" aria-hidden="true">
<use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-mail"></use>
</svg>
<div>
<h2>RustBox <span class="about-version">__RUSTBOX_VERSION__</span></h2>
<p class="muted">Fast, self-hosted webmail written in Rust.</p>
</div>
</div>
<dl class="about-list">
<dt>Upstream</dt>
<dd>c0h1b4/oxi</dd>
<dt>Oxi base</dt>
<dd><code>__RUSTBOX_OXI_BASE__</code></dd>
<dt>Frontend</dt>
<dd>HTML, CSS and vanilla JavaScript embedded at Cargo build time</dd>
<dt>Author</dt>
<dd>@linuxiarz.pl</dd>
<dd>Mateusz Gruszczyński · <a href="https://linuxiarz.pl" target="_blank"
rel="noopener noreferrer">linuxiarz.pl</a></dd>
<dt>Repository</dt>
<dd><a href="https://git.linuxiarz.pl/gru/RustBox" target="_blank"
rel="noopener noreferrer">git.linuxiarz.pl/gru/RustBox</a></dd>
<dt>License</dt>
<dd>GNU AGPL v3 or commercial license</dd>
<dt>Technology</dt>
<dd>Rust server with an embedded HTML, CSS and JavaScript interface</dd>
</dl>
<div class="about-card">
<strong>License</strong>
<p>RustBox is free software under the GNU Affero General Public License v3: you may use, modify and
share it, also in a company. If you run a modified version as a network service, you must offer its
source code to its users.</p>
<p>Companies that prefer different terms — for example to keep their changes private — can obtain a
paid commercial license. Contacting the author is voluntary: it is never required for use that
follows the AGPL.</p>
<p><a href="mailto:linuxiarz@linuxiarz.pl">linuxiarz@linuxiarz.pl</a></p>
</div>
<p class="about-origin muted">The first version of RustBox was created as a fork of the Oxi project (MIT
License).</p>
<p class="about-copyright muted">© 2026 Mateusz Gruszczyński</p>
</section>
</div>
</div>
+7 -17
View File
@@ -231,9 +231,7 @@ function resolvedSkinDepth(prefs = state.prefs) {
return skinDepths.includes(prefs.skinDepth) ? prefs.skinDepth : "standard";
}
// "colors" keeps only a predefined style's palette; "standard" and "immersive"
// also apply its fixed layout profile ("immersive" adds the structural tokens
// from 57-skin-tokens.css on top, purely in CSS).
// Style depth: "colors" = palette only; "standard" adds the layout profile; "immersive" adds the structural tokens.
function layoutInterfaceStyle(prefs = state.prefs) {
return resolvedSkinDepth(prefs) === "colors" ? null : activeInterfaceStyle(prefs);
}
@@ -310,9 +308,7 @@ function applyPreferences() {
const fixedStyle = layoutInterfaceStyle(p);
const skinDepth = resolvedSkinDepth(p);
const previousLook = `${root.dataset.theme}|${root.dataset.interfaceSkin}|${root.dataset.skinDepth}|${root.dataset.skinGradients}`;
// A predefined style fixes the layout unless editing is allowed; then the
// user's own values (seeded from the style when editing was enabled) win,
// while the style's palette, tokens and gradients stay in place.
// The style fixes the layout unless editing is allowed.
const visual = fixedStyle && !p.presetScaleUnlocked
? { ...p, ...fixedStyle.preferences }
: p;
@@ -487,9 +483,7 @@ function contrastRatio(first, second) {
return (a + 0.05) / (b + 0.05);
}
// Warns while the user's own colours make text hard to read (WCAG AA, 4.5:1
// for text, 3:1 for the accent against panels). Predefined palettes are
// checked at release time, so the hint is only shown for custom colours.
// Live WCAG contrast hint for custom colours.
function updateContrastWarning(usingPredefinedPalette) {
const warning = $("#pref-contrast-warning");
if (!warning) return;
@@ -535,6 +529,7 @@ async function loadPreferences() {
catch (error) { console.warn("Could not load display preferences", error); }
applyPreferences();
await loadTranslations(state.prefs.language);
applyPreferences();
}
async function loadMailPreferences() {
@@ -724,9 +719,7 @@ function schedulePromotedAppearanceSave() {
const presetLayoutPreferenceKeys = [...presetEditablePreferenceKeys].filter((key) => !presetColorPreferenceKeys.has(key));
// "Allow editing predefined style": seed the user's own layout values from the
// style, so editing starts from exactly what is on screen and never mixes in
// values saved earlier for another style.
// Seeds the user's layout from the style so editing starts from what is on screen.
async function setPresetEditing(enabled) {
const layoutStyle = layoutInterfaceStyle(state.prefs);
const payload = { preset_scale_unlocked: enabled };
@@ -746,10 +739,7 @@ async function setPresetEditing(enabled) {
}
}
// Colours of a predefined style come from its light and dark palettes and
// cannot be overridden one by one without mixing two palettes. Changing one
// therefore turns the style into a RustBox custom copy of the palette that is
// on screen now, pinned to the current light/dark mode so it stays coherent.
// A colour change on a predefined style creates a RustBox custom copy of the current palette.
async function promotePresetToCustom(key, value) {
const paletteStyle = activeInterfaceStyle(state.prefs);
if (!paletteStyle || !presetColorPreferenceKeys.has(key) || !state.prefs.presetScaleUnlocked) return false;
@@ -815,7 +805,7 @@ function savePreference(key, value) {
}
if (["accentColor", "backgroundColor", "panelColor", "textColor"].includes(key)) state.prefs.colorPreset = "custom";
applyPreferences();
if (key === "language") loadTranslations(value);
if (key === "language") loadTranslations(value).then(applyPreferences);
const previousTimer = preferenceTimers.get(key);
if (previousTimer) clearTimeout(previousTimer);
const timer = setTimeout(async () => {
+24 -5
View File
@@ -11,12 +11,31 @@ function folderIconName(folder) {
return "folder";
}
function folderPathParts(folder) {
const delimiter = folder.delimiter || "/";
return delimiter ? folder.name.split(delimiter) : [folder.name];
}
// Tree order: children under their parent, INBOX first, siblings alphabetical.
function compareFolderPaths(a, b) {
const left = folderPathParts(a);
const right = folderPathParts(b);
for (let index = 0; index < Math.max(left.length, right.length); index += 1) {
if (left[index] === undefined) return -1;
if (right[index] === undefined) return 1;
if (left[index] === right[index]) continue;
if (index === 0) {
const leftInbox = left[0].toUpperCase() === "INBOX";
const rightInbox = right[0].toUpperCase() === "INBOX";
if (leftInbox !== rightInbox) return leftInbox ? -1 : 1;
}
return left[index].localeCompare(right[index]) || (left[index] < right[index] ? -1 : 1);
}
return 0;
}
function orderedFolders() {
return [...state.folders].sort((a, b) => {
if (a.name.toUpperCase() === "INBOX") return -1;
if (b.name.toUpperCase() === "INBOX") return 1;
return a.name.localeCompare(b.name);
});
return [...state.folders].sort(compareFolderPaths);
}
function folderDepth(folder) {
+2 -8
View File
@@ -312,11 +312,7 @@ async function quickUpdateMessageFlag(message, flag, add, successMessage) {
}
}
// Hides rows for messages that are currently snoozed in this folder. This is
// a purely visual, best-effort filter on top of the already-rendered list:
// it never changes state.messages/state.total, so pagination/counts/caching
// above are completely unaffected. A message simply stops being hidden once
// its wake time passes and the folder is reloaded or reopened.
// Hides rows of snoozed messages (visual only; counts and paging unchanged).
async function applySnoozeHiding() {
if (state.searchContext) return;
const folder = state.folder;
@@ -508,9 +504,7 @@ function readerFullscreenUrl(detail = state.currentDetail) {
return new URL(location.href);
}
// Gmail-style modifier clicks on a message row, active only outside selection
// mode (inside selection mode the same modifiers keep doing range/toggle
// selection, see the row click handler below).
// Ctrl/Cmd/Shift-click on a row outside selection mode.
function openMessageInNewTab(folder, uid) {
window.open(messageDeepLinkUrl(folder, uid), "_blank", "noopener");
}
+1 -4
View File
@@ -1788,10 +1788,7 @@ function buildReferences(detail) {
return values.length ? values.join(" ") : null;
}
// "Undo Send": the server held the just-sent message back for a few seconds
// instead of dispatching it immediately. Show a toast with an Undo action
// for the whole window, then poll once for the final result so a send that
// actually failed in the background is still reported.
// Undo Send: offer Undo during the hold window, then report the final result.
function notifyUndoSendStaged(pendingId, windowSecs) {
const seconds = Math.max(0, windowSecs);
toast("Sending\u2026", "info", seconds * 1000 || 4000, {
+1 -3
View File
@@ -181,9 +181,7 @@ async function copyCurrentTo(targetFolder) {
} catch (error) { toast(error.message, "error"); }
}
// Snoozing hides a message from its folder listing until a chosen wake time.
// It is purely local client/server cache state: nothing moves on the real
// IMAP server, so a snooze never risks the actual mailbox.
// Snooze hides a message until its wake time (local only, nothing moves on IMAP).
function snoozePresetOptions() {
const now = new Date();
const laterToday = new Date(now.getTime() + 3 * 60 * 60 * 1000);
+2 -7
View File
@@ -409,9 +409,7 @@ function calendarReminderStorageKey(event, minutes) {
return `rustbox.calendarReminder.${state.accountId || "account"}.${event.id}.${event.start_time}.${minutes}`;
}
// Timed events: start minus the reminder. All-day events: the reminder counts
// from the start of the working day (calendar settings, default 09:00) of the
// event's date, so "1 day before" fires the previous morning, not at midnight.
// All-day events are reminded relative to the start of the working day.
function calendarReminderTrigger(event, minutes) {
const offset = Math.max(0, Number(minutes) || 0) * 60000;
if (event.all_day) {
@@ -476,10 +474,7 @@ function isDuplicateRealtimeEvent(payload) {
const now = Date.now();
for (const [key, seenAt] of state.realtimeSeenEvents) if (now - seenAt > 10000) state.realtimeSeenEvents.delete(key);
// Server websocket envelopes carry a unique eventId. When present it is the
// only safe deduplication key: state can legitimately cycle A -> B -> A in
// under a second (for example unread 1 -> 0 -> 1). Fingerprint-based
// deduplication would incorrectly drop the second A and leave stale UI.
// Deduplicate by eventId when present: the same state can legitimately repeat.
if (payload.eventId) {
const idKey = `id:${payload.eventId}`;
const duplicate = state.realtimeSeenEvents.has(idKey);
+1 -3
View File
@@ -1,6 +1,4 @@
// Filter conveniences on top of the visual Sieve editor: templates, creating
// missing target folders, and "Apply now" — running a rule's actions on mail
// that is already in a folder (Sieve itself only sees newly delivered mail).
// Filter templates, missing-folder creation and "Apply now" for existing mail.
function fmtText(template, values = {}) {
return trText(template).replace(/\{(\w+)\}/g, (_, key) => String(values[key] ?? ""));
+1 -3
View File
@@ -507,9 +507,7 @@ function bindEvents() {
$$("[data-signature-command]").forEach((button) => button.addEventListener("click", () => {
const range = selectedSignatureRange();
if (!range) {
// execCommand on a collapsed caret enables a sticky typing style (for
// example every subsequently typed character becomes bold). Signature
// formatting is intentionally selection-only.
// Formatting applies to the selection only (a collapsed caret would make it sticky).
signatureEditor.focus();
return;
}