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

This commit is contained in:
Mateusz Gruszczyński committed 2026-11-03 10:35:05 +01:00
1 parent 2ac1566eca
commit 1bb398acd1
32 files changed
+907 -136

No files matched your search

+7
View File
@@ -294,6 +294,9 @@ pub struct AppConfig {
#[serde(default = "default_feature_enabled")]
pub about_enabled: bool,
#[serde(default = "default_feature_enabled")]
pub multi_account_enabled: bool,
#[serde(default)]
pub security_headers_enabled: bool,
@@ -586,6 +589,7 @@ impl Default for AppConfig {
sieve_enabled: true,
diagnostics_enabled: true,
about_enabled: true,
multi_account_enabled: true,
security_headers_enabled: false,
sieve_host: None,
sieve_port: default_sieve_port(),
@@ -1229,6 +1233,7 @@ mod tests {
assert!(config.sieve_enabled);
assert!(config.diagnostics_enabled);
assert!(config.about_enabled);
assert!(config.multi_account_enabled);
assert!(!config.security_headers_enabled);
assert!(config.sieve_host.is_none());
assert_eq!(config.sieve_port, 4190);
@@ -1269,6 +1274,7 @@ mod tests {
.merge(("sieve_enabled", false))
.merge(("diagnostics_enabled", false))
.merge(("about_enabled", false))
.merge(("multi_account_enabled", false))
.merge(("security_headers_enabled", true))
.merge(("sieve_host", "sieve.example.com"))
.merge(("sieve_port", 4191u16))
@@ -1300,6 +1306,7 @@ mod tests {
assert!(!config.sieve_enabled);
assert!(!config.diagnostics_enabled);
assert!(!config.about_enabled);
assert!(!config.multi_account_enabled);
assert!(config.security_headers_enabled);
assert_eq!(config.sieve_host.as_deref(), Some("sieve.example.com"));
assert_eq!(config.sieve_port, 4191);
+7 -4
View File
@@ -179,10 +179,13 @@ pub(crate) fn has_attachments(bs: &async_imap::imap_proto::types::BodyStructure<
use async_imap::imap_proto::types::BodyStructure;
match bs {
BodyStructure::Basic { common, .. } => {
if let Some(ref disp) = common.disposition
&& disp.ty.eq_ignore_ascii_case("attachment")
{
return true;
if let Some(ref disp) = common.disposition {
if disp.ty.eq_ignore_ascii_case("attachment") {
return true;
}
if disp.ty.eq_ignore_ascii_case("inline") {
return false;
}
}
let ty = common.ty.ty.to_ascii_lowercase();
+17 -2
View File
@@ -2,10 +2,11 @@ use std::collections::HashMap;
use std::sync::Arc;
use axum::Extension;
use axum::extract::WebSocketUpgrade;
use axum::extract::ws::{Message, WebSocket};
use axum::extract::{Query, WebSocketUpgrade};
use axum::response::{IntoResponse, Response};
use futures::{SinkExt, StreamExt};
use serde::Deserialize;
use tokio::sync::{broadcast, mpsc};
use crate::auth::session::{AccountSession, SessionStore};
@@ -22,6 +23,11 @@ fn unix_time_millis() -> u128 {
.as_millis()
}
#[derive(Debug, Default, Deserialize)]
pub(crate) struct WsQuery {
account_id: Option<String>,
}
fn extract_browser_id(cookie_header: &str) -> Option<String> {
for segment in cookie_header.split(';') {
let trimmed = segment.trim();
@@ -38,6 +44,7 @@ fn extract_browser_id(cookie_header: &str) -> Option<String> {
#[allow(clippy::too_many_arguments)]
pub async fn ws_handler(
ws: WebSocketUpgrade,
Query(query): Query<WsQuery>,
headers: axum::http::HeaderMap,
Extension(store): Extension<Arc<SessionStore>>,
Extension(config): Extension<Arc<AppConfig>>,
@@ -60,7 +67,15 @@ pub async fn ws_handler(
.into_response();
};
let accounts = store.get_browser_accounts_shared(&browser_id).await;
let mut accounts = store.get_browser_accounts_shared(&browser_id).await;
if !config.multi_account_enabled {
if let Some(account_id) = query.account_id.as_deref() {
accounts.retain(|account| account.account_id == account_id);
} else {
accounts.truncate(1);
}
}
if accounts.is_empty() {
return (axum::http::StatusCode::UNAUTHORIZED, "No active accounts").into_response();
+18
View File
@@ -33,6 +33,7 @@ pub struct SessionPolicyResponse {
sieve_enabled: bool,
diagnostics_enabled: bool,
about_enabled: bool,
multi_account_enabled: bool,
}
pub async fn session_policy(
@@ -43,6 +44,7 @@ pub async fn session_policy(
sieve_enabled: config.sieve_enabled,
diagnostics_enabled: config.diagnostics_enabled,
about_enabled: config.about_enabled,
multi_account_enabled: config.multi_account_enabled,
})
}
@@ -246,6 +248,22 @@ pub async fn login(
)
.into_response();
}
if !config.multi_account_enabled && !existing_accounts.is_empty() {
return (
StatusCode::FORBIDDEN,
[("content-type", "application/json")],
serde_json::json!({
"error": {
"code": "MULTI_ACCOUNT_DISABLED",
"message": "Multiple accounts are disabled by the RustBox administrator",
"status": 403
}
})
.to_string(),
)
.into_response();
}
}
let user_hash = user_data::hash_email(&body.email);
+26 -9
View File
@@ -843,8 +843,11 @@ pub async fn get_message(
let attachments: Vec<AttachmentMeta> = cached
.attachments_json
.as_deref()
.and_then(|j| serde_json::from_str(j).ok())
.unwrap_or_default();
.and_then(|j| serde_json::from_str::<Vec<AttachmentMeta>>(j).ok())
.unwrap_or_default()
.into_iter()
.filter(|attachment| attachment.content_id.is_none())
.collect();
let theme = cached.email_theme;
if theme.is_none()
&& let Some(ref html) = cached.html
@@ -912,12 +915,26 @@ pub async fn get_message(
.attachments
.iter()
.enumerate()
.map(|(i, a)| AttachmentMeta {
id: i.to_string(),
filename: a.filename.clone(),
content_type: preview_content_type(&a.content_type, a.filename.as_deref(), &a.data),
size: a.size,
content_id: a.content_id.clone(),
.filter_map(|(i, a)| {
let referenced_inline = a.content_id.as_ref().is_some_and(|cid| {
sanitized_html
.as_deref()
.is_some_and(|html| html.contains(&format!("cid:{cid}")))
});
if referenced_inline {
return None;
}
Some(AttachmentMeta {
id: i.to_string(),
filename: a.filename.clone(),
content_type: preview_content_type(
&a.content_type,
a.filename.as_deref(),
&a.data,
),
size: a.size,
content_id: a.content_id.clone(),
})
})
.collect();
@@ -1114,7 +1131,7 @@ pub async fn get_message(
cc_addresses: parse_address_list(&msg.cc_addresses),
date: msg.date,
flags: parse_flags(&msg.flags),
has_attachments: msg.has_attachments,
has_attachments: !attachments.is_empty(),
html: body_html,
text: body_text,
raw_headers,
+1
View File
@@ -513,6 +513,7 @@ fn sanitized_config(config: &AppConfig) -> Value {
"features": {
"userDiagnostics": config.diagnostics_enabled,
"about": config.about_enabled,
"multiAccount": config.multi_account_enabled,
"pgp": config.pgp_enabled,
"securityHeaders": config.security_headers_enabled,
},
+3
View File
@@ -312,6 +312,7 @@ async fn auth_policy_exposes_only_login_requirements_before_authentication() {
config.sieve_enabled = false;
config.diagnostics_enabled = false;
config.about_enabled = false;
config.multi_account_enabled = false;
let store = test_store();
let (browser_id, account_id, token) = setup_test_account(&store, "alice@example.com");
let app = create_router(
@@ -345,6 +346,7 @@ async fn auth_policy_exposes_only_login_requirements_before_authentication() {
"sieveEnabled",
"diagnosticsEnabled",
"aboutEnabled",
"multiAccountEnabled",
] {
assert!(json.get(key).is_none(), "public policy leaked {key}");
}
@@ -377,6 +379,7 @@ async fn auth_policy_exposes_only_login_requirements_before_authentication() {
assert_eq!(json["sieveEnabled"], false);
assert_eq!(json["diagnosticsEnabled"], false);
assert_eq!(json["aboutEnabled"], false);
assert_eq!(json["multiAccountEnabled"], false);
assert!(json.get("rememberMeDays").is_none());
assert!(json.get("sessionTimeoutHours").is_none());
}
+41 -1
View File
@@ -364,4 +364,44 @@
.network-offline .account-summary span:last-child {
color: var(--warning);
}
}
.account-switcher > summary {
width: auto;
min-width: var(--control-height);
display: inline-flex;
align-items: center;
justify-content: center;
gap: 2px;
padding: 0 6px;
}
.account-switcher > summary svg {
width: 16px;
height: 16px;
}
#account-switcher-menu {
min-width: 248px;
}
#account-switcher-menu button {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 12px;
align-items: center;
}
#account-switcher-menu button span:first-child {
overflow: hidden;
text-overflow: ellipsis;
}
#account-switcher-menu button .account-switcher-state {
color: var(--muted);
font-size: 10px;
}
#account-switcher-menu button.active {
background: var(--selected);
color: var(--accent);
}
+7 -1
View File
@@ -257,7 +257,7 @@
.message-pagination {
width: 100%;
display: grid;
grid-template-columns: 30px auto 30px;
grid-template-columns: 30px 30px auto 30px 30px;
justify-content: center;
align-items: center;
gap: 8px;
@@ -280,6 +280,12 @@
height: 16px;
}
.pagination-jump span {
font-size: 18px;
line-height: 1;
transform: translateY(-1px);
}
.message-page-status {
min-width: 88px;
text-align: center;
+84 -6
View File
@@ -573,17 +573,83 @@
.identity-signature-grid>label,
.identity-signature-grid>.identity-signature-field {
min-width: 0
min-width: 0;
display: flex;
flex-direction: column
}
.identity-signature-grid textarea,
.identity-signature-grid .signature-editor-shell {
height: 240px;
min-height: 240px;
max-height: 240px;
box-sizing: border-box
}
.identity-signature-grid textarea {
min-height: 210px;
resize: vertical
resize: none
}
.identity-signature-grid .signature-editor-shell {
display: grid;
grid-template-rows: auto minmax(0, 1fr)
}
.identity-signature-grid .signature-editor-shell .signature-editor {
min-height: 210px;
max-height: 360px
min-height: 0;
max-height: none;
height: 100%
}
.dialog.signature-html-dialog {
width: min(1040px, calc(100vw - 28px));
max-width: none
}
.signature-html-card {
width: 100%;
max-height: calc(100dvh - 28px);
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
overflow: hidden
}
.signature-html-body {
min-height: 0;
overflow: auto;
display: grid;
grid-template-columns: minmax(300px, .9fr) minmax(420px, 1.1fr);
gap: 14px;
padding: 14px
}
.signature-html-source-pane,
.signature-html-preview-pane {
min-width: 0;
display: grid;
align-content: start;
gap: 8px
}
.signature-html-source-pane textarea {
width: 100%;
min-height: 380px;
resize: vertical;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12px;
line-height: 1.45;
box-sizing: border-box
}
.signature-html-preview-shell {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
min-height: 420px
}
.signature-html-preview-editor {
min-height: 360px;
max-height: 520px
}
.identity-options-grid {
@@ -2445,4 +2511,16 @@ kbd {
.diagnostic-imap-actions {
justify-content: flex-end;
margin-top: 10px;
}
}
.multi-account-notifications.hidden {
display: none;
}
.account-notification-row {
grid-template-columns: minmax(0, 1fr) auto;
}
.account-notification-row .checkbox-row {
margin: 0;
white-space: nowrap;
}
+11 -1
View File
@@ -590,4 +590,14 @@ html[data-preview="bottom"] .reader-pane {
.compose-mode-control select {
min-width: 108px;
}
}
}
@media (max-width: 820px) {
.signature-html-body {
grid-template-columns: 1fr;
}
.signature-html-source-pane textarea,
.signature-html-preview-shell {
min-height: 280px;
}
}
+58 -1
View File
@@ -43,7 +43,7 @@
<div id="login-error" class="form-error hidden" role="alert"></div>
<button class="button primary wide" type="submit">Sign in</button>
</form>
<footer>RustBox __RUSTBOX_VERSION__</footer>
<footer>RustBox</footer>
</div>
</section>
@@ -84,6 +84,12 @@
<button id="logout-btn" class="icon-button" type="button" title="Sign out" aria-label="Sign out"><svg>
<use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-log-out"></use>
</svg></button>
<details id="account-switcher" class="toolbar-menu account-switcher hidden">
<summary class="icon-button" title="Switch account" aria-label="Switch account"><svg aria-hidden="true">
<use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-user-round"></use>
</svg></summary>
<div id="account-switcher-menu" class="toolbar-menu-popover" role="menu" aria-label="Switch account"></div>
</details>
<div class="account-summary"><span id="account-email">—</span><span id="connection-label">Connecting</span>
</div>
</div>
@@ -181,6 +187,8 @@
</div>
<div id="message-list" class="message-list" aria-live="polite"></div>
<div id="message-pagination" class="message-pagination hidden" aria-label="Mailbox pages">
<button id="page-first" class="icon-button pagination-arrow pagination-jump" type="button" title="First page"
aria-label="First page"><span aria-hidden="true">«</span></button>
<button id="page-prev" class="icon-button pagination-arrow" type="button" title="Previous page"
aria-label="Previous page"><svg aria-hidden="true">
<use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-arrow-left"></use>
@@ -190,6 +198,8 @@
aria-label="Next page"><svg aria-hidden="true">
<use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-chevron-right"></use>
</svg></button>
<button id="page-last" class="icon-button pagination-arrow pagination-jump" type="button" title="Last page"
aria-label="Last page"><span aria-hidden="true">»</span></button>
</div>
<div id="message-load-actions" class="message-load-actions hidden">
<button id="load-more" class="button quiet load-more" type="button">Load more</button>
@@ -1200,6 +1210,15 @@
</div><button id="account-add" class="button quiet" type="button">Add account</button>
</div>
<div id="account-settings-list" class="account-settings-list"></div>
<div id="multi-account-notifications" class="multi-account-notifications">
<div class="settings-panel-heading account-session-heading">
<div>
<h3>Notifications from accounts</h3>
<p class="muted">Choose which signed-in accounts can show combined new-mail notifications in this browser.</p>
</div>
</div>
<div id="account-notification-list" class="account-settings-list"></div>
</div>
<div class="settings-panel-heading account-session-heading">
<div>
<h3>Active sessions</h3>
@@ -1280,6 +1299,8 @@
<use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-link"></use>
</svg></button><button type="button" id="identity-signature-image-btn" title="Import image"><svg>
<use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-image"></use>
</svg></button><button type="button" id="identity-signature-html-btn" title="Paste or edit HTML source"><svg>
<use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-file-text"></use>
</svg></button><button type="button" data-signature-command="removeFormat"
title="Clear formatting"><svg>
<use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-remove-format"></use>
@@ -1334,6 +1355,42 @@
</form>
</dialog>
<dialog id="identity-signature-html-dialog" class="dialog signature-html-dialog" data-i18n-scope>
<div class="dialog-card signature-html-card">
<header class="dialog-header"><strong>HTML signature editor</strong><button type="button"
class="icon-button close-dialog" aria-label="Close"><svg>
<use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-x"></use>
</svg></button></header>
<div class="signature-html-body">
<section class="signature-html-source-pane">
<div class="section-heading"><span>HTML source</span><button id="identity-signature-html-preview" type="button"
class="button quiet small">Refresh preview</button></div>
<textarea id="identity-signature-html-source" spellcheck="false"
placeholder="Paste a complete HTML signature or an HTML fragment here"></textarea>
<small class="muted">Scripts, forms and unsafe attributes are removed before the signature is used.</small>
</section>
<section class="signature-html-preview-pane">
<div class="section-heading"><span>Visual editor</span><span class="muted">You can add images and adjust the rendered signature.</span></div>
<div class="signature-editor-shell signature-html-preview-shell">
<div class="signature-editor-toolbar" aria-label="HTML signature formatting">
<button type="button" data-signature-html-command="bold" title="Bold"><svg><use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-bold"></use></svg></button>
<button type="button" data-signature-html-command="italic" title="Italic"><svg><use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-italic"></use></svg></button>
<button type="button" data-signature-html-command="underline" title="Underline"><svg><use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-underline"></use></svg></button>
<button type="button" id="identity-signature-html-link" title="Insert link"><svg><use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-link"></use></svg></button>
<button type="button" id="identity-signature-html-image-btn" title="Import image"><svg><use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-image"></use></svg></button>
<button type="button" data-signature-html-command="removeFormat" title="Clear formatting"><svg><use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-remove-format"></use></svg></button>
</div>
<div id="identity-signature-html-preview-editor" class="signature-editor signature-html-preview-editor"
contenteditable="true" role="textbox" aria-multiline="true" aria-label="Rendered HTML signature"></div>
</div>
<input id="identity-signature-html-image" type="file" accept="image/png,image/jpeg,image/gif,image/webp" hidden>
</section>
</div>
<footer class="dialog-actions"><button type="button" class="button quiet close-dialog">Cancel</button><button
id="identity-signature-html-apply" type="button" class="button primary">Use this HTML</button></footer>
</div>
</dialog>
<dialog id="sieve-rule-dialog" class="dialog sieve-rule-dialog" data-i18n-scope>
<div class="dialog-card sieve-rule-card">
<header class="dialog-header">
+7 -1
View File
@@ -19,10 +19,12 @@
total: 0,
loadingMoreMessages: false,
loadingAllMessages: false,
messageLoadPending: 0,
messages: [],
selected: null,
selectedMessages: new Set(),
selectionMode: false,
selectionAnchorKey: null,
searchContext: null,
searchAllResults: null,
currentDetail: null,
@@ -35,7 +37,7 @@
pgpKeys: [],
pgpPrefs: { wkdEnabled: true, hkpEnabled: false, requireTrustedEncryption: false },
pgpRecipientStatus: [],
authPolicy: { rememberMeEnabled: true, pgpEnabled: false, sieveEnabled: false, diagnosticsEnabled: false, aboutEnabled: false },
authPolicy: { rememberMeEnabled: true, pgpEnabled: false, sieveEnabled: false, diagnosticsEnabled: false, aboutEnabled: false, multiAccountEnabled: false },
contacts: [],
trustedSenders: new Set(),
contactGroups: [],
@@ -47,6 +49,8 @@
savedSearches: [],
responseTemplates: [],
notificationPrefs: { enabled: true, sound: false, folders: ["INBOX"], dnd_enabled: false, dnd_start: "22:00", dnd_end: "07:00" },
notificationPrefsByAccount: new Map(),
notificationAccountIds: new Set(),
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,
@@ -89,7 +93,9 @@
composeInsertRange: null,
composeSelectedImage: null,
identitySignatureRange: null,
identitySignatureHtmlRange: null,
inlineAttachmentIds: new Set(),
signatureAttachmentIds: new Set(),
inlineImageCleanupTimer: null,
imageEditorBitmap: null,
imageEditorBlob: null,
+9 -6
View File
@@ -77,19 +77,22 @@ function scheduleMessageListStateSave() {
}
async function request(path, options = {}) {
const headers = new Headers(options.headers || {});
if (state.accountId) headers.set("X-Active-Account", state.accountId);
if (options.body && !(options.body instanceof FormData)) headers.set("Content-Type", "application/json");
if (options.method && options.method !== "GET") headers.set("X-Requested-With", "XMLHttpRequest");
const requestOptions = { ...options };
const activeAccountId = Object.prototype.hasOwnProperty.call(requestOptions, "accountId") ? requestOptions.accountId : state.accountId;
delete requestOptions.accountId;
const headers = new Headers(requestOptions.headers || {});
if (activeAccountId) headers.set("X-Active-Account", activeAccountId);
if (requestOptions.body && !(requestOptions.body instanceof FormData)) headers.set("Content-Type", "application/json");
if (requestOptions.method && requestOptions.method !== "GET") headers.set("X-Requested-With", "XMLHttpRequest");
const method = String(options.method || "GET").toUpperCase();
const method = String(requestOptions.method || "GET").toUpperCase();
const maxAttempts = method === "GET" || method === "HEAD" ? 3 : 1;
let lastNetworkError = null;
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
try {
const response = await fetch(apiPath(path), {
credentials: "include",
...options,
...requestOptions,
headers,
});
if ([502, 503, 504].includes(response.status) && attempt + 1 < maxAttempts) {
+133 -7
View File
@@ -654,6 +654,10 @@ function applyFeatureAvailability() {
}
$("#diagnostic-sieve-card")?.classList.toggle("hidden", state.authPolicy.sieveEnabled === false);
$("#quick-filter-btn")?.classList.toggle("hidden", state.authPolicy.sieveEnabled === false);
$("#account-add")?.classList.toggle("hidden", !multiAccountEnabled());
renderAccountSwitcher();
renderAccountNotificationSettings();
renderAccountSettings();
}
async function loadAuthPolicy() {
@@ -677,6 +681,103 @@ async function loadAuthPolicy() {
});
}
const NOTIFICATION_ACCOUNTS_STORAGE_KEY = "rustbox.notificationAccounts";
function multiAccountEnabled() {
return state.authPolicy.multiAccountEnabled === true;
}
function syncNotificationAccountSelection() {
let storedEmails = null;
try {
const raw = localStorage.getItem(NOTIFICATION_ACCOUNTS_STORAGE_KEY);
if (raw !== null) {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) storedEmails = new Set(parsed.map((value) => String(value).trim().toLowerCase()).filter(Boolean));
}
} catch (_) { }
const selected = storedEmails === null
? state.accounts.map((account) => account.id)
: state.accounts
.filter((account) => storedEmails.has(String(account.email || "").trim().toLowerCase()))
.map((account) => account.id);
state.notificationAccountIds = new Set(selected);
}
function saveNotificationAccountSelection() {
const selectedEmails = state.accounts
.filter((account) => state.notificationAccountIds.has(account.id))
.map((account) => String(account.email || "").trim().toLowerCase())
.filter(Boolean);
localStorage.setItem(NOTIFICATION_ACCOUNTS_STORAGE_KEY, JSON.stringify(selectedEmails));
}
function notificationAccountEnabled(accountId) {
if (!accountId) return false;
if (!multiAccountEnabled()) return accountId === state.accountId;
return state.notificationAccountIds.has(accountId);
}
function renderAccountSwitcher() {
const switcher = $("#account-switcher");
const menu = $("#account-switcher-menu");
if (!switcher || !menu) return;
const visible = multiAccountEnabled() && state.accounts.length > 1;
if (!visible) {
switcher.open = false;
switcher.classList.add("hidden");
menu.replaceChildren();
return;
}
switcher.classList.remove("hidden");
menu.replaceChildren();
for (const account of state.accounts) {
const button = el("button", account.id === state.accountId ? "active" : "");
button.type = "button";
button.setAttribute("role", "menuitem");
button.append(
el("span", "", account.email || "Account"),
el("span", "account-switcher-state", account.id === state.accountId ? "Current" : "Switch"),
);
button.addEventListener("click", async () => {
switcher.open = false;
if (account.id !== state.accountId) await switchAccount(account.id);
});
menu.append(button);
}
}
function renderAccountNotificationSettings() {
const section = $("#multi-account-notifications");
const list = $("#account-notification-list");
if (!section || !list) return;
const visible = multiAccountEnabled() && state.accounts.length > 1;
section.classList.toggle("hidden", !visible);
list.replaceChildren();
if (!visible) return;
for (const account of state.accounts) {
const row = el("div", "account-settings-row account-notification-row");
const details = el("div", "account-session-details");
details.append(
el("strong", "account-email", account.email || "Account"),
el("span", "muted", account.id === state.accountId ? "Current account" : "Signed in account"),
);
const label = el("label", "checkbox-row");
const input = document.createElement("input");
input.type = "checkbox";
input.checked = state.notificationAccountIds.has(account.id);
input.addEventListener("change", () => {
if (input.checked) state.notificationAccountIds.add(account.id);
else state.notificationAccountIds.delete(account.id);
saveNotificationAccountSelection();
if (input.checked && typeof loadNotificationPreferencesForAccount === "function") void loadNotificationPreferencesForAccount(account.id);
});
label.append(input, el("span", "", "Notify"));
row.append(details, label);
list.append(row);
}
}
async function loadSessionPolicy() {
try {
const policy = await request("/auth/session-policy", { method: "GET" });
@@ -688,6 +789,7 @@ async function loadSessionPolicy() {
sieveEnabled: false,
diagnosticsEnabled: false,
aboutEnabled: false,
multiAccountEnabled: false,
};
}
setOpenPgpAvailability(state.authPolicy.pgpEnabled !== false);
@@ -702,6 +804,8 @@ async function loadAccounts() {
state.accountId = null;
localStorage.removeItem("rustbox.activeAccount");
state.folders = [];
state.notificationAccountIds.clear();
state.notificationPrefsByAccount.clear();
updateUnreadAppBadge();
showLogin();
return false;
@@ -712,6 +816,9 @@ async function loadAccounts() {
}
const account = state.accounts.find((item) => item.id === state.accountId) || state.accounts[0];
$("#account-email").textContent = account.email;
syncNotificationAccountSelection();
renderAccountSwitcher();
renderAccountNotificationSettings();
showApp();
return true;
} catch (error) {
@@ -725,23 +832,33 @@ function renderAccountSettings() {
const list = $("#account-settings-list");
if (!list) return;
list.replaceChildren();
if (!state.accounts.length) {
const accounts = multiAccountEnabled()
? state.accounts
: state.accounts.filter((account) => account.id === state.accountId);
if (!accounts.length) {
list.append(el("div", "empty-list", "No active accounts."));
renderAccountSwitcher();
renderAccountNotificationSettings();
return;
}
for (const account of state.accounts) {
for (const account of accounts) {
const row = el("div", `account-settings-row ${account.id === state.accountId ? "active" : ""}`);
const email = el("span", "account-email", account.email || "Account");
const use = el("button", "button quiet", account.id === state.accountId ? "Current" : "Switch");
use.type = "button";
use.disabled = account.id === state.accountId;
use.disabled = account.id === state.accountId || !multiAccountEnabled();
use.addEventListener("click", () => switchAccount(account.id));
const remove = el("button", "button quiet danger", "Remove");
remove.type = "button";
remove.addEventListener("click", () => removeBrowserAccount(account.id));
row.append(email, use, remove);
row.append(email, use);
if (multiAccountEnabled()) {
const remove = el("button", "button quiet danger", "Remove");
remove.type = "button";
remove.addEventListener("click", () => removeBrowserAccount(account.id));
row.append(remove);
}
list.append(row);
}
renderAccountSwitcher();
renderAccountNotificationSettings();
}
async function loadActiveSessions() {
@@ -824,15 +941,19 @@ async function reloadActiveAccount() {
}
async function switchAccount(accountId) {
if (!multiAccountEnabled()) return;
if (!state.accounts.some((account) => account.id === accountId) || accountId === state.accountId) return;
state.accountId = accountId;
localStorage.setItem("rustbox.activeAccount", accountId);
const account = state.accounts.find((item) => item.id === accountId);
if (account) $("#account-email").textContent = account.email;
renderAccountSwitcher();
renderAccountNotificationSettings();
try { await reloadActiveAccount(); } catch (error) { toast(error.message, "error"); }
}
async function removeBrowserAccount(accountId) {
if (!multiAccountEnabled()) return;
const account = state.accounts.find((item) => item.id === accountId);
if (!account || !await appConfirm(`Remove ${account.email} from this browser session?`, { title: "Remove account", confirmLabel: "Remove", danger: true })) return;
try {
@@ -842,11 +963,14 @@ async function removeBrowserAccount(accountId) {
if (!state.accounts.length) return;
if (removingCurrent) await reloadActiveAccount();
else renderAccountSettings();
disconnectRealtime();
connectRealtime();
} catch (error) { toast(error.message, "error"); }
}
async function addBrowserAccount(event) {
event.preventDefault();
if (!multiAccountEnabled()) return toast("Multiple accounts are disabled by the RustBox administrator", "warning");
const errorBox = $("#add-account-error");
const submit = $("#add-account-form button[type=submit]");
errorBox.classList.add("hidden");
@@ -917,6 +1041,8 @@ async function logout() {
try { await request("/auth/logout", { method: "POST", body: "{}" }); } catch (_) { }
state.accountId = null;
state.accounts = [];
state.notificationAccountIds.clear();
state.notificationPrefsByAccount.clear();
localStorage.removeItem("rustbox.activeAccount");
showLogin();
}
+26
View File
@@ -348,20 +348,46 @@ function setSelectionMode(enabled) {
if (!state.selectionMode) {
state.selectedMessages.clear();
state.searchAllResults = null;
state.selectionAnchorKey = null;
}
renderMessages();
}
function setMessageSelectionAnchor(message) {
state.selectionAnchorKey = message ? messageKey(message) : null;
}
function setMessageSelected(message, selected) {
if (!state.selectionMode) state.selectionMode = true;
const key = messageKey(message);
selected ? state.selectedMessages.add(key) : state.selectedMessages.delete(key);
state.selectionAnchorKey = key;
renderMessages();
}
function selectMessageRange(message) {
if (!message) return;
if (!state.selectionMode) state.selectionMode = true;
const source = state.messages;
const targetKey = messageKey(message);
const targetIndex = source.findIndex((item) => messageKey(item) === targetKey);
const anchorIndex = source.findIndex((item) => messageKey(item) === state.selectionAnchorKey);
if (targetIndex < 0 || anchorIndex < 0) {
state.selectedMessages.add(targetKey);
state.selectionAnchorKey = targetKey;
renderMessages();
return;
}
const start = Math.min(anchorIndex, targetIndex);
const end = Math.max(anchorIndex, targetIndex);
for (let index = start; index <= end; index += 1) state.selectedMessages.add(messageKey(source[index]));
renderMessages();
}
function clearBulkSelection() {
state.selectedMessages.clear();
state.searchAllResults = null;
state.selectionAnchorKey = null;
renderMessages();
}
+183 -49
View File
@@ -18,11 +18,15 @@ function updateMessageLoadControls() {
const pages = mailboxPageCount();
const showPagination = !searchable && mode === "pagination" && pages > 1;
pagination.classList.toggle("hidden", !showPagination);
const first = $("#page-first");
const previous = $("#page-prev");
const next = $("#page-next");
const last = $("#page-last");
const status = $("#page-status");
if (first) first.disabled = state.page <= 0;
if (previous) previous.disabled = state.page <= 0;
if (next) next.disabled = state.page >= pages - 1;
if (last) last.disabled = state.page >= pages - 1;
if (status) status.textContent = `${tr("page", "Page")} ${Math.min(state.page + 1, pages)} ${tr("of", "of")} ${pages}`;
}
@@ -43,11 +47,13 @@ async function loadMessages(reset = false, options = {}) {
if (!options.background) {
state.messages = [];
state.selectedMessages.clear();
state.selectionAnchorKey = null;
clearActiveSearchContext();
list.innerHTML = `<div class="empty-list">Loading messages…</div>`;
}
}
$("#folder-title").textContent = state.folder;
beginMessageLoadActivity();
try {
const sort = encodeURIComponent(state.mailPrefs.default_sort || "date_desc");
const maxRestorePage = Math.max(0, Math.ceil(500 / Math.max(20, state.perPage)) - 1);
@@ -109,13 +115,15 @@ async function loadMessages(reset = false, options = {}) {
$("#message-count").textContent = state.messages.length ? `${state.total} messages · cached view` : "Unavailable";
updateMessageLoadControls();
return false;
} finally {
endMessageLoadActivity();
}
}
async function changeMailboxPage(delta) {
async function goToMailboxPage(targetPage) {
if (state.searchContext || mailboxLoadingMode() !== "pagination") return false;
const pages = mailboxPageCount();
const target = Math.max(0, Math.min(pages - 1, state.page + delta));
const target = Math.max(0, Math.min(pages - 1, Number(targetPage) || 0));
if (target === state.page) return false;
const previousPage = state.page;
state.page = target;
@@ -131,6 +139,10 @@ async function changeMailboxPage(delta) {
return true;
}
async function changeMailboxPage(delta) {
return goToMailboxPage(state.page + Number(delta || 0));
}
async function loadNextProgressivePage() {
if (state.searchContext || mailboxLoadingMode() !== "progressive" || state.loadingMoreMessages || state.messages.length >= state.total) return;
state.loadingMoreMessages = true;
@@ -157,6 +169,7 @@ function handleMessageListScroll() {
async function loadAllMessages() {
if (state.searchContext || mailboxLoadingMode() !== "all" || state.messages.length >= state.total || state.loadingAllMessages) return;
state.loadingAllMessages = true;
beginMessageLoadActivity();
try {
let guard = 0;
while (state.messages.length < state.total && guard < 10000) {
@@ -173,6 +186,7 @@ async function loadAllMessages() {
saveMessageListState();
} finally {
state.loadingAllMessages = false;
endMessageLoadActivity();
updateMessageLoadControls();
}
}
@@ -317,7 +331,13 @@ function renderMessages() {
checkbox.checked = checked;
checkbox.disabled = !state.selectionMode;
checkbox.setAttribute("aria-label", "Select message");
checkbox.addEventListener("click", (event) => { event.stopPropagation(); });
checkbox.addEventListener("click", (event) => {
event.stopPropagation();
if (event.shiftKey) {
event.preventDefault();
selectMessageRange(message);
}
});
checkbox.addEventListener("change", () => setMessageSelected(message, checkbox.checked));
row.innerHTML = `<span class="message-state" aria-hidden="true"></span><div class="message-main"><span class="message-from"></span><time class="message-date">${escapeHtml(formatDate(message.date))}</time></div><div class="message-subject-line"><span class="message-subject"></span><span class="message-snippet"></span></div><span class="message-icons"></span>`;
renderHighlightedText(row.querySelector(".message-from"), message.from_name || message.from_address || "Unknown", highlightTerms);
@@ -381,7 +401,13 @@ function renderMessages() {
});
row.addEventListener("dragend", () => row.classList.remove("dragging"));
row.addEventListener("click", (event) => {
if (event.shiftKey) {
event.preventDefault();
selectMessageRange(message);
return;
}
if (state.selectionMode || event.ctrlKey || event.metaKey) return setMessageSelected(message, !state.selectedMessages.has(messageKey(message)));
setMessageSelectionAnchor(message);
openMessage(folder, message.uid);
});
row.addEventListener("keydown", (event) => { if (event.key === "Enter" && !state.selectionMode) openMessage(folder, message.uid); if (event.key === " ") { event.preventDefault(); setMessageSelected(message, !state.selectedMessages.has(messageKey(message))); } });
@@ -518,37 +544,42 @@ function messageLooksLikeImapDraft(folder, uid) {
}
async function openMessage(folder, uid) {
if (messageLooksLikeImapDraft(folder, uid)) {
try {
await openImapDraftComposer(folder, uid);
return;
} catch (error) {
toast(error.message || "Could not open IMAP draft", "error");
}
}
clearTimeout(state.markReadTimer);
state.markReadTimer = null;
state.selected = { folder, uid };
updateActiveMessageRow();
updateMessageNavigation();
$("#reader-empty").classList.add("hidden");
$("#reader-content").classList.remove("hidden");
$("#reader-subject").textContent = "Loading…";
$("#message-body").replaceChildren();
$("#reader").classList.add("mobile-open");
beginMessageLoadActivity();
try {
const detail = await request(`/messages/${encodeURIComponent(folder)}/${uid}`, { method: "GET" });
if (state.mailPrefs.remote_images === "contacts" && detail.from_address) {
detail._senderInContacts = await senderIsSavedContact(detail.from_address);
if (messageLooksLikeImapDraft(folder, uid)) {
try {
await openImapDraftComposer(folder, uid);
return;
} catch (error) {
toast(error.message || "Could not open IMAP draft", "error");
}
}
if (detail.from_address) detail._senderTrusted = state.trustedSenders.has(String(detail.from_address).trim().toLowerCase());
state.currentDetail = detail;
renderMessageDetail(detail);
renderMoveOptions();
scheduleMarkRead(detail);
} catch (error) {
$("#reader-subject").textContent = "Unable to open message";
$("#message-body").innerHTML = `<div class="empty-list">${escapeHtml(error.message)}</div>`;
clearTimeout(state.markReadTimer);
state.markReadTimer = null;
state.selected = { folder, uid };
updateActiveMessageRow();
updateMessageNavigation();
$("#reader-empty").classList.add("hidden");
$("#reader-content").classList.remove("hidden");
$("#reader-subject").textContent = "Loading…";
$("#message-body").replaceChildren();
$("#reader").classList.add("mobile-open");
try {
const detail = await request(`/messages/${encodeURIComponent(folder)}/${uid}`, { method: "GET" });
if (state.mailPrefs.remote_images === "contacts" && detail.from_address) {
detail._senderInContacts = await senderIsSavedContact(detail.from_address);
}
if (detail.from_address) detail._senderTrusted = state.trustedSenders.has(String(detail.from_address).trim().toLowerCase());
state.currentDetail = detail;
renderMessageDetail(detail);
renderMoveOptions();
scheduleMarkRead(detail);
} catch (error) {
$("#reader-subject").textContent = "Unable to open message";
$("#message-body").innerHTML = `<div class="empty-list">${escapeHtml(error.message)}</div>`;
}
} finally {
endMessageLoadActivity();
}
}
@@ -907,12 +938,60 @@ function adaptiveEmailReadableColor(color, background, preferred) {
return adaptiveEmailContrast(white, background) >= adaptiveEmailContrast(black, background) ? white : black;
}
function adaptiveEmailThemePalette(body, targetTheme) {
const targetDark = targetTheme === "dark";
const rootStyles = getComputedStyle(root);
const bodyStyles = getComputedStyle(body);
const defaults = targetDark
? { surface: { r: 24, g: 28, b: 25, a: 1 }, text: { r: 237, g: 240, b: 236, a: 1 } }
: { surface: { r: 255, g: 255, b: 255, a: 1 }, text: { r: 32, g: 38, b: 34, a: 1 } };
const surfaceCandidates = [
bodyStyles.backgroundColor,
rootStyles.getPropertyValue("--reader-bg"),
rootStyles.getPropertyValue("--panel"),
rootStyles.getPropertyValue("--bg"),
].map(parseAdaptiveEmailColor).filter(Boolean);
const surfaceMatchesTheme = (color) => targetDark
? adaptiveEmailBrightness(color) <= 0.46
: adaptiveEmailBrightness(color) >= 0.54;
const surface = surfaceCandidates.find(surfaceMatchesTheme) || defaults.surface;
const textCandidates = [
bodyStyles.color,
rootStyles.getPropertyValue("--text"),
].map(parseAdaptiveEmailColor).filter(Boolean);
const text = textCandidates.find((color) => adaptiveEmailContrast(color, surface) >= 4.5)
|| adaptiveEmailReadableColor(defaults.text, surface, defaults.text);
return { surface, text };
}
function adaptiveEmailMapCssDeclarations(css, mapBackground, mapText, mapBorder) {
if (!css) return css;
const colorToken = /#[0-9a-f]{3,8}\b|rgba?\([^)]*\)|\b(?:black|white|gray|grey|silver|navy|red|green|blue|yellow|maroon|purple|teal|olive|aqua|fuchsia|lime|whitesmoke|gainsboro)\b/gi;
return css.replace(/(^|[;{])\s*([a-z-]+)\s*:\s*([^;{}]+)/gi, (whole, prefix, property, value) => {
const prop = String(property || "").toLowerCase();
let mapper = null;
if (prop === "color") mapper = mapText;
else if (prop === "background-color" || (prop === "background" && !/url\s*\(/i.test(value))) mapper = mapBackground;
else if (/^border(?:-(?:top|right|bottom|left))?(?:-color)?$/.test(prop)) mapper = mapBorder;
if (!mapper) return whole;
const next = String(value).replace(colorToken, (token) => {
const parsed = parseAdaptiveEmailColor(token);
const mapped = parsed ? mapper(parsed) : null;
return mapped ? adaptiveEmailRgbCss(mapped) : token;
});
return `${prefix}${property}:${next}`;
});
}
function adaptMessagePreviewColors(html, detail, body) {
const targetTheme = resolveTheme(state.prefs.theme);
const targetDark = targetTheme === "dark";
const bodyStyles = getComputedStyle(body);
const surface = parseAdaptiveEmailColor(bodyStyles.backgroundColor) || (targetDark ? { r: 24, g: 28, b: 25, a: 1 } : { r: 255, g: 255, b: 255, a: 1 });
const text = parseAdaptiveEmailColor(bodyStyles.color) || (targetDark ? { r: 237, g: 240, b: 236, a: 1 } : { r: 32, g: 38, b: 34, a: 1 });
const palette = adaptiveEmailThemePalette(body, targetTheme);
const surface = palette.surface;
const text = palette.text;
const mailTheme = String(detail?.email_theme || "").toLowerCase();
const themeAlreadyMatches = mailTheme === targetTheme;
const doc = new DOMParser().parseFromString(`<body>${html}</body>`, "text/html");
@@ -922,11 +1001,13 @@ function adaptMessagePreviewColors(html, detail, body) {
const brightness = adaptiveEmailBrightness(color);
const saturation = adaptiveEmailSaturation(color);
if (saturation > 0.38) return null;
if (targetDark && brightness >= 0.62) {
return adaptiveEmailMix(surface, text, Math.max(0, Math.min(0.12, (1 - brightness) * 0.26)));
if (targetDark && brightness >= 0.54) {
const depth = Math.max(0, Math.min(0.14, (1 - brightness) * 0.30));
return adaptiveEmailMix(surface, text, depth);
}
if (!targetDark && brightness <= 0.38) {
return adaptiveEmailMix(surface, text, Math.max(0, Math.min(0.10, brightness * 0.24)));
if (!targetDark && brightness <= 0.46) {
const depth = Math.max(0, Math.min(0.12, brightness * 0.28));
return adaptiveEmailMix(surface, text, depth);
}
return null;
};
@@ -936,11 +1017,58 @@ function adaptMessagePreviewColors(html, detail, body) {
const brightness = adaptiveEmailBrightness(color);
const saturation = adaptiveEmailSaturation(color);
if (saturation > 0.32) return null;
if (targetDark && brightness <= 0.45) return adaptiveEmailMix(text, surface, brightness * 0.28);
if (!targetDark && brightness >= 0.62) return adaptiveEmailMix(text, surface, (1 - brightness) * 0.28);
if (targetDark && brightness <= 0.52) return adaptiveEmailMix(text, surface, brightness * 0.24);
if (!targetDark && brightness >= 0.56) return adaptiveEmailMix(text, surface, (1 - brightness) * 0.24);
return null;
};
const remapBorder = (color) => {
if (!color || color.a < 0.35 || themeAlreadyMatches) return null;
const saturation = adaptiveEmailSaturation(color);
if (saturation > 0.28) return null;
const brightness = adaptiveEmailBrightness(color);
if (targetDark && brightness >= 0.58) return adaptiveEmailMix(surface, text, 0.22);
if (!targetDark && brightness <= 0.42) return adaptiveEmailMix(surface, text, 0.18);
return null;
};
const mapBackground = (color) => themeAlreadyMatches ? null : remapBackground(color);
const mapText = (color, background = surface) => {
if (!color) return null;
const neutralMapped = themeAlreadyMatches ? null : remapNeutralText(color);
const candidate = neutralMapped || color;
const readable = adaptiveEmailReadableColor(candidate, background, text);
return neutralMapped || readable !== candidate ? readable : null;
};
let messageSurface = surface;
let messageText = text;
if (detail?.html) {
const sourceDoc = new DOMParser().parseFromString(String(detail.html), "text/html");
const sourceBody = sourceDoc.body;
const sourceBodyBackground = parseAdaptiveEmailColor(sourceBody?.style?.backgroundColor || sourceBody?.getAttribute("bgcolor"));
if (sourceBodyBackground) {
const composited = adaptiveEmailComposite(sourceBodyBackground, surface);
messageSurface = mapBackground(composited) || composited;
}
const sourceBodyText = parseAdaptiveEmailColor(sourceBody?.style?.color || sourceBody?.getAttribute("text"));
if (sourceBodyText) {
const composited = adaptiveEmailComposite(sourceBodyText, messageSurface);
messageText = mapText(composited, messageSurface) || composited;
} else if (adaptiveEmailContrast(messageText, messageSurface) < 4.5) {
messageText = adaptiveEmailReadableColor(messageText, messageSurface, text);
}
}
doc.querySelectorAll("style").forEach((styleNode) => {
styleNode.textContent = adaptiveEmailMapCssDeclarations(
styleNode.textContent || "",
mapBackground,
(color) => mapText(color, messageSurface),
remapBorder,
);
});
const walk = (node, inheritedBackground) => {
let effectiveBackground = inheritedBackground;
const style = node.style;
@@ -948,10 +1076,9 @@ function adaptMessagePreviewColors(html, detail, body) {
const sourceBackground = parseAdaptiveEmailColor(style?.backgroundColor || backgroundAttribute);
if (sourceBackground) {
const composited = adaptiveEmailComposite(sourceBackground, inheritedBackground);
const mappedBackground = themeAlreadyMatches ? null : remapBackground(composited);
const mappedBackground = mapBackground(composited);
if (mappedBackground) {
if (style) style.backgroundColor = adaptiveEmailRgbCss(mappedBackground);
else node.setAttribute("bgcolor", adaptiveEmailRgbCss(mappedBackground));
if (backgroundAttribute) node.setAttribute("bgcolor", adaptiveEmailRgbCss(mappedBackground));
effectiveBackground = mappedBackground;
} else {
@@ -963,20 +1090,27 @@ function adaptMessagePreviewColors(html, detail, body) {
const sourceColor = parseAdaptiveEmailColor(style?.color || colorAttribute);
if (sourceColor) {
const composited = adaptiveEmailComposite(sourceColor, effectiveBackground);
const neutralMapped = themeAlreadyMatches ? null : remapNeutralText(composited);
const readable = adaptiveEmailReadableColor(neutralMapped || composited, effectiveBackground, text);
if (neutralMapped || readable !== composited) {
const replacement = adaptiveEmailRgbCss(readable);
const mappedText = mapText(composited, effectiveBackground);
if (mappedText) {
const replacement = adaptiveEmailRgbCss(mappedText);
if (style) style.color = replacement;
if (colorAttribute) node.setAttribute("color", replacement);
}
}
if (style) {
for (const property of ["borderTopColor", "borderRightColor", "borderBottomColor", "borderLeftColor", "outlineColor"]) {
const parsed = parseAdaptiveEmailColor(style[property]);
const mapped = remapBorder(parsed);
if (mapped) style[property] = adaptiveEmailRgbCss(mapped);
}
}
for (const child of node.children) walk(child, effectiveBackground);
};
for (const child of doc.body.children) walk(child, surface);
return { html: doc.body.innerHTML, targetTheme, surface: adaptiveEmailRgbCss(surface), text: adaptiveEmailRgbCss(text) };
for (const child of doc.body.children) walk(child, messageSurface);
return { html: doc.body.innerHTML, targetTheme, surface: adaptiveEmailRgbCss(messageSurface), text: adaptiveEmailRgbCss(messageText) };
}
function renderMessageBody(detail, forceRemote = false) {
+26 -4
View File
@@ -214,8 +214,9 @@ function renderComposeAttachments() {
box.replaceChildren();
const attachments = mergeComposeAttachments(state.composeAttachments);
state.composeAttachments = attachments;
box.classList.toggle("hidden", !attachments.length);
for (const attachment of attachments) {
const visibleAttachments = attachments.filter((attachment) => !state.signatureAttachmentIds.has(String(attachment.id || "")));
box.classList.toggle("hidden", !visibleAttachments.length);
for (const attachment of visibleAttachments) {
const id = String(attachment.id || "");
const filename = attachment.filename || "attachment";
const isImage = composeAttachmentIsImage(attachment);
@@ -306,6 +307,12 @@ function syncComposeInlineAttachments() {
if (changed) renderComposeAttachments();
}
function trackSignatureInlineAttachment(id) {
const value = String(id || "");
if (!value) return;
state.signatureAttachmentIds.add(value);
}
function composeAttachmentInsertionRange(range, editor = $("#compose-editor")) {
const target = range?.cloneRange?.() || document.createRange();
try {
@@ -533,6 +540,7 @@ function hydrateComposeHtml(html, attachments = state.composeAttachments, draftI
const contentUrl = composeAttachmentUrl(attachment, draftId);
if (!contentUrl) return;
state.inlineAttachmentIds.add(id);
if (image.closest('.compose-signature, [data-rustbox-signature="true"]')) trackSignatureInlineAttachment(id);
image.dataset.rustboxCid = id;
image.draggable = true;
image.setAttribute("src", contentUrl);
@@ -749,6 +757,7 @@ async function deleteComposeDraft() {
state.composeRemoteDraft = null;
state.composeAttachments = [];
state.inlineAttachmentIds = new Set();
state.signatureAttachmentIds = new Set();
setSelectedInlineImage(null);
state.composeReplyMeta = null;
state.composeDirty = false;
@@ -853,6 +862,7 @@ async function uploadComposeFiles(files, options = {}) {
setComposeUploadProgress(true, overall, `${trText("Uploading attachment…")} ${file.name} · ${overall}%`);
}, controller.signal);
const uploaded = Array.isArray(data.attachments) ? data.attachments : [];
if (options.signature) uploaded.forEach((attachment) => trackSignatureInlineAttachment(attachment.id));
added.push(...uploaded);
state.composeAttachments = mergeComposeAttachments(state.composeAttachments, uploaded);
renderComposeAttachments();
@@ -1390,11 +1400,14 @@ async function applyInlineImageEditor(event) {
function scheduleUnusedInlineAttachmentCleanup() {
clearTimeout(state.inlineImageCleanupTimer);
state.inlineImageCleanupTimer = setTimeout(syncComposeInlineAttachments, 250);
state.inlineImageCleanupTimer = setTimeout(() => { void cleanupUnusedInlineAttachments(); }, 250);
}
async function cleanupUnusedInlineAttachments() {
syncComposeInlineAttachments();
const referenced = composeInlineAttachmentIds();
const staleSignatureIds = [...state.signatureAttachmentIds].filter((id) => !referenced.has(id));
for (const id of staleSignatureIds) await deleteComposeAttachment(id);
}
function editorRangeFromPoint(x, y) {
@@ -1438,6 +1451,7 @@ async function openImapDraftComposer(folder, uid) {
state.composeRemoteDraft = draft.imap_source || { folder, uid, uid_validity: 0 };
state.composeAttachments = mergeComposeAttachments(draft.attachments);
state.inlineAttachmentIds = new Set();
state.signatureAttachmentIds = new Set();
setSelectedInlineImage(null);
state.composeReplyMeta = {
inReplyTo: draft.in_reply_to || null,
@@ -1482,6 +1496,7 @@ function applyRecoveredDraft(draft, sourceLabel = "Saved draft") {
state.composeRemoteDraft = draft.imap_source || null;
state.composeAttachments = mergeComposeAttachments(draft.attachments);
state.inlineAttachmentIds = new Set();
state.signatureAttachmentIds = new Set();
setSelectedInlineImage(null);
state.composeReplyMeta = {
inReplyTo: draft.in_reply_to || null,
@@ -1592,10 +1607,14 @@ function attachmentReminderNeeded() {
async function deleteComposeAttachment(id) {
const attachment = state.composeAttachments.find((item) => String(item.id) === String(id));
if (!attachment) return;
if (!attachment) {
state.signatureAttachmentIds.delete(String(id));
return;
}
if (attachment.source === "imap") {
state.composeAttachments = state.composeAttachments.filter((item) => String(item.id) !== String(id));
state.inlineAttachmentIds.delete(String(id));
state.signatureAttachmentIds.delete(String(id));
if (state.composeSelectedImage?.dataset?.rustboxCid === String(id)) setSelectedInlineImage(null);
$("#compose-editor")?.querySelectorAll("img[data-rustbox-cid], a[data-rustbox-cid]").forEach((node) => {
if (String(node.dataset.rustboxCid) === String(id)) node.remove();
@@ -1609,6 +1628,7 @@ async function deleteComposeAttachment(id) {
await request(`/drafts/${encodeURIComponent(state.composeDraftId)}/attachments/${encodeURIComponent(id)}`, { method: "DELETE" });
state.composeAttachments = state.composeAttachments.filter((a) => a.id !== id);
state.inlineAttachmentIds.delete(String(id));
state.signatureAttachmentIds.delete(String(id));
if (state.composeSelectedImage?.dataset?.rustboxCid === String(id)) setSelectedInlineImage(null);
$("#compose-editor")?.querySelectorAll("img[data-rustbox-cid], a[data-rustbox-cid]").forEach((node) => {
if (String(node.dataset.rustboxCid) === String(id)) node.remove();
@@ -1683,6 +1703,7 @@ function openCompose(mode = "new", options = {}) {
state.composeRemoteDraft = null;
state.composeAttachments = [];
state.inlineAttachmentIds = new Set();
state.signatureAttachmentIds = new Set();
setSelectedInlineImage(null);
setComposeFieldsCollapsed(false);
state.composeDirty = false;
@@ -1832,6 +1853,7 @@ async function sendMessage(event) {
state.composeRemoteDraft = null;
state.composeAttachments = [];
state.inlineAttachmentIds = new Set();
state.signatureAttachmentIds = new Set();
setSelectedInlineImage(null);
state.composeReplyMeta = null;
state.composeDirty = false;
+75 -10
View File
@@ -476,12 +476,13 @@ async function materializeVisibleSignatureImages(signature, generation) {
.map((image, index) => ({ image, file: dataUrlToSignatureFile(image.getAttribute("src"), index) }))
.filter((item) => item.file);
if (!pairs.length) return;
const uploaded = await uploadComposeFiles(pairs.map((item) => item.file), { announce: false });
const uploaded = await uploadComposeFiles(pairs.map((item) => item.file), { announce: false, signature: true });
if (!uploaded.length) return;
if (generation !== state.composeSignatureGeneration || !signature.isConnected) {
for (const attachment of uploaded) state.inlineAttachmentIds.add(String(attachment.id));
renderComposeAttachments();
scheduleUnusedInlineAttachmentCleanup();
for (const attachment of uploaded) {
trackSignatureInlineAttachment(attachment.id);
await deleteComposeAttachment(attachment.id);
}
return;
}
const draftId = ensureDraftId();
@@ -489,6 +490,7 @@ async function materializeVisibleSignatureImages(signature, generation) {
const { image } = pairs[index];
const attachment = uploaded[index];
state.inlineAttachmentIds.add(String(attachment.id));
trackSignatureInlineAttachment(attachment.id);
image.dataset.rustboxCid = String(attachment.id);
image.dataset.rustboxSignatureImage = "true";
image.setAttribute("src", composeAttachmentContentUrl(draftId, attachment.id));
@@ -628,23 +630,86 @@ function readFileAsDataUrl(file) {
});
}
async function insertIdentitySignatureImage(file, range = state.identitySignatureRange) {
state.identitySignatureRange = null;
if (!file) return;
if (!/^image\/(png|jpeg|gif|webp)$/i.test(file.type || "")) return toast("Choose a PNG, JPEG, GIF or WebP image", "warning");
if (file.size > 2 * 1024 * 1024) return toast("Signature image must be 2 MB or smaller", "warning");
const editor = $("#identity-signature");
async function insertSignatureImageIntoEditor(file, editor, range = null) {
if (!file || !editor) return false;
if (!/^image\/(png|jpeg|gif|webp)$/i.test(file.type || "")) {
toast("Choose a PNG, JPEG, GIF or WebP image", "warning");
return false;
}
if (file.size > 2 * 1024 * 1024) {
toast("Signature image must be 2 MB or smaller", "warning");
return false;
}
try {
const src = await readFileAsDataUrl(file);
editor.focus();
restoreEditorRange(range, editor);
const alt = escapeHtml((file.name || "signature image").replace(/\.[^.]+$/, ""));
document.execCommand("insertHTML", false, `<img src="${escapeHtml(src)}" alt="${alt}" style="max-width:320px;height:auto">`);
return true;
} catch (error) {
toast(error.message || "Could not import signature image", "error");
return false;
}
}
async function insertIdentitySignatureImage(file, range = state.identitySignatureRange) {
state.identitySignatureRange = null;
return insertSignatureImageIntoEditor(file, $("#identity-signature"), range);
}
function renderIdentitySignatureHtmlPreview() {
const source = $("#identity-signature-html-source");
const preview = $("#identity-signature-html-preview-editor");
if (!source || !preview) return;
const sanitized = sanitizeMessageHtml(source.value || "");
preview.innerHTML = sanitized;
source.value = sanitized;
source.dataset.renderedValue = sanitized;
state.identitySignatureHtmlRange = null;
}
function syncIdentitySignatureHtmlSourceFromPreview() {
const source = $("#identity-signature-html-source");
const preview = $("#identity-signature-html-preview-editor");
if (!source || !preview) return;
const sanitized = sanitizeMessageHtml(preview.innerHTML);
source.value = sanitized;
source.dataset.renderedValue = sanitized;
}
function openIdentitySignatureHtmlEditor() {
const source = $("#identity-signature-html-source");
const editor = $("#identity-signature");
const dialog = $("#identity-signature-html-dialog");
if (!source || !editor || !dialog) return;
source.value = sanitizeMessageHtml(editor.innerHTML);
source.dataset.renderedValue = "";
renderIdentitySignatureHtmlPreview();
if (!dialog.open) dialog.showModal();
setTimeout(() => source.focus(), 20);
}
function applyIdentitySignatureHtmlEditor() {
const source = $("#identity-signature-html-source");
const preview = $("#identity-signature-html-preview-editor");
const editor = $("#identity-signature");
const dialog = $("#identity-signature-html-dialog");
if (!source || !preview || !editor) return;
if (source.value !== (source.dataset.renderedValue || "")) renderIdentitySignatureHtmlPreview();
editor.innerHTML = sanitizeMessageHtml(preview.innerHTML);
state.identitySignatureRange = null;
dialog?.close();
}
async function insertIdentitySignatureHtmlImage(file, range = state.identitySignatureHtmlRange) {
state.identitySignatureHtmlRange = null;
const preview = $("#identity-signature-html-preview-editor");
const inserted = await insertSignatureImageIntoEditor(file, preview, range);
if (inserted) syncIdentitySignatureHtmlSourceFromPreview();
return inserted;
}
function populateSpecialFolderSelects() {
const roles = ["sent", "drafts", "trash", "junk", "archive"];
+56 -15
View File
@@ -70,18 +70,43 @@ function updateNotificationPermissionStatus() {
status.textContent = !("Notification" in window) ? "Browser permission: unsupported" : `Browser permission: ${Notification.permission}`;
}
async function loadNotificationPreferences() {
function notificationPreferencesForAccount(accountId) {
if (accountId && state.notificationPrefsByAccount.has(accountId)) return state.notificationPrefsByAccount.get(accountId);
if (accountId === state.accountId) return state.notificationPrefs;
return { enabled: true, sound: false, folders: ["INBOX"], dnd_enabled: false, dnd_start: "22:00", dnd_end: "07:00" };
}
async function loadNotificationPreferencesForAccount(accountId, { silent = true } = {}) {
if (!accountId) return null;
try {
const prefs = await request("/settings/notifications", { method: "GET" });
state.notificationPrefs = { ...state.notificationPrefs, ...prefs };
$("#notify-enabled").checked = Boolean(prefs.enabled);
$("#notify-sound").checked = Boolean(prefs.sound);
$("#notify-dnd-enabled").checked = Boolean(prefs.dnd_enabled);
$("#notify-dnd-start").value = prefs.dnd_start || "22:00";
$("#notify-dnd-end").value = prefs.dnd_end || "07:00";
renderNotificationFolders(prefs.folders || ["INBOX"]);
updateNotificationPermissionStatus();
} catch (error) { toast(error.message, "error"); }
const prefs = await request("/settings/notifications", { method: "GET", accountId });
const normalized = { ...notificationPreferencesForAccount(accountId), ...prefs };
state.notificationPrefsByAccount.set(accountId, normalized);
if (accountId === state.accountId) state.notificationPrefs = normalized;
return normalized;
} catch (error) {
if (!silent) toast(error.message, "error");
return null;
}
}
async function loadCombinedNotificationPreferences() {
if (!multiAccountEnabled()) return;
const accountIds = [...state.notificationAccountIds].filter((accountId) => accountId !== state.accountId && state.accounts.some((account) => account.id === accountId));
await Promise.all(accountIds.map((accountId) => loadNotificationPreferencesForAccount(accountId)));
}
async function loadNotificationPreferences() {
const prefs = await loadNotificationPreferencesForAccount(state.accountId, { silent: false });
if (!prefs) return;
$("#notify-enabled").checked = Boolean(prefs.enabled);
$("#notify-sound").checked = Boolean(prefs.sound);
$("#notify-dnd-enabled").checked = Boolean(prefs.dnd_enabled);
$("#notify-dnd-start").value = prefs.dnd_start || "22:00";
$("#notify-dnd-end").value = prefs.dnd_end || "07:00";
renderNotificationFolders(prefs.folders || ["INBOX"]);
updateNotificationPermissionStatus();
await loadCombinedNotificationPreferences();
}
function renderNotificationFolders(selected) {
@@ -105,6 +130,7 @@ async function saveNotificationPreferences() {
if ($("#notify-enabled").checked && "Notification" in window && Notification.permission === "default") await requestNotificationPermission();
const prefs = await request("/settings/notifications", { method: "PUT", body: JSON.stringify({ enabled: $("#notify-enabled").checked, sound: $("#notify-sound").checked, folders, dnd_enabled: $("#notify-dnd-enabled").checked, dnd_start: $("#notify-dnd-start").value || "22:00", dnd_end: $("#notify-dnd-end").value || "07:00" }) });
state.notificationPrefs = { ...state.notificationPrefs, ...prefs };
if (state.accountId) state.notificationPrefsByAccount.set(state.accountId, state.notificationPrefs);
updateNotificationPermissionStatus();
toast("Notification settings saved");
} catch (error) { toast(error.message, "error"); }
@@ -163,10 +189,14 @@ function playNotificationSound() {
function renderSyncActivityIndicator() {
const indicator = $("#indexing-indicator");
if (!indicator) return;
const busy = Boolean(state.indexingActive || state.imapSyncActive);
const busy = Boolean(state.messageLoadPending > 0 || state.indexingActive || state.imapSyncActive);
indicator.classList.toggle("hidden", !busy);
indicator.classList.toggle("active", busy);
const parts = [];
if (state.messageLoadPending > 0) {
const folder = state.folder ? ` · ${state.folder}` : "";
parts.push(`Loading messages${folder}`);
}
if (state.imapSyncActive || state.imapSyncPending > 0) {
const folder = state.imapSyncFolder ? ` · ${state.imapSyncFolder}` : "";
const pending = state.imapSyncPending > 0 ? ` · ${state.imapSyncPending} headers pending in task` : "";
@@ -190,6 +220,16 @@ function setIndexingIndicator(active, pending = 0) {
renderSyncActivityIndicator();
}
function beginMessageLoadActivity() {
state.messageLoadPending = Math.max(0, Number(state.messageLoadPending) || 0) + 1;
renderSyncActivityIndicator();
}
function endMessageLoadActivity() {
state.messageLoadPending = Math.max(0, (Number(state.messageLoadPending) || 0) - 1);
renderSyncActivityIndicator();
}
function setImapSyncIndicator(active, folder = null, pending = 0) {
state.imapSyncActive = Boolean(active);
state.imapSyncFolder = folder || null;
@@ -318,7 +358,7 @@ async function openRealtimeNotificationTarget(accountId, data) {
}
function notifyNewMail(data, accountId) {
const prefs = state.notificationPrefs || {};
const prefs = notificationPreferencesForAccount(accountId) || {};
if (!prefs.enabled || dndActive(prefs)) return;
const folders = prefs.folders || [];
if (folders.length && !folders.includes(data.folder)) return;
@@ -505,7 +545,8 @@ function connectRealtime() {
clearTimeout(state.wsRetryTimer);
state.wsRetryTimer = null;
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
const socket = new WebSocket(`${protocol}//${location.host}${BASE}/api/ws`);
const accountQuery = state.accountId ? `?account_id=${encodeURIComponent(state.accountId)}` : "";
const socket = new WebSocket(`${protocol}//${location.host}${BASE}/api/ws${accountQuery}`);
state.ws = socket;
socket.onopen = () => {
if (state.ws !== socket) return;
@@ -533,7 +574,7 @@ function connectRealtime() {
if (event.type === "NewMessages") {
const data = event.data || {};
const duplicate = isDuplicateNewMailNotification(data, payload.accountId);
if (!duplicate && !realtimeNewMailNotificationsSuppressed(data, payload.accountId)) {
if (!duplicate && notificationAccountEnabled(payload.accountId) && !realtimeNewMailNotificationsSuppressed(data, payload.accountId)) {
toastNewMail(data, payload.accountId);
notifyNewMail(data, payload.accountId);
}
+54 -1
View File
@@ -324,8 +324,10 @@ function bindEvents() {
toast("Message sort updated", "success");
} catch (error) { toast(error.message, "error"); }
});
$("#page-first").addEventListener("click", () => goToMailboxPage(0));
$("#page-prev").addEventListener("click", () => changeMailboxPage(-1));
$("#page-next").addEventListener("click", () => changeMailboxPage(1));
$("#page-last").addEventListener("click", () => goToMailboxPage(mailboxPageCount() - 1));
$("#load-more").addEventListener("click", loadNextProgressivePage);
$("#message-list").addEventListener("scroll", handleMessageListScroll, { passive: true });
window.addEventListener("offline", () => setConnectivityState(false));
@@ -477,7 +479,7 @@ function bindEvents() {
// selection become the target of the next toolbar action.
state.identitySignatureRange = null;
});
$$(".signature-editor-toolbar button").forEach((button) => button.addEventListener("mousedown", (event) => {
$$(".identity-signature-field .signature-editor-toolbar button").forEach((button) => button.addEventListener("mousedown", (event) => {
if (event.button !== 0) return;
rememberSignatureRange();
event.preventDefault();
@@ -515,6 +517,57 @@ function bindEvents() {
event.preventDefault();
await insertIdentitySignatureImage(file, captureEditorRange($("#identity-signature")));
});
$("#identity-signature-html-btn").addEventListener("click", openIdentitySignatureHtmlEditor);
$("#identity-signature-html-preview").addEventListener("click", renderIdentitySignatureHtmlPreview);
$("#identity-signature-html-apply").addEventListener("click", applyIdentitySignatureHtmlEditor);
const signatureHtmlEditor = $("#identity-signature-html-preview-editor");
const rememberSignatureHtmlRange = () => {
const range = captureEditorRange(signatureHtmlEditor);
if (range) state.identitySignatureHtmlRange = range;
return range;
};
const selectedSignatureHtmlRange = () => {
const range = state.identitySignatureHtmlRange || captureEditorRange(signatureHtmlEditor);
if (!range || range.collapsed) return null;
return range;
};
["keyup", "mouseup"].forEach((eventName) => signatureHtmlEditor.addEventListener(eventName, rememberSignatureHtmlRange));
signatureHtmlEditor.addEventListener("input", () => { rememberSignatureHtmlRange(); syncIdentitySignatureHtmlSourceFromPreview(); });
signatureHtmlEditor.addEventListener("pointerdown", () => { state.identitySignatureHtmlRange = null; });
$$("#identity-signature-html-dialog .signature-editor-toolbar button").forEach((button) => button.addEventListener("mousedown", (event) => {
if (event.button !== 0) return;
rememberSignatureHtmlRange();
event.preventDefault();
}));
$$('[data-signature-html-command]').forEach((button) => button.addEventListener("click", () => {
const range = selectedSignatureHtmlRange();
if (!range) { signatureHtmlEditor.focus(); return; }
restoreEditorRange(range, signatureHtmlEditor);
document.execCommand(button.dataset.signatureHtmlCommand, false, null);
rememberSignatureHtmlRange();
syncIdentitySignatureHtmlSourceFromPreview();
}));
$("#identity-signature-html-link").addEventListener("click", async () => {
const range = selectedSignatureHtmlRange();
if (!range) {
signatureHtmlEditor.focus();
return toast("Select signature text before inserting a link", "warning");
}
const url = await appPrompt("Insert signature link", { placeholder: "https://example.com", type: "url", confirmLabel: "Insert" });
if (!url) return;
restoreEditorRange(range, signatureHtmlEditor);
document.execCommand("createLink", false, url);
rememberSignatureHtmlRange();
syncIdentitySignatureHtmlSourceFromPreview();
});
$("#identity-signature-html-image-btn").addEventListener("click", () => { rememberSignatureHtmlRange(); $("#identity-signature-html-image").click(); });
$("#identity-signature-html-image").addEventListener("change", async (event) => { const file = event.target.files?.[0]; await insertIdentitySignatureHtmlImage(file); event.target.value = ""; });
signatureHtmlEditor.addEventListener("paste", async (event) => {
const file = [...(event.clipboardData?.items || [])].find((item) => item.kind === "file" && /^image\/(png|jpeg|gif|webp)$/i.test(item.type))?.getAsFile();
if (!file) return;
event.preventDefault();
await insertIdentitySignatureHtmlImage(file, captureEditorRange(signatureHtmlEditor));
});
$("#contact-form").addEventListener("submit", saveContact);
$("#contact-new").addEventListener("click", () => editContact(null));
$("#contact-import").addEventListener("click", () => $("#contact-import-file").click());