1 parent
648556f762
commit
99ee3385ac
69 files changed
+1423
-1365
No files matched your search
+1
-1
@@ -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
@@ -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
@@ -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,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
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,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%);
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,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] ?? ""));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user