1 parent
2ac1566eca
commit
1bb398acd1
32 files changed
+907
-136
No files matched your search
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"];
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
Reference in new issue
Block a user