diff --git a/CHANGELOG.md b/CHANGELOG.md index e0b4a3d..aeb9e19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # RustBox changelog +### 0.2.17 + +- Added a top-bar quick account switcher that appears when more than one account is signed in to the current browser session. +- Added browser-level combined notification selection in Account settings; selected accounts keep their own notification folder, sound and DND preferences. +- Added administrator feature gate `multi_account_enabled`; disabling it hides multi-account controls, rejects adding a second account and limits realtime WebSocket monitoring to the active account. +- Made the plain-text and HTML signature editors the same visual height. +- Added a dedicated HTML signature source modal with sanitized source import, visual preview/editing, formatting controls and image insertion. +- Kept the global synchronization spinner active for mailbox page loads, progressive/full-folder loading and individual message/IMAP-draft retrieval. +- Added Shift-click range selection across the currently displayed message list, using the last regular/toggle selection as the anchor. +- Added first-page and last-page controls to mailbox pagination. +- Hide HTML inline/CID resources, including images inserted through signature management, from the reader attachment list while continuing to resolve them inside the message body. +- Treat MIME parts explicitly marked `Content-Disposition: inline` as inline content rather than message attachments, preventing signature-only messages from gaining an attachment indicator after the next IMAP metadata sync. +- Retain signature images as `multipart/related` CID parts for outgoing mail; regular file attachments remain unchanged. +- Kept the application/source release at 0.2.17. + ### 0.2.16 - Added opt-in adaptive HTML-mail colors that adjust neutral backgrounds and low-contrast text to the active light/dark theme while preserving layout, images and brand colors. diff --git a/Cargo.lock b/Cargo.lock index afa7ce7..7cb5d3c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3506,7 +3506,7 @@ dependencies = [ [[package]] name = "rustbox" -version = "0.2.16" +version = "0.2.17" dependencies = [ "aes-gcm", "async-imap", diff --git a/Cargo.toml b/Cargo.toml index 432f159..170f6d3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustbox" -version = "0.2.16" +version = "0.2.17" edition = "2024" rust-version = "1.99.1" default-run = "rustbox" diff --git a/NOTICE.md b/NOTICE.md index 3727ac2..bf522d8 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -1,6 +1,6 @@ # RustBox attribution / notice -RustBox 0.2.16 is derived from the Oxi webmail project: +RustBox 0.2.17 is derived from the Oxi webmail project: - Upstream: `c0h1b4/oxi` - Base snapshot: `7f1507c486b78cf33e54beb701dd460d3bd2bd5d` diff --git a/README.md b/README.md index af1970c..bdbd280 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# RustBox 0.2.16 +# RustBox 0.2.17 RustBox is a webmail derived from `c0h1b4/oxi`. The consumed Oxi revision is recorded in `UPSTREAM_BASE`. @@ -201,6 +201,8 @@ cargo build --release ## UI +Multi-account browser sessions are enabled by default and can be disabled by the administrator with `multi_account_enabled = false`. When enabled, signed-in accounts can be switched from the top bar and selected together for new-mail notifications. + RustBox uses a compact three-pane mail layout: folders, message list and reader. The current internal baseline includes configurable special IMAP folders, collapsible folder hierarchy, folder CRUD/subscriptions, move/copy/bulk actions, drag-and-drop message moves, collapsible threads, raw headers/source download, labels, advanced/saved search, quota warnings, contacts/groups/vCard, calendar/ICS import, identities/signatures with automatic reply identity selection, HTML/plain/Markdown compose modes, CID inline images, and ManageSieve raw + visual rule editing with negation, dynamically addable conditions/actions and nested AND/OR chaining. OpenPGP includes per-user key management, identity binding, PGP/MIME + inline sign/encrypt/decrypt/verify, WKD/HKP discovery and private-key passphrase management. IMAP IDLE events are delivered to the browser over the embedded WebSocket endpoint for realtime refresh and optional desktop notifications. Notification preferences include sound, folder selection and Do Not Disturb hours. diff --git a/RELEASE-MANIFEST.txt b/RELEASE-MANIFEST.txt index 4a0dfb7..7414a89 100644 --- a/RELEASE-MANIFEST.txt +++ b/RELEASE-MANIFEST.txt @@ -1,6 +1,9 @@ -RustBox 0.2.16 release manifest +RustBox 0.2.17 release manifest -0.2.16 release changes and retained feature set: +0.2.17 release changes and retained feature set: +- adds equal-height text/HTML signature fields and a dedicated raw-HTML + visual signature editor with image insertion +- keeps synchronization activity visible during page/progressive/all mailbox loads and individual message retrieval +- adds Shift-click contiguous message selection and direct first/last pagination controls - fixes HTML signature formatting so toolbar actions preserve the selected text and reliably toggle formatting - gives new messages with visible signatures a real editable body entry above the signature and softens/spaces the composer-only separator - strips the editor-only body-entry marker from serialized outgoing HTML while retaining signature de-duplication metadata @@ -67,14 +70,14 @@ Docker examples: Operations/docs: - standalone `scripts/migrate_roundcube.py` imports Roundcube contacts/identities using RustBox config.toml and supports SQLite/MySQL/PostgreSQL targets -- README.md documents 0.2.16 configuration +- README.md documents 0.2.17 configuration - docker/README.md documents Compose variants - docs/STORAGE_SEARCH_OPERATIONS.md documents failure modes, shared-storage requirements and migration behavior - TODO.md leaves live SILO/MinIO/AWS/Ceph and real multi-node acceptance validation explicitly pending Build/release metadata: - `scripts/package-release.sh` runs the full Rust release gate before creating ZIP/SHA256 artifacts -- Cargo.toml version = 0.2.16 +- Cargo.toml version = 0.2.17 - rust-toolchain.toml pins Rust 1.99.1 - Cargo.lock is retained for reproducible application builds; the release gate generates it only when missing - CI and scripts/build-release.sh use the retained Cargo.lock (generating it only if missing), then run locked tests/Clippy/release build diff --git a/RELEASE-VALIDATION.txt b/RELEASE-VALIDATION.txt index 56e64ba..2f9a035 100644 --- a/RELEASE-VALIDATION.txt +++ b/RELEASE-VALIDATION.txt @@ -1,6 +1,10 @@ -RustBox 0.2.16 - final source validation +RustBox 0.2.17 - final source validation -0.2.16 scope: +0.2.17 scope: +- multi-account sessions expose a top-bar quick switcher, per-browser combined notification selection, per-account notification preferences and the administrator `multi_account_enabled` feature gate +- plain-text and HTML signature controls have aligned heights; raw HTML can be pasted into a modal, previewed/edited visually and extended with inline images +- synchronization spinner covers mailbox pagination, progressive/full-folder loading and message/draft body retrieval +- Shift-click selects a contiguous visible-message range; pagination includes first/last page jumps - signature HTML formatting preserves selected text across toolbar clicks, including formatting removal, while collapsed-caret clicks cannot enable sticky bold/italic/underline typing modes - the HTML signature editor is no longer wrapped in a label, preventing whitespace/outside clicks from implicitly activating the Bold toolbar button - new messages with visible signatures provide an editable body area above a more subtle, better-spaced separator @@ -28,15 +32,15 @@ RustBox 0.2.16 - final source validation - Composing shows only compact attachment/message size-limit values on one line - 0.2.9 realtime notification gating, browser shortcut preservation and 300-message defaults remain intact - V047-V050 are consolidated into compatibility baseline V050; V051 migrates existing tenants with adaptive HTML-mail colors and mailbox loading mode; V052 is next -- version metadata updated to 0.2.16 +- version metadata remains at 0.2.17 Completed in this environment: -- offline release validator: scripts/validate-release-static.py (421/421 checks passed) +- offline release validator: scripts/validate-release-static.py (413/413 checks passed) - browser JavaScript assembled in manifest order passes `node --check` - all JSON files parse successfully - Python helper scripts compile successfully - shell scripts pass `bash -n` -- release metadata consistently identifies 0.2.16 +- release metadata consistently identifies 0.2.17 - V050 baseline consolidation plus the V051 adaptive-colors/mailbox-loading migration are validated offline diff --git a/config.dev.toml b/config.dev.toml index 1080e63..3d40a1d 100644 --- a/config.dev.toml +++ b/config.dev.toml @@ -43,6 +43,8 @@ max_message_size_mb = 50 sieve_enabled = true diagnostics_enabled = true about_enabled = true +# Allow more than one signed-in account in the same browser session. +multi_account_enabled = true security_headers_enabled = false # ManageSieve. Leave sieve_host unset to use imap_host. Ignored when disabled. diff --git a/config.example.toml b/config.example.toml index 92d7361..500b0d0 100644 --- a/config.example.toml +++ b/config.example.toml @@ -59,6 +59,8 @@ max_message_size_mb = 50 sieve_enabled = true diagnostics_enabled = true about_enabled = true +# Allow more than one signed-in account in the same browser session. +multi_account_enabled = true # Adds content-type-aware browser security headers: document policies on HTML, private no-store/framing/CORP policy on /api, Referrer-Policy on HTML/CSS, and nosniff broadly. security_headers_enabled = false diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index a074ffa..afa0bec 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,10 +1,13 @@ -# RustBox 0.2.16 - implementation status +# RustBox 0.2.17 - implementation status - Draft saves are realtime-silent: Drafts folder/list updates continue, but saving a draft does not surface as a new-mail notification. -This file tracks the `0.2.16` source snapshot. Storage/search scope and outstanding acceptance work are tracked in `TODO.md`. A feature is only listed as implemented when working code exists in this source tree; partial items are called out explicitly. +This file tracks the `0.2.17` source snapshot. Storage/search scope and outstanding acceptance work are tracked in `TODO.md`. A feature is only listed as implemented when working code exists in this source tree; partial items are called out explicitly. -## 0.2.16 browser mail UX +## 0.2.17 browser mail UX +- Signature management uses equal-height text and HTML fields and includes a raw-HTML modal with a sanitized visual editor and inline image import. +- Mail loading activity is represented by the app-bar spinner for pagination, progressive/full-folder retrieval and individual message/draft loading. +- Shift-click selects a contiguous range in the visible message list, and pagination includes direct first/last page navigation. - Signature HTML formatting now preserves the selected text when toolbar controls are used, so bold/italic/underline and remove-format can be toggled reliably. - New messages with a visible signature always get an editable body line before the signature; the composer separator is spaced farther from the body and rendered as a subtle solid rule. - Wide message panes use a default-on compact single-line row layout (sender, subject/snippet, date and icons); users can disable it in Appearance settings. @@ -38,7 +41,7 @@ This file tracks the `0.2.16` source snapshot. Storage/search scope and outstand - Private `/api` responses receive `Cache-Control: no-store`, `X-Content-Type-Options: nosniff` and `Cross-Origin-Resource-Policy: same-origin`. API responses are normally non-frameable (`frame-ancestors 'none'` / `X-Frame-Options: DENY`); `application/pdf` is the narrow exception and allows same-origin framing for the attachment preview (`frame-ancestors 'self'` / `SAMEORIGIN`). API JSON does not receive the full document CSP, `Permissions-Policy`, COOP or `Referrer-Policy`. - CSS receives `Referrer-Policy: no-referrer` and `X-Content-Type-Options: nosniff`; application assets also receive same-origin CORP without redundant HTML document policies. - HSTS is deliberately not synthesized by RustBox because the application may sit behind a TLS-terminating reverse proxy and cannot reliably determine the public HTTPS boundary; HSTS belongs at that HTTPS edge. -- The public `/api/auth/policy` is data-minimized to the single login-form capability `rememberMeEnabled`. Optional feature availability (OpenPGP, Sieve, diagnostics, About) is returned only from authenticated `/api/auth/session-policy`; session/remember durations are not exposed to the browser policy endpoints. +- The public `/api/auth/policy` is data-minimized to the single login-form capability `rememberMeEnabled`. Optional feature availability (OpenPGP, Sieve, diagnostics, About, multi-account sessions) is returned only from authenticated `/api/auth/session-policy`; session/remember durations are not exposed to the browser policy endpoints. ## 0.2.8 storage/search architecture diff --git a/scripts/validate-release-static.py b/scripts/validate-release-static.py index f23f6c6..01dbb54 100755 --- a/scripts/validate-release-static.py +++ b/scripts/validate-release-static.py @@ -144,6 +144,8 @@ require('1..=300' in read("src/routes/messages/mod.rs"), "folder message API acc require('20..=300' in read("src/db/mail_preferences.rs") and '"pagination" | "progressive" | "all"' in read("src/db/mail_preferences.rs"), "mailbox preferences accept 300-message pages and the three loading modes") require('const appShortcut = keyboardIdle && !event.ctrlKey && !event.metaKey && !event.altKey;' in bindings_js, "single-key shortcuts preserve Ctrl/Cmd/Alt browser shortcuts") require('realtimeNotificationsReady' in realtime_js and 'suppressRealtimeNotificationsForFolder' in realtime_js, "realtime new-mail notification gating is present") +require('id="account-switcher"' in read("src/web/index.html") and 'renderAccountSwitcher' in auth_js and 'state.accounts.length > 1' in auth_js, "multi-account quick switcher appears only for multiple signed-in accounts") +require('id="account-notification-list"' in read("src/web/index.html") and 'notificationAccountEnabled' in auth_js and 'notificationPrefsByAccount' in bootstrap_js and 'loadCombinedNotificationPreferences' in realtime_js, "browser session can select multiple accounts for combined notifications") require('realtimeFolderIsDrafts' in realtime_js and 'if (realtimeFolderIsDrafts(data?.folder)) return true;' in realtime_js, "realtime new-mail notifications are suppressed for Drafts folders") require('const draftsFolder = syncImap' in compose_js and 'suppressRealtimeNotificationsForFolder(draftsFolder)' in compose_js and 'releaseRealtimeSuppression();' in compose_js, "manual IMAP draft save suppresses its own realtime new-mail notification") require('suppressRealtimeNotificationsForFolder(notificationDestination)' in mail_actions_js, "bulk destination notifications are suppressed") @@ -159,11 +161,16 @@ require('clipboardData' in bindings_js and 'uploadInlineImage(images, range)' in require('.compose-attachment-kind' in compose_css and 'Image attachment' in compose_js and 'Inline image' in compose_js, "composer distinguishes embedded images from regular attachments") require("function mergeComposeAttachments" in compose_js and "await refreshComposeAttachmentsFromServer(id);" in compose_js and "attachments: mergeComposeAttachments(local.attachments, server.attachments)" in compose_js, "composer preserves multiple attachments and draft recovery merges authoritative server attachment metadata") require("const attachments = mergeComposeAttachments(state.composeAttachments);" in compose_js and "Inline link" in compose_js and "detachComposeAttachmentFromBody" in compose_js, "composer attachment strip keeps inline and regular attachments visible together") -require('.message-pagination {' in mail_css and 'grid-template-columns: 30px auto 30px;' in mail_css and '.pagination-arrow {' in mail_css and '.load-more {' in mail_css and 'width: 100%;' in mail_css.split('.load-more {', 1)[1].split('}', 1)[0], "message pagination uses compact SVG arrows while progressive-load controls span the message list width") +require('.message-pagination {' in mail_css and 'grid-template-columns: 30px 30px auto 30px 30px;' in mail_css and '.pagination-arrow {' in mail_css and '.load-more {' in mail_css and 'width: 100%;' in mail_css.split('.load-more {', 1)[1].split('}', 1)[0], "message pagination uses compact first/previous/next/last controls while progressive-load controls span the message list width") require('id="compose-fields-toggle"' in read("src/web/index.html") and 'function setComposeFieldsCollapsed' in compose_js and 'function toggleComposeFields' in compose_js and '.compose-dialog.compose-fields-collapsed .compose-addresses' in compose_css and 'setComposeFieldsCollapsed(false)' in bindings_js, "composer addressing fields can collapse to maximize editor space and reopen on invalid recipient input") require('id="compose-save-draft"' in read("src/web/index.html") and '$("#compose-save-draft").addEventListener("click", saveComposeDraftToImap);' in bindings_js and 'saveCurrentDraft(false, true, true)' in compose_js, "composer exposes an explicit manual IMAP draft save before cancel") require('rememberSignatureRange' in bindings_js and 'selectedSignatureRange' in bindings_js and 'if (!range || range.collapsed) return null;' in bindings_js and 'state.identitySignatureRange = null;' in bindings_js and '.signature-editor-toolbar button' in bindings_js and 'event.preventDefault();' in bindings_js and 'restoreEditorRange(range, signatureEditor);' in bindings_js, "signature formatting preserves real selections and refuses collapsed-caret commands that would enable sticky typing styles") +require('id="identity-signature-html-dialog"' in read("src/web/index.html") and 'id="identity-signature-html-source"' in read("src/web/index.html") and 'id="identity-signature-html-preview-editor"' in read("src/web/index.html") and 'openIdentitySignatureHtmlEditor' in read("src/web/js/72-openpgp-identities.js") and 'insertIdentitySignatureHtmlImage' in read("src/web/js/72-openpgp-identities.js"), "signature management exposes sanitized raw-HTML import with visual editing and image insertion") +require('.identity-signature-grid textarea,' in read("src/web/css/50-settings.css") and 'height: 240px;' in read("src/web/css/50-settings.css") and '.identity-signature-grid .signature-editor-shell {' in read("src/web/css/50-settings.css"), "plain-text and HTML signature controls use the same fixed editor height") +require('messageLoadPending: 0' in bootstrap_js and 'function beginMessageLoadActivity()' in realtime_js and 'function endMessageLoadActivity()' in realtime_js and reader_js.count('beginMessageLoadActivity();') >= 3 and reader_js.count('endMessageLoadActivity();') >= 3, "mail loading spinner covers list pagination/progressive/all loading and individual message retrieval") +require('function selectMessageRange(message)' in read("src/web/js/30-folders-tags.js") and 'if (event.shiftKey)' in reader_js and 'setMessageSelectionAnchor(message);' in reader_js, "Shift-click selects a contiguous visible-message range from the last selection anchor") +require('id="page-first"' in read("src/web/index.html") and 'id="page-last"' in read("src/web/index.html") and 'function goToMailboxPage(targetPage)' in reader_js and 'goToMailboxPage(mailboxPageCount() - 1)' in bindings_js, "mailbox pagination can jump directly to the first and last page") index_html = read("src/web/index.html") require('
HTML signature / footer' in index_html and 'aria-labelledby="identity-signature-label"' in index_html and '
@@ -84,6 +84,12 @@ +
—Connecting
@@ -181,6 +187,8 @@
+
+
+
+

Notifications from accounts

+

Choose which signed-in accounts can show combined new-mail notifications in this browser.

+
+
+
+

Active sessions

@@ -1280,6 +1299,8 @@ +
+
+
HTML source
+ + Scripts, forms and unsafe attributes are removed before the signature is used. +
+
+
Visual editorYou can add images and adjust the rendered signature.
+
+
+ + + + + + +
+
+
+ +
+
+
+
+ +
diff --git a/src/web/js/00-bootstrap.js b/src/web/js/00-bootstrap.js index f191e15..75090f9 100644 --- a/src/web/js/00-bootstrap.js +++ b/src/web/js/00-bootstrap.js @@ -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, diff --git a/src/web/js/10-core.js b/src/web/js/10-core.js index 13dada0..45d96e1 100644 --- a/src/web/js/10-core.js +++ b/src/web/js/10-core.js @@ -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) { diff --git a/src/web/js/20-preferences-auth.js b/src/web/js/20-preferences-auth.js index 09cf781..77a97af 100644 --- a/src/web/js/20-preferences-auth.js +++ b/src/web/js/20-preferences-auth.js @@ -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(); } diff --git a/src/web/js/30-folders-tags.js b/src/web/js/30-folders-tags.js index 0334ee2..ebb8b04 100644 --- a/src/web/js/30-folders-tags.js +++ b/src/web/js/30-folders-tags.js @@ -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(); } diff --git a/src/web/js/40-messages-reader.js b/src/web/js/40-messages-reader.js index ead39da..354bbd9 100644 --- a/src/web/js/40-messages-reader.js +++ b/src/web/js/40-messages-reader.js @@ -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 = `
Loading messages…
`; } } $("#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 = `
`; 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 = `
${escapeHtml(error.message)}
`; + 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 = `
${escapeHtml(error.message)}
`; + } + } 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(`${html}`, "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) { diff --git a/src/web/js/50-compose.js b/src/web/js/50-compose.js index d4f1b80..b564b4f 100644 --- a/src/web/js/50-compose.js +++ b/src/web/js/50-compose.js @@ -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; diff --git a/src/web/js/72-openpgp-identities.js b/src/web/js/72-openpgp-identities.js index acd338b..03377e0 100644 --- a/src/web/js/72-openpgp-identities.js +++ b/src/web/js/72-openpgp-identities.js @@ -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, `${alt}`); + 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"]; diff --git a/src/web/js/80-settings-realtime.js b/src/web/js/80-settings-realtime.js index 120eec1..81809cb 100644 --- a/src/web/js/80-settings-realtime.js +++ b/src/web/js/80-settings-realtime.js @@ -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); } diff --git a/src/web/js/95-bindings-boot.js b/src/web/js/95-bindings-boot.js index 880c19e..62e186b 100644 --- a/src/web/js/95-bindings-boot.js +++ b/src/web/js/95-bindings-boot.js @@ -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());