1 parent
cfc74d9db6
commit
dacdf9c27d
76 files changed
+5209
-741
No files matched your search
+62
-1
@@ -1,3 +1,4 @@
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
@@ -5,9 +6,10 @@ use base64::Engine;
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use dashmap::DashMap;
|
||||
use rand::RngCore;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::session_store::crypto::SessionCrypto;
|
||||
use crate::session_store::{PersistedSession, SessionPersistence};
|
||||
use crate::session_store::{PersistedSession, SessionPersistence, SessionPersistenceDiagnostics};
|
||||
|
||||
pub type SessionId = String;
|
||||
pub type BrowserId = String;
|
||||
@@ -54,6 +56,29 @@ pub struct AccountSession {
|
||||
pub user_agent: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct DiagnosticSession {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub email: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub user_hash: Option<String>,
|
||||
pub imap_host: String,
|
||||
pub imap_port: u16,
|
||||
pub smtp_host: String,
|
||||
pub smtp_port: u16,
|
||||
pub created_at_unix: u64,
|
||||
pub last_accessed_unix: u64,
|
||||
pub user_agent: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct SessionStoreDiagnostics {
|
||||
pub local_cached_sessions: usize,
|
||||
pub local_cached_browsers: usize,
|
||||
pub local_unique_users: usize,
|
||||
pub sessions: Vec<DiagnosticSession>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SessionStore {
|
||||
sessions: Arc<DashMap<SessionId, AccountSession>>,
|
||||
@@ -480,6 +505,34 @@ impl SessionStore {
|
||||
self.sessions.is_empty()
|
||||
}
|
||||
|
||||
pub fn diagnostics_snapshot(&self, include_user_identifiers: bool) -> SessionStoreDiagnostics {
|
||||
let mut users = HashSet::new();
|
||||
let mut sessions = Vec::new();
|
||||
for entry in self.sessions.iter() {
|
||||
let session = entry.value();
|
||||
users.insert(session.user_hash.clone());
|
||||
sessions.push(DiagnosticSession {
|
||||
email: include_user_identifiers.then(|| session.email.clone()),
|
||||
user_hash: include_user_identifiers.then(|| session.user_hash.clone()),
|
||||
imap_host: session.imap_host.clone(),
|
||||
imap_port: session.imap_port,
|
||||
smtp_host: session.smtp_host.clone(),
|
||||
smtp_port: session.smtp_port,
|
||||
created_at_unix: session.created_at_unix,
|
||||
last_accessed_unix: session.last_accessed_unix,
|
||||
user_agent: session.user_agent.chars().take(256).collect(),
|
||||
});
|
||||
}
|
||||
sessions.sort_by_key(|session| std::cmp::Reverse(session.last_accessed_unix));
|
||||
sessions.truncate(100);
|
||||
SessionStoreDiagnostics {
|
||||
local_cached_sessions: self.sessions.len(),
|
||||
local_cached_browsers: self.browsers.len(),
|
||||
local_unique_users: users.len(),
|
||||
sessions,
|
||||
}
|
||||
}
|
||||
|
||||
fn cache_persisted(&self, record: &PersistedSession) -> Result<AccountSession, String> {
|
||||
let crypto = self
|
||||
.crypto
|
||||
@@ -747,6 +800,14 @@ impl SessionStore {
|
||||
removed
|
||||
}
|
||||
|
||||
pub async fn persistence_diagnostics(&self) -> Result<SessionPersistenceDiagnostics, String> {
|
||||
let persistence = self
|
||||
.persistence
|
||||
.as_ref()
|
||||
.ok_or_else(|| "session persistence is not configured".to_string())?;
|
||||
persistence.diagnostics(Self::unix_now()).await
|
||||
}
|
||||
|
||||
pub async fn purge_expired_shared(&self) {
|
||||
self.purge_expired();
|
||||
if let Some(persistence) = self.persistence.as_ref()
|
||||
|
||||
+208
-1
@@ -4,7 +4,7 @@ use figment::{
|
||||
Figment,
|
||||
providers::{Env, Format, Toml},
|
||||
};
|
||||
use ipnet::IpNet;
|
||||
use ipnet::{IpNet, Ipv4Net, Ipv6Net};
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
@@ -72,6 +72,64 @@ impl Default for StorageConfig {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct SyncConfig {
|
||||
#[serde(default = "default_sync_interval_secs")]
|
||||
pub interval_secs: u64,
|
||||
#[serde(default = "default_deep_index_batch_size")]
|
||||
pub deep_index_batch_size: u32,
|
||||
}
|
||||
|
||||
impl Default for SyncConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
interval_secs: default_sync_interval_secs(),
|
||||
deep_index_batch_size: default_deep_index_batch_size(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct DiagnosticsEndpointConfig {
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
#[serde(default = "default_diagnostics_endpoint_path")]
|
||||
pub path: String,
|
||||
#[serde(default = "default_diagnostics_allowed_networks")]
|
||||
pub allowed_networks: Vec<String>,
|
||||
#[serde(default = "default_diagnostics_auth_mode")]
|
||||
pub auth: String,
|
||||
#[serde(default)]
|
||||
pub token: Option<String>,
|
||||
#[serde(default)]
|
||||
pub basic_username: Option<String>,
|
||||
#[serde(default)]
|
||||
pub basic_password: Option<String>,
|
||||
#[serde(default)]
|
||||
pub include_user_identifiers: bool,
|
||||
#[serde(default = "default_diagnostics_probe_timeout_secs")]
|
||||
pub probe_timeout_secs: u64,
|
||||
#[serde(default = "default_diagnostics_security_window_secs")]
|
||||
pub security_window_secs: u64,
|
||||
}
|
||||
|
||||
impl Default for DiagnosticsEndpointConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
path: default_diagnostics_endpoint_path(),
|
||||
allowed_networks: default_diagnostics_allowed_networks(),
|
||||
auth: default_diagnostics_auth_mode(),
|
||||
token: None,
|
||||
basic_username: None,
|
||||
basic_password: None,
|
||||
include_user_identifiers: false,
|
||||
probe_timeout_secs: default_diagnostics_probe_timeout_secs(),
|
||||
security_window_secs: default_diagnostics_security_window_secs(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct SearchConfig {
|
||||
#[serde(default = "default_search_backend")]
|
||||
@@ -92,6 +150,8 @@ pub struct SearchConfig {
|
||||
pub allow_insecure_http: bool,
|
||||
#[serde(default = "default_search_request_timeout_secs")]
|
||||
pub request_timeout_secs: u64,
|
||||
#[serde(default)]
|
||||
pub deep_index: bool,
|
||||
#[serde(default = "default_search_worker_interval_secs")]
|
||||
pub worker_interval_secs: u64,
|
||||
#[serde(default = "default_search_worker_batch_size")]
|
||||
@@ -112,6 +172,7 @@ impl Default for SearchConfig {
|
||||
required: false,
|
||||
allow_insecure_http: false,
|
||||
request_timeout_secs: default_search_request_timeout_secs(),
|
||||
deep_index: false,
|
||||
worker_interval_secs: default_search_worker_interval_secs(),
|
||||
worker_batch_size: default_search_worker_batch_size(),
|
||||
worker_max_attempts: default_search_worker_max_attempts(),
|
||||
@@ -197,6 +258,12 @@ pub struct AppConfig {
|
||||
#[serde(default)]
|
||||
pub search: SearchConfig,
|
||||
|
||||
#[serde(default)]
|
||||
pub sync: SyncConfig,
|
||||
|
||||
#[serde(default)]
|
||||
pub diagnostics_endpoint: DiagnosticsEndpointConfig,
|
||||
|
||||
#[serde(default = "default_session_timeout_hours")]
|
||||
pub session_timeout_hours: u64,
|
||||
|
||||
@@ -363,6 +430,34 @@ fn default_search_request_timeout_secs() -> u64 {
|
||||
10
|
||||
}
|
||||
|
||||
fn default_sync_interval_secs() -> u64 {
|
||||
30
|
||||
}
|
||||
|
||||
fn default_deep_index_batch_size() -> u32 {
|
||||
10
|
||||
}
|
||||
|
||||
fn default_diagnostics_endpoint_path() -> String {
|
||||
"/api/system/diagnostics".to_string()
|
||||
}
|
||||
|
||||
fn default_diagnostics_allowed_networks() -> Vec<String> {
|
||||
vec!["127.0.0.1/32".to_string(), "::1/128".to_string()]
|
||||
}
|
||||
|
||||
fn default_diagnostics_auth_mode() -> String {
|
||||
"token".to_string()
|
||||
}
|
||||
|
||||
fn default_diagnostics_probe_timeout_secs() -> u64 {
|
||||
5
|
||||
}
|
||||
|
||||
fn default_diagnostics_security_window_secs() -> u64 {
|
||||
900
|
||||
}
|
||||
|
||||
fn default_search_worker_interval_secs() -> u64 {
|
||||
2
|
||||
}
|
||||
@@ -479,6 +574,8 @@ impl Default for AppConfig {
|
||||
cluster_mode: false,
|
||||
storage: StorageConfig::default(),
|
||||
search: SearchConfig::default(),
|
||||
sync: SyncConfig::default(),
|
||||
diagnostics_endpoint: DiagnosticsEndpointConfig::default(),
|
||||
session_timeout_hours: default_session_timeout_hours(),
|
||||
remember_me_enabled: default_remember_me_enabled(),
|
||||
remember_me_days: default_remember_me_days(),
|
||||
@@ -565,6 +662,30 @@ impl AppConfig {
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn parsed_diagnostics_allowed_networks(&self) -> Result<Vec<IpNet>, String> {
|
||||
self.diagnostics_endpoint
|
||||
.allowed_networks
|
||||
.iter()
|
||||
.map(|entry| {
|
||||
let trimmed = entry.trim();
|
||||
if let Ok(network) = trimmed.parse::<IpNet>() {
|
||||
return Ok(network);
|
||||
}
|
||||
let ip = trimmed
|
||||
.parse::<IpAddr>()
|
||||
.map_err(|e| format!("invalid diagnostics allowed network '{trimmed}': {e}"))?;
|
||||
Ok(match ip {
|
||||
IpAddr::V4(value) => {
|
||||
IpNet::V4(Ipv4Net::new(value, 32).expect("valid IPv4 prefix"))
|
||||
}
|
||||
IpAddr::V6(value) => {
|
||||
IpNet::V6(Ipv6Net::new(value, 128).expect("valid IPv6 prefix"))
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn parsed_outgoing_headers(&self) -> Result<Vec<(String, String)>, String> {
|
||||
const RESERVED: &[&str] = &[
|
||||
"from",
|
||||
@@ -743,6 +864,86 @@ impl AppConfig {
|
||||
return Err("Redis sessions require REDIS_URL or REDIS_SENTINEL_NODES + REDIS_SENTINEL_MASTER".to_string());
|
||||
}
|
||||
}
|
||||
if self.diagnostics_endpoint.enabled {
|
||||
let path = self.diagnostics_endpoint.path.trim();
|
||||
if !path.starts_with("/api/system/") || path.contains('?') || path.contains('#') {
|
||||
return Err("diagnostics_endpoint.path must be an absolute /api/system/... path without query/fragment".to_string());
|
||||
}
|
||||
if self.diagnostics_endpoint.allowed_networks.is_empty() {
|
||||
return Err(
|
||||
"diagnostics_endpoint.allowed_networks cannot be empty when enabled"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
self.parsed_diagnostics_allowed_networks()?;
|
||||
if self.diagnostics_endpoint.probe_timeout_secs == 0
|
||||
|| self.diagnostics_endpoint.probe_timeout_secs > 60
|
||||
{
|
||||
return Err(
|
||||
"diagnostics_endpoint.probe_timeout_secs must be between 1 and 60".to_string(),
|
||||
);
|
||||
}
|
||||
if self.diagnostics_endpoint.security_window_secs < 60
|
||||
|| self.diagnostics_endpoint.security_window_secs > 86_400
|
||||
{
|
||||
return Err(
|
||||
"diagnostics_endpoint.security_window_secs must be between 60 and 86400"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
match self
|
||||
.diagnostics_endpoint
|
||||
.auth
|
||||
.trim()
|
||||
.to_ascii_lowercase()
|
||||
.as_str()
|
||||
{
|
||||
"token" => {
|
||||
let token = self
|
||||
.diagnostics_endpoint
|
||||
.token
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or("diagnostics_endpoint.token is required when auth=token")?;
|
||||
if token.len() < 24 {
|
||||
return Err(
|
||||
"diagnostics_endpoint.token must contain at least 24 characters"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
"basic" => {
|
||||
let username = self
|
||||
.diagnostics_endpoint
|
||||
.basic_username
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or("diagnostics_endpoint.basic_username is required when auth=basic")?;
|
||||
let password = self
|
||||
.diagnostics_endpoint
|
||||
.basic_password
|
||||
.as_deref()
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or("diagnostics_endpoint.basic_password is required when auth=basic")?;
|
||||
if username.contains(':') {
|
||||
return Err(
|
||||
"diagnostics_endpoint.basic_username cannot contain ':'".to_string()
|
||||
);
|
||||
}
|
||||
if password.len() < 16 {
|
||||
return Err("diagnostics_endpoint.basic_password must contain at least 16 characters".to_string());
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
return Err(
|
||||
"diagnostics_endpoint.auth must be one of: token, basic".to_string()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let storage_backend = self.effective_storage_backend();
|
||||
if !matches!(storage_backend.as_str(), "filesystem" | "s3") {
|
||||
return Err("storage.backend must be one of: filesystem, s3".to_string());
|
||||
@@ -871,6 +1072,12 @@ impl AppConfig {
|
||||
{
|
||||
return Err("search timeout/worker settings must be greater than zero".to_string());
|
||||
}
|
||||
if self.sync.interval_secs == 0 || self.sync.deep_index_batch_size == 0 {
|
||||
return Err(
|
||||
"sync.interval_secs and sync.deep_index_batch_size must be greater than zero"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
if self.cluster_mode && self.database_backend == "sqlite" {
|
||||
return Err(
|
||||
"CLUSTER_MODE requires a shared mysql/pgsql application database; Redis only replaces session persistence"
|
||||
|
||||
+124
-21
@@ -12,6 +12,7 @@ pub struct CachedFolder {
|
||||
pub unread_count: u32,
|
||||
pub uid_validity: u32,
|
||||
pub highest_modseq: u64,
|
||||
pub counter_generation: u64,
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -68,6 +69,7 @@ pub fn get_all_folders(conn: &Connection) -> Result<Vec<CachedFolder>, String> {
|
||||
.query_map([], |row| {
|
||||
let is_subscribed_int: i32 = row.get(4)?;
|
||||
let highest_modseq_int: i64 = row.get(8)?;
|
||||
let counter_generation_int: i64 = row.get(9)?;
|
||||
Ok(CachedFolder {
|
||||
name: row.get(0)?,
|
||||
delimiter: row.get(1)?,
|
||||
@@ -78,6 +80,7 @@ pub fn get_all_folders(conn: &Connection) -> Result<Vec<CachedFolder>, String> {
|
||||
unread_count: row.get(6)?,
|
||||
uid_validity: row.get(7)?,
|
||||
highest_modseq: highest_modseq_int as u64,
|
||||
counter_generation: counter_generation_int as u64,
|
||||
})
|
||||
})
|
||||
.map_err(|e| format!("Failed to query folders: {e}"))?;
|
||||
@@ -118,6 +121,7 @@ pub fn touch_all_folders(conn: &Connection) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn update_folder_status(
|
||||
conn: &Connection,
|
||||
name: &str,
|
||||
@@ -147,21 +151,6 @@ pub fn update_folder_sync_status(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn refresh_unread_count(conn: &Connection, folder_name: &str) -> Result<(), String> {
|
||||
let unread: u32 = conn
|
||||
.query_row(
|
||||
crate::dbq!(db_folders::Q_008),
|
||||
params![folder_name],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(|e| format!("Failed to count unread messages: {e}"))?;
|
||||
|
||||
conn.execute(crate::dbq!(db_folders::Q_009), params![unread, folder_name])
|
||||
.map_err(|e| format!("Failed to update unread_count: {e}"))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_unread_count(
|
||||
conn: &Connection,
|
||||
folder_name: &str,
|
||||
@@ -175,15 +164,74 @@ pub fn set_unread_count(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn mark_folder_messages_fresh(conn: &Connection, folder_name: &str) -> Result<(), String> {
|
||||
conn.execute(crate::dbq!(db_folders::Q_029), params![folder_name])
|
||||
.map_err(|e| format!("Failed to mark folder messages fresh: {e}"))?;
|
||||
pub fn set_folder_counts(
|
||||
conn: &Connection,
|
||||
folder_name: &str,
|
||||
total_count: u32,
|
||||
unread_count: u32,
|
||||
) -> Result<(), String> {
|
||||
conn.execute(
|
||||
crate::dbq!(db_folders::Q_030),
|
||||
params![total_count, unread_count, folder_name],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set folder counts: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn adjust_unread_count(conn: &Connection, folder_name: &str, delta: i32) -> Result<(), String> {
|
||||
conn.execute(crate::dbq!(db_folders::Q_010), params![delta, folder_name])
|
||||
.map_err(|e| format!("Failed to adjust unread_count: {e}"))?;
|
||||
pub fn update_folder_sync_cursor(
|
||||
conn: &Connection,
|
||||
folder_name: &str,
|
||||
uid_validity: u32,
|
||||
highest_modseq: u64,
|
||||
) -> Result<(), String> {
|
||||
conn.execute(
|
||||
crate::dbq!(db_folders::Q_031),
|
||||
params![uid_validity, highest_modseq as i64, folder_name],
|
||||
)
|
||||
.map_err(|e| format!("Failed to update folder sync cursor: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_folder_counts_if_unchanged(
|
||||
conn: &Connection,
|
||||
folder_name: &str,
|
||||
expected_generation: u64,
|
||||
total_count: u32,
|
||||
unread_count: u32,
|
||||
) -> Result<bool, String> {
|
||||
let changed = conn
|
||||
.execute(
|
||||
crate::dbq!(db_folders::Q_032),
|
||||
params![
|
||||
total_count,
|
||||
unread_count,
|
||||
folder_name,
|
||||
expected_generation as i64
|
||||
],
|
||||
)
|
||||
.map_err(|e| format!("Failed to conditionally set folder counts: {e}"))?;
|
||||
Ok(changed > 0)
|
||||
}
|
||||
|
||||
pub fn pending_message_sync_count(conn: &Connection) -> Result<u32, String> {
|
||||
let mut pending = 0u64;
|
||||
for folder in get_all_folders(conn)? {
|
||||
if folder
|
||||
.flags
|
||||
.split(',')
|
||||
.any(|flag| flag.trim().eq_ignore_ascii_case("\\Noselect"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let cached = crate::db::messages::count_messages(conn, &folder.name)?;
|
||||
pending = pending.saturating_add(folder.total_count.saturating_sub(cached) as u64);
|
||||
}
|
||||
Ok(pending.min(u32::MAX as u64) as u32)
|
||||
}
|
||||
|
||||
pub fn mark_folder_messages_fresh(conn: &Connection, folder_name: &str) -> Result<(), String> {
|
||||
conn.execute(crate::dbq!(db_folders::Q_029), params![folder_name])
|
||||
.map_err(|e| format!("Failed to mark folder messages fresh: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -237,6 +285,7 @@ pub fn get_folder(conn: &Connection, name: &str) -> Result<Option<CachedFolder>,
|
||||
let result = conn.query_row(crate::dbq!(db_folders::Q_015), params![name], |row| {
|
||||
let is_subscribed_int: i32 = row.get(4)?;
|
||||
let highest_modseq_int: i64 = row.get(8)?;
|
||||
let counter_generation_int: i64 = row.get(9)?;
|
||||
Ok(CachedFolder {
|
||||
name: row.get(0)?,
|
||||
delimiter: row.get(1)?,
|
||||
@@ -247,6 +296,7 @@ pub fn get_folder(conn: &Connection, name: &str) -> Result<Option<CachedFolder>,
|
||||
unread_count: row.get(6)?,
|
||||
uid_validity: row.get(7)?,
|
||||
highest_modseq: highest_modseq_int as u64,
|
||||
counter_generation: counter_generation_int as u64,
|
||||
})
|
||||
});
|
||||
|
||||
@@ -433,6 +483,59 @@ mod tests {
|
||||
assert!(!is_folder_fresh(&conn, "Sent", 30).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conditional_folder_counts_reject_stale_writer() {
|
||||
let conn = open_test_db();
|
||||
upsert_folder(&conn, "INBOX", None, None, "", true, 3444, 12, 1, 50).unwrap();
|
||||
|
||||
let generation = get_folder(&conn, "INBOX")
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.counter_generation;
|
||||
assert!(set_folder_counts_if_unchanged(&conn, "INBOX", generation, 3444, 11).unwrap());
|
||||
assert!(!set_folder_counts_if_unchanged(&conn, "INBOX", generation, 3444, 10).unwrap());
|
||||
|
||||
let folder = get_folder(&conn, "INBOX").unwrap().unwrap();
|
||||
assert_eq!(folder.total_count, 3444);
|
||||
assert_eq!(folder.unread_count, 11);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn counter_generation_blocks_aba_stale_writer() {
|
||||
let conn = open_test_db();
|
||||
upsert_folder(&conn, "INBOX", None, None, "", true, 3444, 12, 1, 50).unwrap();
|
||||
|
||||
let generation_12 = get_folder(&conn, "INBOX")
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.counter_generation;
|
||||
assert!(set_folder_counts_if_unchanged(&conn, "INBOX", generation_12, 3444, 11).unwrap());
|
||||
|
||||
let generation_11 = get_folder(&conn, "INBOX")
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.counter_generation;
|
||||
assert!(set_folder_counts_if_unchanged(&conn, "INBOX", generation_11, 3444, 12).unwrap());
|
||||
assert!(!set_folder_counts_if_unchanged(&conn, "INBOX", generation_12, 3444, 10).unwrap());
|
||||
let folder = get_folder(&conn, "INBOX").unwrap().unwrap();
|
||||
assert_eq!((folder.total_count, folder.unread_count), (3444, 12));
|
||||
assert!(folder.counter_generation > generation_12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_cursor_update_does_not_change_authoritative_counts() {
|
||||
let conn = open_test_db();
|
||||
upsert_folder(&conn, "INBOX", None, None, "", true, 3444, 12, 1, 50).unwrap();
|
||||
|
||||
update_folder_sync_cursor(&conn, "INBOX", 2, 75).unwrap();
|
||||
|
||||
let folder = get_folder(&conn, "INBOX").unwrap().unwrap();
|
||||
assert_eq!(folder.total_count, 3444);
|
||||
assert_eq!(folder.unread_count, 12);
|
||||
assert_eq!(folder.uid_validity, 2);
|
||||
assert_eq!(folder.highest_modseq, 75);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_remove_stale_folders() {
|
||||
let conn = open_test_db();
|
||||
|
||||
@@ -476,6 +476,11 @@ pub fn get_unindexed_messages(conn: &Connection, limit: u32) -> Result<Vec<(Stri
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub fn count_unindexed_messages(conn: &Connection) -> Result<u32, String> {
|
||||
conn.query_row(crate::dbq!(db_messages::Q_015), [], |row| row.get(0))
|
||||
.map_err(|e| format!("Failed to count unindexed messages: {e}"))
|
||||
}
|
||||
|
||||
pub fn get_all_uids_and_flags(
|
||||
conn: &Connection,
|
||||
folder: &str,
|
||||
|
||||
+234
-15
@@ -7,6 +7,7 @@ use std::sync::{Arc, Mutex, OnceLock, mpsc};
|
||||
|
||||
use rusqlite::OptionalExtension as _;
|
||||
use rusqlite::types::{ToSql, ToSqlOutput, Value};
|
||||
use serde::Serialize;
|
||||
use sha2::{Digest, Sha256};
|
||||
use sqlx::pool::PoolConnection;
|
||||
use sqlx::{Executor as _, MySql, MySqlPool, PgPool, Postgres, Row as SqlxRow};
|
||||
@@ -20,18 +21,36 @@ const SQLITE_SCHEMA: &str = include_str!("../../migrations/sqlite/schema.sql");
|
||||
const MYSQL_SCHEMA: &str = include_str!("../../migrations/mysql/schema.sql");
|
||||
const PGSQL_SCHEMA: &str = include_str!("../../migrations/pgsql/schema.sql");
|
||||
|
||||
const SQLITE_MIGRATIONS: &[(u32, &str)] = &[(
|
||||
47,
|
||||
include_str!("../../migrations/sqlite/V047__blob_keys_and_search_outbox.sql"),
|
||||
)];
|
||||
const MYSQL_MIGRATIONS: &[(u32, &str)] = &[(
|
||||
47,
|
||||
include_str!("../../migrations/mysql/V047__blob_keys_and_search_outbox.sql"),
|
||||
)];
|
||||
const PGSQL_MIGRATIONS: &[(u32, &str)] = &[(
|
||||
47,
|
||||
include_str!("../../migrations/pgsql/V047__blob_keys_and_search_outbox.sql"),
|
||||
)];
|
||||
const SQLITE_MIGRATIONS: &[(u32, &str)] = &[
|
||||
(
|
||||
47,
|
||||
include_str!("../../migrations/sqlite/V047__blob_keys_and_search_outbox.sql"),
|
||||
),
|
||||
(
|
||||
48,
|
||||
include_str!("../../migrations/sqlite/V048__folder_counter_generation.sql"),
|
||||
),
|
||||
];
|
||||
const MYSQL_MIGRATIONS: &[(u32, &str)] = &[
|
||||
(
|
||||
47,
|
||||
include_str!("../../migrations/mysql/V047__blob_keys_and_search_outbox.sql"),
|
||||
),
|
||||
(
|
||||
48,
|
||||
include_str!("../../migrations/mysql/V048__folder_counter_generation.sql"),
|
||||
),
|
||||
];
|
||||
const PGSQL_MIGRATIONS: &[(u32, &str)] = &[
|
||||
(
|
||||
47,
|
||||
include_str!("../../migrations/pgsql/V047__blob_keys_and_search_outbox.sql"),
|
||||
),
|
||||
(
|
||||
48,
|
||||
include_str!("../../migrations/pgsql/V048__folder_counter_generation.sql"),
|
||||
),
|
||||
];
|
||||
|
||||
const TENANT_TABLES: &[&str] = &[
|
||||
"calendar_events",
|
||||
@@ -93,6 +112,26 @@ const IMPORT_TABLES: &[&str] = &[
|
||||
"user_meta",
|
||||
];
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct DatabaseDiagnostics {
|
||||
pub backend: String,
|
||||
pub status: String,
|
||||
pub latency_ms: u128,
|
||||
pub pool_size: Option<u32>,
|
||||
pub idle_connections: Option<usize>,
|
||||
pub database_bytes: Option<u64>,
|
||||
pub engine_version: Option<String>,
|
||||
pub account_count: Option<u64>,
|
||||
pub schema_version_min: Option<u32>,
|
||||
pub schema_version_max: Option<u32>,
|
||||
pub schema_aligned: Option<bool>,
|
||||
pub expected_schema_version: u32,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
type SqliteDiagnosticsProbe = (u64, Option<u32>, Option<u32>, Option<u64>, String);
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DatabaseBackend {
|
||||
Sqlite,
|
||||
@@ -312,6 +351,188 @@ pub fn configured_backend() -> DatabaseBackend {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn diagnostics() -> DatabaseDiagnostics {
|
||||
let started = std::time::Instant::now();
|
||||
let expected = latest_schema_version();
|
||||
match DATABASE.get() {
|
||||
Some(ConfiguredDatabase::Sqlite { path }) => {
|
||||
let path = path.clone();
|
||||
let result = tokio::task::spawn_blocking(move || -> Result<SqliteDiagnosticsProbe, String> {
|
||||
let conn = rusqlite::Connection::open(&path)
|
||||
.map_err(|e| format!("failed to open SQLite diagnostics connection: {e}"))?;
|
||||
conn.query_row("SELECT 1", [], |_row| Ok(()))
|
||||
.map_err(|e| format!("SQLite probe failed: {e}"))?;
|
||||
let engine_version: String = conn
|
||||
.query_row("SELECT sqlite_version()", [], |row| row.get(0))
|
||||
.map_err(|e| format!("SQLite version probe failed: {e}"))?;
|
||||
let (count, min_version, max_version): (i64, Option<i64>, Option<i64>) = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*), MIN(schema_version), MAX(schema_version) FROM rustbox_accounts",
|
||||
[],
|
||||
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
|
||||
)
|
||||
.map_err(|e| format!("SQLite account diagnostics failed: {e}"))?;
|
||||
let bytes = std::fs::metadata(&path).ok().map(|meta| meta.len());
|
||||
Ok((
|
||||
count.max(0) as u64,
|
||||
min_version.and_then(|value| u32::try_from(value).ok()),
|
||||
max_version.and_then(|value| u32::try_from(value).ok()),
|
||||
bytes,
|
||||
engine_version,
|
||||
))
|
||||
}).await;
|
||||
match result {
|
||||
Ok(Ok((count, min_version, max_version, bytes, engine_version))) => {
|
||||
DatabaseDiagnostics {
|
||||
backend: "sqlite".to_string(),
|
||||
status: "ok".to_string(),
|
||||
latency_ms: started.elapsed().as_millis(),
|
||||
pool_size: None,
|
||||
idle_connections: None,
|
||||
database_bytes: bytes,
|
||||
engine_version: Some(engine_version),
|
||||
account_count: Some(count),
|
||||
schema_version_min: min_version,
|
||||
schema_version_max: max_version,
|
||||
schema_aligned: Some(
|
||||
count == 0
|
||||
|| (min_version == Some(expected) && max_version == Some(expected)),
|
||||
),
|
||||
expected_schema_version: expected,
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
other => DatabaseDiagnostics {
|
||||
backend: "sqlite".to_string(),
|
||||
status: "error".to_string(),
|
||||
latency_ms: started.elapsed().as_millis(),
|
||||
pool_size: None,
|
||||
idle_connections: None,
|
||||
database_bytes: None,
|
||||
engine_version: None,
|
||||
account_count: None,
|
||||
schema_version_min: None,
|
||||
schema_version_max: None,
|
||||
schema_aligned: None,
|
||||
expected_schema_version: expected,
|
||||
error: Some(match other {
|
||||
Ok(Err(error)) => error,
|
||||
Err(error) => format!("SQLite diagnostics task failed: {error}"),
|
||||
Ok(Ok(_)) => unreachable!(),
|
||||
}),
|
||||
},
|
||||
}
|
||||
}
|
||||
Some(ConfiguredDatabase::Mysql { pool, .. }) => {
|
||||
let result: Result<(i64, Option<i64>, Option<i64>, String), String> = async {
|
||||
sqlx::query_scalar::<_, i32>("SELECT 1")
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|e| format!("MySQL probe failed: {e}"))?;
|
||||
let engine_version: String = sqlx::query_scalar("SELECT VERSION()")
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|e| format!("MySQL version probe failed: {e}"))?;
|
||||
let (count, min, max): (i64, Option<i64>, Option<i64>) = sqlx::query_as("SELECT COUNT(*), MIN(schema_version), MAX(schema_version) FROM rustbox_accounts")
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|e| format!("MySQL account diagnostics failed: {e}"))?;
|
||||
Ok((count, min, max, engine_version))
|
||||
}.await;
|
||||
let (status, account_count, min_version, max_version, engine_version, error) =
|
||||
match result {
|
||||
Ok((count, min, max, version)) => (
|
||||
"ok",
|
||||
Some(count.max(0) as u64),
|
||||
min.and_then(|v| u32::try_from(v).ok()),
|
||||
max.and_then(|v| u32::try_from(v).ok()),
|
||||
Some(version),
|
||||
None,
|
||||
),
|
||||
Err(error) => ("error", None, None, None, None, Some(error)),
|
||||
};
|
||||
DatabaseDiagnostics {
|
||||
backend: "mysql".to_string(),
|
||||
status: status.to_string(),
|
||||
latency_ms: started.elapsed().as_millis(),
|
||||
pool_size: Some(pool.size()),
|
||||
idle_connections: Some(pool.num_idle()),
|
||||
database_bytes: None,
|
||||
engine_version,
|
||||
account_count,
|
||||
schema_version_min: min_version,
|
||||
schema_version_max: max_version,
|
||||
schema_aligned: account_count.map(|count| {
|
||||
count == 0 || (min_version == Some(expected) && max_version == Some(expected))
|
||||
}),
|
||||
expected_schema_version: expected,
|
||||
error,
|
||||
}
|
||||
}
|
||||
Some(ConfiguredDatabase::Pgsql { pool, .. }) => {
|
||||
let result: Result<(i64, Option<i64>, Option<i64>, String), String> = async {
|
||||
sqlx::query_scalar::<_, i32>("SELECT 1")
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|e| format!("PostgreSQL probe failed: {e}"))?;
|
||||
let engine_version: String = sqlx::query_scalar("SHOW server_version")
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|e| format!("PostgreSQL version probe failed: {e}"))?;
|
||||
let (count, min, max): (i64, Option<i64>, Option<i64>) = sqlx::query_as("SELECT COUNT(*), MIN(schema_version), MAX(schema_version) FROM rustbox_accounts")
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|e| format!("PostgreSQL account diagnostics failed: {e}"))?;
|
||||
Ok((count, min, max, engine_version))
|
||||
}.await;
|
||||
let (status, account_count, min_version, max_version, engine_version, error) =
|
||||
match result {
|
||||
Ok((count, min, max, version)) => (
|
||||
"ok",
|
||||
Some(count.max(0) as u64),
|
||||
min.and_then(|v| u32::try_from(v).ok()),
|
||||
max.and_then(|v| u32::try_from(v).ok()),
|
||||
Some(version),
|
||||
None,
|
||||
),
|
||||
Err(error) => ("error", None, None, None, None, Some(error)),
|
||||
};
|
||||
DatabaseDiagnostics {
|
||||
backend: "pgsql".to_string(),
|
||||
status: status.to_string(),
|
||||
latency_ms: started.elapsed().as_millis(),
|
||||
pool_size: Some(pool.size()),
|
||||
idle_connections: Some(pool.num_idle()),
|
||||
database_bytes: None,
|
||||
engine_version,
|
||||
account_count,
|
||||
schema_version_min: min_version,
|
||||
schema_version_max: max_version,
|
||||
schema_aligned: account_count.map(|count| {
|
||||
count == 0 || (min_version == Some(expected) && max_version == Some(expected))
|
||||
}),
|
||||
expected_schema_version: expected,
|
||||
error,
|
||||
}
|
||||
}
|
||||
None => DatabaseDiagnostics {
|
||||
backend: "unconfigured".to_string(),
|
||||
status: "error".to_string(),
|
||||
latency_ms: started.elapsed().as_millis(),
|
||||
pool_size: None,
|
||||
idle_connections: None,
|
||||
database_bytes: None,
|
||||
engine_version: None,
|
||||
account_count: None,
|
||||
schema_version_min: None,
|
||||
schema_version_max: None,
|
||||
schema_aligned: None,
|
||||
expected_schema_version: expected,
|
||||
error: Some("database is not configured".to_string()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn list_account_user_hashes() -> Result<Vec<String>, String> {
|
||||
match DATABASE.get() {
|
||||
Some(ConfiguredDatabase::Sqlite { path }) => {
|
||||
@@ -1838,7 +2059,7 @@ fn tenant_prefix(user_hash: &str) -> Result<String, String> {
|
||||
Ok(format!("u_{}__", &normalized[..24]))
|
||||
}
|
||||
|
||||
fn latest_schema_version() -> u32 {
|
||||
pub fn latest_schema_version() -> u32 {
|
||||
let migrations = match configured_backend() {
|
||||
DatabaseBackend::Sqlite => SQLITE_MIGRATIONS,
|
||||
DatabaseBackend::Mysql => MYSQL_MIGRATIONS,
|
||||
@@ -2108,8 +2329,6 @@ fn ensure_server_tenant(
|
||||
let tenant = tenant_schema(PGSQL_SCHEMA, &prefix)?;
|
||||
let legacy_path = legacy_path.clone();
|
||||
runtime_wait(runtime, async move {
|
||||
// PostgreSQL advisory locks are session-scoped, so they work across all RustBox nodes
|
||||
// that share this database and disappear automatically with the connection.
|
||||
let lock_name = format!("rustbox:{prefix}");
|
||||
let mut lock_conn = pool.acquire().await.map_err(|e| {
|
||||
format!("failed to acquire PostgreSQL provisioning connection: {e}")
|
||||
|
||||
+91
-15
@@ -19,6 +19,60 @@ pub(crate) use super::connection::connect;
|
||||
#[path = "mock.rs"]
|
||||
pub mod mock;
|
||||
|
||||
async fn folder_status_extended_on_session(
|
||||
session: &mut async_imap::Session<super::connection::ImapStream>,
|
||||
folder: &str,
|
||||
) -> Result<FolderStatusExtended, ImapError> {
|
||||
if let Ok(mailbox) = session
|
||||
.status(
|
||||
folder,
|
||||
"(MESSAGES UIDNEXT UIDVALIDITY UNSEEN HIGHESTMODSEQ)",
|
||||
)
|
||||
.await
|
||||
&& let Some(unseen) = mailbox.unseen
|
||||
{
|
||||
return Ok(FolderStatusExtended {
|
||||
uid_validity: mailbox.uid_validity.unwrap_or(0),
|
||||
exists: mailbox.exists,
|
||||
uid_next: mailbox.uid_next.unwrap_or(0),
|
||||
unseen,
|
||||
highest_modseq: mailbox.highest_modseq.unwrap_or(0),
|
||||
});
|
||||
}
|
||||
|
||||
if let Ok(mailbox) = session
|
||||
.status(folder, "(MESSAGES UIDNEXT UIDVALIDITY UNSEEN)")
|
||||
.await
|
||||
&& let Some(unseen) = mailbox.unseen
|
||||
{
|
||||
return Ok(FolderStatusExtended {
|
||||
uid_validity: mailbox.uid_validity.unwrap_or(0),
|
||||
exists: mailbox.exists,
|
||||
uid_next: mailbox.uid_next.unwrap_or(0),
|
||||
unseen,
|
||||
highest_modseq: mailbox.highest_modseq.unwrap_or(0),
|
||||
});
|
||||
}
|
||||
|
||||
let mailbox = match session.select_condstore(folder).await {
|
||||
Ok(mailbox) => mailbox,
|
||||
Err(_) => session.select(folder).await.map_err(map_imap_error)?,
|
||||
};
|
||||
let unseen = session
|
||||
.uid_search("UNSEEN")
|
||||
.await
|
||||
.map_err(map_imap_error)?
|
||||
.len() as u32;
|
||||
|
||||
Ok(FolderStatusExtended {
|
||||
uid_validity: mailbox.uid_validity.unwrap_or(0),
|
||||
exists: mailbox.exists,
|
||||
uid_next: mailbox.uid_next.unwrap_or(0),
|
||||
unseen,
|
||||
highest_modseq: mailbox.highest_modseq.unwrap_or(0),
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[allow(clippy::double_must_use)]
|
||||
#[async_trait]
|
||||
@@ -170,6 +224,21 @@ pub trait ImapClient: Send + Sync {
|
||||
folder: &str,
|
||||
) -> Result<FolderStatusExtended, ImapError>;
|
||||
|
||||
async fn folder_statuses_extended(
|
||||
&self,
|
||||
creds: &ImapCredentials,
|
||||
folders: &[String],
|
||||
) -> Result<Vec<(String, FolderStatusExtended)>, ImapError> {
|
||||
let mut statuses = Vec::with_capacity(folders.len());
|
||||
for folder in folders {
|
||||
statuses.push((
|
||||
folder.clone(),
|
||||
self.folder_status_extended(creds, folder).await?,
|
||||
));
|
||||
}
|
||||
Ok(statuses)
|
||||
}
|
||||
|
||||
async fn fetch_changed_flags(
|
||||
&self,
|
||||
creds: &ImapCredentials,
|
||||
@@ -1073,25 +1142,32 @@ impl ImapClient for RealImapClient {
|
||||
folder: &str,
|
||||
) -> Result<FolderStatusExtended, ImapError> {
|
||||
let mut session = self.pool.checkout(creds).await?;
|
||||
let result = folder_status_extended_on_session(&mut session, folder).await;
|
||||
if result.is_ok() {
|
||||
session.recycle();
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
let mailbox = session
|
||||
.status(
|
||||
folder,
|
||||
"(MESSAGES UIDNEXT UIDVALIDITY UNSEEN HIGHESTMODSEQ)",
|
||||
)
|
||||
.await
|
||||
.map_err(map_imap_error)?;
|
||||
async fn folder_statuses_extended(
|
||||
&self,
|
||||
creds: &ImapCredentials,
|
||||
folders: &[String],
|
||||
) -> Result<Vec<(String, FolderStatusExtended)>, ImapError> {
|
||||
let mut session = self.pool.checkout(creds).await?;
|
||||
let mut statuses = Vec::with_capacity(folders.len());
|
||||
|
||||
let result = FolderStatusExtended {
|
||||
uid_validity: mailbox.uid_validity.unwrap_or(0),
|
||||
exists: mailbox.exists,
|
||||
uid_next: mailbox.uid_next.unwrap_or(0),
|
||||
unseen: mailbox.unseen.unwrap_or(0),
|
||||
highest_modseq: mailbox.highest_modseq.unwrap_or(0),
|
||||
};
|
||||
for folder in folders {
|
||||
match folder_status_extended_on_session(&mut session, folder).await {
|
||||
Ok(status) => statuses.push((folder.clone(), status)),
|
||||
Err(error) => {
|
||||
tracing::debug!(folder = %folder, error = %error, "Skipping folder STATUS refresh");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
session.recycle();
|
||||
Ok(result)
|
||||
Ok(statuses)
|
||||
}
|
||||
|
||||
async fn fetch_changed_flags(
|
||||
|
||||
+14
-1
@@ -186,9 +186,22 @@ async fn run_search_reindex(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn default_log_filter(log_level: &str) -> String {
|
||||
let app_level = log_level.trim().to_ascii_lowercase();
|
||||
match app_level.as_str() {
|
||||
"trace" => "warn,rustbox=trace,tower_http=debug".to_string(),
|
||||
"debug" => "warn,rustbox=debug,tower_http=info".to_string(),
|
||||
"info" => "warn,rustbox=info,tower_http=info".to_string(),
|
||||
"warn" => "warn,rustbox=warn,tower_http=warn".to_string(),
|
||||
"error" => "error,rustbox=error,tower_http=error".to_string(),
|
||||
"off" => "off".to_string(),
|
||||
_ => "warn,rustbox=info,tower_http=info".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn init_tracing(log_dir: Option<&str>, log_level: &str) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let env_filter = EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| EnvFilter::new(log_level.trim().to_ascii_lowercase()));
|
||||
.unwrap_or_else(|_| EnvFilter::new(default_log_filter(log_level)));
|
||||
if let Some(log_dir) = log_dir.map(str::trim).filter(|value| !value.is_empty()) {
|
||||
std::fs::create_dir_all(log_dir)?;
|
||||
let log_path = std::path::Path::new(log_dir).join("rustbox.log");
|
||||
|
||||
@@ -187,24 +187,25 @@ pub mod db_folders {
|
||||
ON DUPLICATE KEY UPDATE
|
||||
delimiter=VALUES(delimiter), parent=VALUES(parent), flags=VALUES(flags),
|
||||
is_subscribed=VALUES(is_subscribed), total_count=VALUES(total_count), unread_count=VALUES(unread_count),
|
||||
uid_validity=VALUES(uid_validity), highest_modseq=VALUES(highest_modseq), updated_at=CURRENT_TIMESTAMP";
|
||||
uid_validity=VALUES(uid_validity), highest_modseq=VALUES(highest_modseq),
|
||||
counter_generation=counter_generation + 1, updated_at=CURRENT_TIMESTAMP";
|
||||
pub const Q_002: &str = "INSERT IGNORE INTO folders
|
||||
(name, delimiter, flags, is_subscribed, updated_at)
|
||||
VALUES (?, ?, ?, 1, CURRENT_TIMESTAMP)";
|
||||
pub const Q_003: &str = "SELECT name, delimiter, parent, flags, is_subscribed,
|
||||
total_count, unread_count, uid_validity, highest_modseq
|
||||
total_count, unread_count, uid_validity, highest_modseq, counter_generation
|
||||
FROM folders
|
||||
ORDER BY name";
|
||||
pub const Q_004: &str = "DELETE FROM folders";
|
||||
pub const Q_005: &str = "UPDATE folders SET updated_at = CURRENT_TIMESTAMP";
|
||||
pub const Q_006: &str = "UPDATE folders SET uid_validity = ?, total_count = ?, messages_updated_at = CURRENT_TIMESTAMP
|
||||
pub const Q_006: &str = "UPDATE folders SET uid_validity = ?, total_count = ?, counter_generation = counter_generation + 1, messages_updated_at = CURRENT_TIMESTAMP
|
||||
WHERE name = ?";
|
||||
pub const Q_007: &str =
|
||||
"UPDATE folders SET uid_validity = ?, total_count = ?, highest_modseq = ?
|
||||
"UPDATE folders SET uid_validity = ?, total_count = ?, highest_modseq = ?, counter_generation = counter_generation + 1
|
||||
WHERE name = ?";
|
||||
pub const Q_008: &str = "SELECT CAST(COUNT(*) AS SIGNED) FROM messages WHERE folder = ? AND LOCATE(CONCAT(CHAR(92), 'Seen'), flags) = 0";
|
||||
pub const Q_009: &str = "UPDATE folders SET unread_count = ? WHERE name = ?";
|
||||
pub const Q_010: &str = "UPDATE folders SET unread_count = GREATEST(0, CAST(unread_count AS SIGNED) + ?) WHERE name = ?";
|
||||
pub const Q_009: &str = "UPDATE folders SET unread_count = ?, counter_generation = counter_generation + 1 WHERE name = ?";
|
||||
pub const Q_010: &str = "UPDATE folders SET unread_count = GREATEST(0, CAST(unread_count AS SIGNED) + ?), counter_generation = counter_generation + 1 WHERE name = ?";
|
||||
pub const Q_011: &str = "SELECT EXISTS(
|
||||
SELECT 1 FROM folders
|
||||
WHERE updated_at > DATE_FORMAT(DATE_SUB(CURRENT_TIMESTAMP, INTERVAL ? SECOND), '%Y-%m-%d %H:%i:%s')
|
||||
@@ -216,14 +217,14 @@ pub mod db_folders {
|
||||
pub const Q_013: &str = "SELECT messages_updated_at IS NULL FROM folders WHERE name = ?";
|
||||
pub const Q_014: &str = "UPDATE folders SET messages_updated_at = NULL WHERE name = ?";
|
||||
pub const Q_015: &str = "SELECT name, delimiter, parent, flags, is_subscribed,
|
||||
total_count, unread_count, uid_validity, highest_modseq
|
||||
total_count, unread_count, uid_validity, highest_modseq, counter_generation
|
||||
FROM folders
|
||||
WHERE name = ?";
|
||||
pub const Q_016: &str = "DELETE FROM messages WHERE folder = ?";
|
||||
pub const Q_017: &str = "DELETE FROM folders WHERE name = ?";
|
||||
pub const Q_018: &str = "SELECT 1";
|
||||
pub const Q_019: &str = "INSERT INTO folders (name, delimiter, parent, flags, is_subscribed, total_count, unread_count, uid_validity, highest_modseq, updated_at, messages_updated_at)
|
||||
SELECT ?, delimiter, parent, flags, is_subscribed, total_count, unread_count, uid_validity, highest_modseq, CURRENT_TIMESTAMP, messages_updated_at
|
||||
pub const Q_019: &str = "INSERT INTO folders (name, delimiter, parent, flags, is_subscribed, total_count, unread_count, uid_validity, highest_modseq, counter_generation, updated_at, messages_updated_at)
|
||||
SELECT ?, delimiter, parent, flags, is_subscribed, total_count, unread_count, uid_validity, highest_modseq, counter_generation, CURRENT_TIMESTAMP, messages_updated_at
|
||||
FROM folders WHERE name = ?";
|
||||
pub const Q_020: &str = "UPDATE messages SET folder = ? WHERE folder = ?";
|
||||
pub const Q_021: &str = "UPDATE message_tags SET message_folder = ? WHERE message_folder = ?";
|
||||
@@ -235,9 +236,13 @@ pub mod db_folders {
|
||||
pub const Q_026: &str =
|
||||
"UPDATE folders SET messages_updated_at = '2000-01-01 00:00:00' WHERE name = 'Sent'";
|
||||
pub const Q_027: &str = "SELECT messages_updated_at FROM folders WHERE name = 'Sent'";
|
||||
pub const Q_028: &str = "UPDATE folders SET unread_count = ? WHERE name = ?";
|
||||
pub const Q_028: &str = "UPDATE folders SET unread_count = ?, counter_generation = counter_generation + 1 WHERE name = ?";
|
||||
pub const Q_029: &str =
|
||||
"UPDATE folders SET messages_updated_at = CURRENT_TIMESTAMP WHERE name = ?";
|
||||
pub const Q_030: &str = "UPDATE folders SET total_count = ?, unread_count = ?, counter_generation = counter_generation + 1 WHERE name = ?";
|
||||
pub const Q_031: &str =
|
||||
"UPDATE folders SET uid_validity = ?, highest_modseq = ? WHERE name = ?";
|
||||
pub const Q_032: &str = "UPDATE folders SET total_count = ?, unread_count = ?, counter_generation = counter_generation + 1 WHERE name = ? AND counter_generation = ?";
|
||||
pub const FORMAT_001: &str = "DELETE FROM folders WHERE name NOT IN ({})";
|
||||
}
|
||||
|
||||
@@ -310,6 +315,12 @@ pub mod db_messages {
|
||||
LIMIT ?";
|
||||
pub const Q_013: &str = "SELECT uid, flags FROM messages WHERE folder = ?";
|
||||
pub const Q_014: &str = "SELECT CAST(SUM(size) AS SIGNED) FROM messages";
|
||||
pub const Q_015: &str = "SELECT COUNT(*) FROM messages
|
||||
WHERE body_cached = 0
|
||||
AND lower(folder) NOT IN ('trash', 'junk', 'spam')
|
||||
AND lower(folder) NOT LIKE '%/trash'
|
||||
AND lower(folder) NOT LIKE '%/junk'
|
||||
AND lower(folder) NOT LIKE '%/spam'";
|
||||
pub const FORMAT_001: &str = "SELECT {MSG_SELECT_COLS}
|
||||
FROM messages
|
||||
WHERE folder = ?
|
||||
@@ -463,9 +474,9 @@ pub mod routes_diagnostics {
|
||||
|
||||
pub mod routes_folder_mgmt {
|
||||
pub const Q_001: &str = "UPDATE messages SET flags = CASE WHEN flags = '' THEN ?2 ELSE CONCAT(flags, ',', ?2) END WHERE folder = ?1 AND LOCATE(?2, flags) = 0";
|
||||
pub const Q_002: &str = "UPDATE folders SET unread_count = 0 WHERE name = ?";
|
||||
pub const Q_002: &str = "UPDATE folders SET unread_count = 0, counter_generation = counter_generation + 1 WHERE name = ?";
|
||||
pub const Q_003: &str = "SELECT uid FROM messages WHERE folder = ?";
|
||||
pub const Q_004: &str = "UPDATE folders SET total_count = 0, unread_count = 0, messages_updated_at = CURRENT_TIMESTAMP WHERE name = ?";
|
||||
pub const Q_004: &str = "UPDATE folders SET total_count = 0, unread_count = 0, counter_generation = counter_generation + 1, messages_updated_at = CURRENT_TIMESTAMP WHERE name = ?";
|
||||
}
|
||||
|
||||
pub mod routes_messages_mod {
|
||||
|
||||
@@ -187,25 +187,26 @@ pub mod db_folders {
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, CURRENT_TIMESTAMP::text)
|
||||
ON CONFLICT (name) DO UPDATE SET delimiter=excluded.delimiter, parent=excluded.parent, flags=excluded.flags,
|
||||
is_subscribed=excluded.is_subscribed, total_count=excluded.total_count, unread_count=excluded.unread_count,
|
||||
uid_validity=excluded.uid_validity, highest_modseq=excluded.highest_modseq, updated_at=CURRENT_TIMESTAMP::text";
|
||||
uid_validity=excluded.uid_validity, highest_modseq=excluded.highest_modseq,
|
||||
counter_generation=folders.counter_generation + 1, updated_at=CURRENT_TIMESTAMP::text";
|
||||
pub const Q_002: &str = "INSERT INTO folders
|
||||
(name, delimiter, flags, is_subscribed, updated_at)
|
||||
VALUES ($1, $2, $3, 1, CURRENT_TIMESTAMP::text) ON CONFLICT (name) DO NOTHING";
|
||||
pub const Q_003: &str = "SELECT name, delimiter, parent, flags, is_subscribed,
|
||||
total_count, unread_count, uid_validity, highest_modseq
|
||||
total_count, unread_count, uid_validity, highest_modseq, counter_generation
|
||||
FROM folders
|
||||
ORDER BY name";
|
||||
pub const Q_004: &str = "DELETE FROM folders";
|
||||
pub const Q_005: &str = "UPDATE folders SET updated_at = CURRENT_TIMESTAMP::text";
|
||||
pub const Q_006: &str = "UPDATE folders SET uid_validity = $1, total_count = $2, messages_updated_at = CURRENT_TIMESTAMP::text
|
||||
pub const Q_006: &str = "UPDATE folders SET uid_validity = $1, total_count = $2, counter_generation = counter_generation + 1, messages_updated_at = CURRENT_TIMESTAMP::text
|
||||
WHERE name = $3";
|
||||
pub const Q_007: &str =
|
||||
"UPDATE folders SET uid_validity = $1, total_count = $2, highest_modseq = $3
|
||||
"UPDATE folders SET uid_validity = $1, total_count = $2, highest_modseq = $3, counter_generation = counter_generation + 1
|
||||
WHERE name = $4";
|
||||
pub const Q_008: &str =
|
||||
"SELECT COUNT(*) FROM messages WHERE folder = $1 AND strpos(flags, chr(92) || 'Seen') = 0";
|
||||
pub const Q_009: &str = "UPDATE folders SET unread_count = $1 WHERE name = $2";
|
||||
pub const Q_010: &str = "UPDATE folders SET unread_count = GREATEST(0, CAST(unread_count AS INTEGER) + $1) WHERE name = $2";
|
||||
pub const Q_009: &str = "UPDATE folders SET unread_count = $1, counter_generation = counter_generation + 1 WHERE name = $2";
|
||||
pub const Q_010: &str = "UPDATE folders SET unread_count = GREATEST(0, CAST(unread_count AS INTEGER) + $1), counter_generation = counter_generation + 1 WHERE name = $2";
|
||||
pub const Q_011: &str = "SELECT EXISTS(
|
||||
SELECT 1 FROM folders
|
||||
WHERE updated_at > to_char(CURRENT_TIMESTAMP - ($1 * INTERVAL '1 second'), 'YYYY-MM-DD HH24:MI:SS')
|
||||
@@ -217,14 +218,14 @@ pub mod db_folders {
|
||||
pub const Q_013: &str = "SELECT messages_updated_at IS NULL FROM folders WHERE name = $1";
|
||||
pub const Q_014: &str = "UPDATE folders SET messages_updated_at = NULL WHERE name = $1";
|
||||
pub const Q_015: &str = "SELECT name, delimiter, parent, flags, is_subscribed,
|
||||
total_count, unread_count, uid_validity, highest_modseq
|
||||
total_count, unread_count, uid_validity, highest_modseq, counter_generation
|
||||
FROM folders
|
||||
WHERE name = $1";
|
||||
pub const Q_016: &str = "DELETE FROM messages WHERE folder = $1";
|
||||
pub const Q_017: &str = "DELETE FROM folders WHERE name = $1";
|
||||
pub const Q_018: &str = "SELECT 1";
|
||||
pub const Q_019: &str = "INSERT INTO folders (name, delimiter, parent, flags, is_subscribed, total_count, unread_count, uid_validity, highest_modseq, updated_at, messages_updated_at)
|
||||
SELECT $1, delimiter, parent, flags, is_subscribed, total_count, unread_count, uid_validity, highest_modseq, CURRENT_TIMESTAMP::text, messages_updated_at
|
||||
pub const Q_019: &str = "INSERT INTO folders (name, delimiter, parent, flags, is_subscribed, total_count, unread_count, uid_validity, highest_modseq, counter_generation, updated_at, messages_updated_at)
|
||||
SELECT $1, delimiter, parent, flags, is_subscribed, total_count, unread_count, uid_validity, highest_modseq, counter_generation, CURRENT_TIMESTAMP::text, messages_updated_at
|
||||
FROM folders WHERE name = $2";
|
||||
pub const Q_020: &str = "UPDATE messages SET folder = $1 WHERE folder = $2";
|
||||
pub const Q_021: &str = "UPDATE message_tags SET message_folder = $1 WHERE message_folder = $2";
|
||||
@@ -236,9 +237,13 @@ pub mod db_folders {
|
||||
pub const Q_026: &str =
|
||||
"UPDATE folders SET messages_updated_at = '2000-01-01 00:00:00' WHERE name = 'Sent'";
|
||||
pub const Q_027: &str = "SELECT messages_updated_at FROM folders WHERE name = 'Sent'";
|
||||
pub const Q_028: &str = "UPDATE folders SET unread_count = $1 WHERE name = $2";
|
||||
pub const Q_028: &str = "UPDATE folders SET unread_count = $1, counter_generation = counter_generation + 1 WHERE name = $2";
|
||||
pub const Q_029: &str =
|
||||
"UPDATE folders SET messages_updated_at = CURRENT_TIMESTAMP::text WHERE name = $1";
|
||||
pub const Q_030: &str = "UPDATE folders SET total_count = $1, unread_count = $2, counter_generation = counter_generation + 1 WHERE name = $3";
|
||||
pub const Q_031: &str =
|
||||
"UPDATE folders SET uid_validity = $1, highest_modseq = $2 WHERE name = $3";
|
||||
pub const Q_032: &str = "UPDATE folders SET total_count = $1, unread_count = $2, counter_generation = counter_generation + 1 WHERE name = $3 AND counter_generation = $4";
|
||||
pub const FORMAT_001: &str = "DELETE FROM folders WHERE name NOT IN ({})";
|
||||
}
|
||||
|
||||
@@ -310,6 +315,12 @@ pub mod db_messages {
|
||||
LIMIT $1";
|
||||
pub const Q_013: &str = "SELECT uid, flags FROM messages WHERE folder = $1";
|
||||
pub const Q_014: &str = "SELECT SUM(size)::bigint FROM messages";
|
||||
pub const Q_015: &str = "SELECT COUNT(*) FROM messages
|
||||
WHERE body_cached = 0
|
||||
AND lower(folder) NOT IN ('trash', 'junk', 'spam')
|
||||
AND lower(folder) NOT LIKE '%/trash'
|
||||
AND lower(folder) NOT LIKE '%/junk'
|
||||
AND lower(folder) NOT LIKE '%/spam'";
|
||||
pub const FORMAT_001: &str = "SELECT {MSG_SELECT_COLS}
|
||||
FROM messages
|
||||
WHERE folder = $1
|
||||
@@ -467,9 +478,9 @@ pub mod routes_diagnostics {
|
||||
|
||||
pub mod routes_folder_mgmt {
|
||||
pub const Q_001: &str = "UPDATE messages SET flags = CASE WHEN flags = '' THEN $2 ELSE flags || ',' || $2 END WHERE folder = $1 AND POSITION($2 IN flags) = 0";
|
||||
pub const Q_002: &str = "UPDATE folders SET unread_count = 0 WHERE name = $1";
|
||||
pub const Q_002: &str = "UPDATE folders SET unread_count = 0, counter_generation = counter_generation + 1 WHERE name = $1";
|
||||
pub const Q_003: &str = "SELECT uid FROM messages WHERE folder = $1";
|
||||
pub const Q_004: &str = "UPDATE folders SET total_count = 0, unread_count = 0, messages_updated_at = CURRENT_TIMESTAMP::text WHERE name = $1";
|
||||
pub const Q_004: &str = "UPDATE folders SET total_count = 0, unread_count = 0, counter_generation = counter_generation + 1, messages_updated_at = CURRENT_TIMESTAMP::text WHERE name = $1";
|
||||
}
|
||||
|
||||
pub mod routes_messages_mod {
|
||||
|
||||
@@ -178,28 +178,33 @@ pub mod db_folder_preferences {
|
||||
}
|
||||
|
||||
pub mod db_folders {
|
||||
pub const Q_001: &str = "INSERT OR REPLACE INTO folders
|
||||
pub const Q_001: &str = "INSERT INTO folders
|
||||
(name, delimiter, parent, flags, is_subscribed,
|
||||
total_count, unread_count, uid_validity, highest_modseq, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, datetime('now'))";
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, datetime('now'))
|
||||
ON CONFLICT(name) DO UPDATE SET
|
||||
delimiter=excluded.delimiter, parent=excluded.parent, flags=excluded.flags,
|
||||
is_subscribed=excluded.is_subscribed, total_count=excluded.total_count, unread_count=excluded.unread_count,
|
||||
uid_validity=excluded.uid_validity, highest_modseq=excluded.highest_modseq,
|
||||
counter_generation=folders.counter_generation + 1, updated_at=datetime('now')";
|
||||
pub const Q_002: &str = "INSERT OR IGNORE INTO folders
|
||||
(name, delimiter, flags, is_subscribed, updated_at)
|
||||
VALUES (?1, ?2, ?3, 1, datetime('now'))";
|
||||
pub const Q_003: &str = "SELECT name, delimiter, parent, flags, is_subscribed,
|
||||
total_count, unread_count, uid_validity, highest_modseq
|
||||
total_count, unread_count, uid_validity, highest_modseq, counter_generation
|
||||
FROM folders
|
||||
ORDER BY name";
|
||||
pub const Q_004: &str = "DELETE FROM folders";
|
||||
pub const Q_005: &str = "UPDATE folders SET updated_at = datetime('now')";
|
||||
pub const Q_006: &str = "UPDATE folders SET uid_validity = ?1, total_count = ?2, messages_updated_at = datetime('now')
|
||||
pub const Q_006: &str = "UPDATE folders SET uid_validity = ?1, total_count = ?2, counter_generation = counter_generation + 1, messages_updated_at = datetime('now')
|
||||
WHERE name = ?3";
|
||||
pub const Q_007: &str =
|
||||
"UPDATE folders SET uid_validity = ?1, total_count = ?2, highest_modseq = ?3
|
||||
"UPDATE folders SET uid_validity = ?1, total_count = ?2, highest_modseq = ?3, counter_generation = counter_generation + 1
|
||||
WHERE name = ?4";
|
||||
pub const Q_008: &str =
|
||||
"SELECT COUNT(*) FROM messages WHERE folder = ?1 AND flags NOT LIKE '%\\Seen%'";
|
||||
pub const Q_009: &str = "UPDATE folders SET unread_count = ?1 WHERE name = ?2";
|
||||
pub const Q_010: &str = "UPDATE folders SET unread_count = MAX(0, CAST(unread_count AS INTEGER) + ?1) WHERE name = ?2";
|
||||
pub const Q_009: &str = "UPDATE folders SET unread_count = ?1, counter_generation = counter_generation + 1 WHERE name = ?2";
|
||||
pub const Q_010: &str = "UPDATE folders SET unread_count = MAX(0, CAST(unread_count AS INTEGER) + ?1), counter_generation = counter_generation + 1 WHERE name = ?2";
|
||||
pub const Q_011: &str = "SELECT EXISTS(
|
||||
SELECT 1 FROM folders
|
||||
WHERE updated_at > datetime('now', printf('-%d seconds', ?1))
|
||||
@@ -211,14 +216,14 @@ pub mod db_folders {
|
||||
pub const Q_013: &str = "SELECT messages_updated_at IS NULL FROM folders WHERE name = ?1";
|
||||
pub const Q_014: &str = "UPDATE folders SET messages_updated_at = NULL WHERE name = ?1";
|
||||
pub const Q_015: &str = "SELECT name, delimiter, parent, flags, is_subscribed,
|
||||
total_count, unread_count, uid_validity, highest_modseq
|
||||
total_count, unread_count, uid_validity, highest_modseq, counter_generation
|
||||
FROM folders
|
||||
WHERE name = ?1";
|
||||
pub const Q_016: &str = "DELETE FROM messages WHERE folder = ?1";
|
||||
pub const Q_017: &str = "DELETE FROM folders WHERE name = ?1";
|
||||
pub const Q_018: &str = "PRAGMA defer_foreign_keys = ON;";
|
||||
pub const Q_019: &str = "INSERT INTO folders (name, delimiter, parent, flags, is_subscribed, total_count, unread_count, uid_validity, highest_modseq, updated_at, messages_updated_at)
|
||||
SELECT ?1, delimiter, parent, flags, is_subscribed, total_count, unread_count, uid_validity, highest_modseq, datetime('now'), messages_updated_at
|
||||
pub const Q_019: &str = "INSERT INTO folders (name, delimiter, parent, flags, is_subscribed, total_count, unread_count, uid_validity, highest_modseq, counter_generation, updated_at, messages_updated_at)
|
||||
SELECT ?1, delimiter, parent, flags, is_subscribed, total_count, unread_count, uid_validity, highest_modseq, counter_generation, datetime('now'), messages_updated_at
|
||||
FROM folders WHERE name = ?2";
|
||||
pub const Q_020: &str = "UPDATE messages SET folder = ?1 WHERE folder = ?2";
|
||||
pub const Q_021: &str = "UPDATE message_tags SET message_folder = ?1 WHERE message_folder = ?2";
|
||||
@@ -230,9 +235,13 @@ pub mod db_folders {
|
||||
pub const Q_026: &str =
|
||||
"UPDATE folders SET messages_updated_at = '2000-01-01 00:00:00' WHERE name = 'Sent'";
|
||||
pub const Q_027: &str = "SELECT messages_updated_at FROM folders WHERE name = 'Sent'";
|
||||
pub const Q_028: &str = "UPDATE folders SET unread_count = ?1 WHERE name = ?2";
|
||||
pub const Q_028: &str = "UPDATE folders SET unread_count = ?1, counter_generation = counter_generation + 1 WHERE name = ?2";
|
||||
pub const Q_029: &str =
|
||||
"UPDATE folders SET messages_updated_at = datetime('now') WHERE name = ?1";
|
||||
pub const Q_030: &str = "UPDATE folders SET total_count = ?1, unread_count = ?2, counter_generation = counter_generation + 1 WHERE name = ?3";
|
||||
pub const Q_031: &str =
|
||||
"UPDATE folders SET uid_validity = ?1, highest_modseq = ?2 WHERE name = ?3";
|
||||
pub const Q_032: &str = "UPDATE folders SET total_count = ?1, unread_count = ?2, counter_generation = counter_generation + 1 WHERE name = ?3 AND counter_generation = ?4";
|
||||
pub const FORMAT_001: &str = "DELETE FROM folders WHERE name NOT IN ({})";
|
||||
}
|
||||
|
||||
@@ -299,6 +308,12 @@ pub mod db_messages {
|
||||
LIMIT ?1";
|
||||
pub const Q_013: &str = "SELECT uid, flags FROM messages WHERE folder = ?1";
|
||||
pub const Q_014: &str = "SELECT SUM(size) FROM messages";
|
||||
pub const Q_015: &str = "SELECT COUNT(*) FROM messages
|
||||
WHERE body_cached = 0
|
||||
AND lower(folder) NOT IN ('trash', 'junk', 'spam')
|
||||
AND lower(folder) NOT LIKE '%/trash'
|
||||
AND lower(folder) NOT LIKE '%/junk'
|
||||
AND lower(folder) NOT LIKE '%/spam'";
|
||||
pub const FORMAT_001: &str = "SELECT {MSG_SELECT_COLS}
|
||||
FROM messages
|
||||
WHERE folder = ?1
|
||||
@@ -457,9 +472,9 @@ pub mod routes_diagnostics {
|
||||
|
||||
pub mod routes_folder_mgmt {
|
||||
pub const Q_001: &str = "UPDATE messages SET flags = CASE WHEN flags = '' THEN ?2 ELSE flags || ',' || ?2 END WHERE folder = ?1 AND instr(flags, ?2) = 0";
|
||||
pub const Q_002: &str = "UPDATE folders SET unread_count = 0 WHERE name = ?1";
|
||||
pub const Q_002: &str = "UPDATE folders SET unread_count = 0, counter_generation = counter_generation + 1 WHERE name = ?1";
|
||||
pub const Q_003: &str = "SELECT uid FROM messages WHERE folder = ?1";
|
||||
pub const Q_004: &str = "UPDATE folders SET total_count = 0, unread_count = 0, messages_updated_at = datetime('now') WHERE name = ?1";
|
||||
pub const Q_004: &str = "UPDATE folders SET total_count = 0, unread_count = 0, counter_generation = counter_generation + 1, messages_updated_at = datetime('now') WHERE name = ?1";
|
||||
}
|
||||
|
||||
pub mod routes_messages_mod {
|
||||
|
||||
@@ -3,6 +3,12 @@ use std::collections::HashMap;
|
||||
use serde::Serialize;
|
||||
use tokio::sync::{RwLock, broadcast};
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct MessageFlagChange {
|
||||
pub uid: u32,
|
||||
pub flags: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(tag = "type", content = "data")]
|
||||
#[allow(dead_code)]
|
||||
@@ -17,10 +23,32 @@ pub enum MailEvent {
|
||||
|
||||
FlagsChanged {
|
||||
folder: String,
|
||||
changes: Vec<MessageFlagChange>,
|
||||
},
|
||||
|
||||
FolderAllRead {
|
||||
folder: String,
|
||||
through_uid: Option<u32>,
|
||||
},
|
||||
|
||||
FolderStatus {
|
||||
folder: String,
|
||||
total_count: u32,
|
||||
unread_count: u32,
|
||||
},
|
||||
|
||||
FolderUpdated,
|
||||
|
||||
SyncStatus {
|
||||
active: bool,
|
||||
folder: Option<String>,
|
||||
pending: u32,
|
||||
},
|
||||
|
||||
SyncInventory {
|
||||
pending: u32,
|
||||
},
|
||||
|
||||
IndexingStatus {
|
||||
active: bool,
|
||||
pending: u32,
|
||||
|
||||
+334
-111
@@ -2,16 +2,25 @@ use std::sync::Arc;
|
||||
|
||||
use dashmap::DashMap;
|
||||
use futures::StreamExt;
|
||||
use serde::Serialize;
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
use crate::config::AppConfig;
|
||||
use crate::imap::client::ImapCredentials;
|
||||
use crate::imap::connection::ImapStream;
|
||||
use crate::imap::parse::{decode_rfc2047, flag_to_string, has_attachments, imap_address_to_email};
|
||||
use crate::realtime::events::{EventBus, MailEvent};
|
||||
use crate::search::engine::SearchEngine;
|
||||
use crate::realtime::events::{EventBus, MailEvent, MessageFlagChange};
|
||||
use crate::search::engine::{IndexableMessage, SearchEngine, UserIndex};
|
||||
use crate::{db, imap};
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct IdleTaskDiagnostics {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub user_hash: Option<String>,
|
||||
pub folder: String,
|
||||
pub subscribers: usize,
|
||||
}
|
||||
|
||||
pub struct IdleManager {
|
||||
tasks: DashMap<(String, String), IdleTask>,
|
||||
}
|
||||
@@ -90,6 +99,21 @@ impl IdleManager {
|
||||
pub fn subscriber_count(&self) -> usize {
|
||||
self.tasks.iter().map(|task| task.value().subscribers).sum()
|
||||
}
|
||||
|
||||
pub fn diagnostics_snapshot(&self, include_user_identifiers: bool) -> Vec<IdleTaskDiagnostics> {
|
||||
let mut tasks = self
|
||||
.tasks
|
||||
.iter()
|
||||
.map(|task| IdleTaskDiagnostics {
|
||||
user_hash: include_user_identifiers.then(|| task.key().0.clone()),
|
||||
folder: task.key().1.clone(),
|
||||
subscribers: task.value().subscribers,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
tasks.sort_by(|left, right| left.folder.cmp(&right.folder));
|
||||
tasks.truncate(200);
|
||||
tasks
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for IdleManager {
|
||||
@@ -201,12 +225,28 @@ async fn run_idle_session(
|
||||
},
|
||||
)
|
||||
.await;
|
||||
} else if result.flags_updated > 0 || result.deleted_count > 0 {
|
||||
}
|
||||
if result.flags_updated > 0 || result.deleted_count > 0 {
|
||||
event_bus
|
||||
.publish(
|
||||
user_hash,
|
||||
MailEvent::FlagsChanged {
|
||||
folder: folder.to_string(),
|
||||
changes: result.flag_changes,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
if let Ok(conn) = db::pool::open_user_db(&config.data_dir, user_hash)
|
||||
&& let Ok(Some(folder_state)) = db::folders::get_folder(&conn, folder)
|
||||
{
|
||||
event_bus
|
||||
.publish(
|
||||
user_hash,
|
||||
MailEvent::FolderStatus {
|
||||
folder: folder.to_string(),
|
||||
total_count: folder_state.total_count,
|
||||
unread_count: folder_state.unread_count,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
@@ -249,6 +289,7 @@ struct ReconcileResult {
|
||||
latest_uid: Option<u32>,
|
||||
latest_sender: Option<String>,
|
||||
latest_subject: Option<String>,
|
||||
flag_changes: Vec<MessageFlagChange>,
|
||||
}
|
||||
|
||||
impl ReconcileResult {
|
||||
@@ -260,6 +301,7 @@ impl ReconcileResult {
|
||||
latest_uid: None,
|
||||
latest_sender: None,
|
||||
latest_subject: None,
|
||||
flag_changes: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -271,28 +313,42 @@ async fn fetch_new_after_idle(
|
||||
config: &AppConfig,
|
||||
search_engine: &SearchEngine,
|
||||
) -> (async_imap::Session<ImapStream>, ReconcileResult) {
|
||||
let (cached_modseq, max_cached_uid, cached_count, cached_uid_validity, header_sync) =
|
||||
match db::pool::open_user_db(&config.data_dir, user_hash) {
|
||||
Ok(conn) => {
|
||||
let header_sync = match db::header_sync::HeaderSync::capture(&conn) {
|
||||
Ok(sync) => sync,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "IDLE fetch: failed to read header generation");
|
||||
return (session, ReconcileResult::empty());
|
||||
}
|
||||
};
|
||||
let cached = db::folders::get_folder(&conn, folder).ok().flatten();
|
||||
let modseq = cached.as_ref().map(|f| f.highest_modseq).unwrap_or(0);
|
||||
let validity = cached.as_ref().map(|f| f.uid_validity).unwrap_or(0);
|
||||
let max_uid = db::messages::max_uid(&conn, folder).unwrap_or(0);
|
||||
let count = db::messages::count_messages(&conn, folder).unwrap_or(0);
|
||||
(modseq, max_uid, count, validity, header_sync)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "IDLE fetch: failed to open DB");
|
||||
return (session, ReconcileResult::empty());
|
||||
}
|
||||
};
|
||||
let (
|
||||
cached_modseq,
|
||||
max_cached_uid,
|
||||
cached_count,
|
||||
cached_counter_generation,
|
||||
cached_uid_validity,
|
||||
header_sync,
|
||||
) = match db::pool::open_user_db(&config.data_dir, user_hash) {
|
||||
Ok(conn) => {
|
||||
let header_sync = match db::header_sync::HeaderSync::capture(&conn) {
|
||||
Ok(sync) => sync,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "IDLE fetch: failed to read header generation");
|
||||
return (session, ReconcileResult::empty());
|
||||
}
|
||||
};
|
||||
let cached = db::folders::get_folder(&conn, folder).ok().flatten();
|
||||
let modseq = cached.as_ref().map(|f| f.highest_modseq).unwrap_or(0);
|
||||
let validity = cached.as_ref().map(|f| f.uid_validity).unwrap_or(0);
|
||||
let counter_generation = cached.as_ref().map(|f| f.counter_generation).unwrap_or(0);
|
||||
let max_uid = db::messages::max_uid(&conn, folder).unwrap_or(0);
|
||||
let count = db::messages::count_messages(&conn, folder).unwrap_or(0);
|
||||
(
|
||||
modseq,
|
||||
max_uid,
|
||||
count,
|
||||
counter_generation,
|
||||
validity,
|
||||
header_sync,
|
||||
)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "IDLE fetch: failed to open DB");
|
||||
return (session, ReconcileResult::empty());
|
||||
}
|
||||
};
|
||||
|
||||
let mailbox = match session.select_condstore(folder).await {
|
||||
Ok(mb) => mb,
|
||||
@@ -303,7 +359,13 @@ async fn fetch_new_after_idle(
|
||||
};
|
||||
|
||||
let server_exists = mailbox.exists;
|
||||
let server_unseen = mailbox.unseen;
|
||||
let server_unseen = match session.uid_search("UNSEEN").await {
|
||||
Ok(uids) => Some(uids.len() as u32),
|
||||
Err(error) => {
|
||||
tracing::debug!(folder = %folder, error = %error, "IDLE UNSEEN search failed");
|
||||
None
|
||||
}
|
||||
};
|
||||
let server_modseq = mailbox.highest_modseq.unwrap_or(0);
|
||||
let uid_validity = mailbox.uid_validity.unwrap_or(0);
|
||||
|
||||
@@ -314,6 +376,18 @@ async fn fetch_new_after_idle(
|
||||
return (session, ReconcileResult::empty());
|
||||
}
|
||||
|
||||
if let Some(unseen) = server_unseen
|
||||
&& let Ok(conn) = db::pool::open_user_db(&config.data_dir, user_hash)
|
||||
{
|
||||
let _ = db::folders::set_folder_counts_if_unchanged(
|
||||
&conn,
|
||||
folder,
|
||||
cached_counter_generation,
|
||||
server_exists,
|
||||
unseen,
|
||||
);
|
||||
}
|
||||
|
||||
if server_modseq > 0 && server_modseq == cached_modseq && server_exists == cached_count {
|
||||
tracing::debug!(
|
||||
user_hash = %user_hash,
|
||||
@@ -331,6 +405,7 @@ async fn fetch_new_after_idle(
|
||||
Ok(stream) => {
|
||||
let mut stream = std::pin::pin!(stream);
|
||||
let mut items = Vec::new();
|
||||
let mut complete = true;
|
||||
while let Some(result) = stream.next().await {
|
||||
match result {
|
||||
Ok(fetch) => {
|
||||
@@ -345,11 +420,12 @@ async fn fetch_new_after_idle(
|
||||
error = %e,
|
||||
"IDLE fetch: error reading CHANGEDSINCE stream"
|
||||
);
|
||||
complete = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(items)
|
||||
complete.then_some(items)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
@@ -378,18 +454,42 @@ async fn fetch_new_after_idle(
|
||||
let sync_keywords = db::mail_preferences::get_preferences(&conn)
|
||||
.map(|prefs| prefs.sync_imap_keywords)
|
||||
.unwrap_or(false);
|
||||
for (uid, flags) in &flag_changes {
|
||||
let mut sorted = flags.clone();
|
||||
sorted.sort();
|
||||
let flags_csv = sorted.join(",");
|
||||
if db::messages::update_message_flags(&conn, folder, *uid, &flags_csv).is_ok() {
|
||||
if sync_keywords
|
||||
&& let Err(error) =
|
||||
db::tags::import_imap_keywords(&conn, *uid, folder, &sorted)
|
||||
{
|
||||
tracing::debug!(uid = uid, error = %error, "IDLE keyword import failed");
|
||||
match header_sync.begin_write(&conn) {
|
||||
Ok(transaction) => {
|
||||
for (uid, flags) in &flag_changes {
|
||||
let mut sorted = flags.clone();
|
||||
sorted.sort();
|
||||
let flags_csv = sorted.join(",");
|
||||
if db::messages::update_message_flags(
|
||||
&transaction,
|
||||
folder,
|
||||
*uid,
|
||||
&flags_csv,
|
||||
)
|
||||
.is_ok()
|
||||
{
|
||||
if sync_keywords
|
||||
&& let Err(error) = db::tags::import_imap_keywords(
|
||||
&transaction,
|
||||
*uid,
|
||||
folder,
|
||||
&sorted,
|
||||
)
|
||||
{
|
||||
tracing::debug!(uid = uid, error = %error, "IDLE keyword import failed");
|
||||
}
|
||||
flags_updated += 1;
|
||||
}
|
||||
}
|
||||
flags_updated += 1;
|
||||
if let Err(error) = transaction.commit() {
|
||||
tracing::debug!(error = %error, "IDLE flag reconciliation commit failed");
|
||||
flags_updated = 0;
|
||||
flag_changes.clear();
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::debug!(error = %error, "IDLE flag reconciliation discarded stale cache generation");
|
||||
flag_changes.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -399,12 +499,15 @@ async fn fetch_new_after_idle(
|
||||
let uid_range = format!("{}:*", max_cached_uid + 1);
|
||||
new_count = fetch_and_store_headers(
|
||||
&mut session,
|
||||
user_hash,
|
||||
folder,
|
||||
config,
|
||||
HeaderFetchContext {
|
||||
user_hash,
|
||||
folder,
|
||||
config,
|
||||
search_engine,
|
||||
header_sync,
|
||||
},
|
||||
&uid_range,
|
||||
max_cached_uid,
|
||||
header_sync,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@@ -430,22 +533,16 @@ async fn fetch_new_after_idle(
|
||||
if let Ok(conn) = db::pool::open_user_db(&config.data_dir, user_hash)
|
||||
&& let Ok(transaction) = header_sync.begin_write(&conn)
|
||||
{
|
||||
let _ = db::folders::update_folder_sync_status(
|
||||
&conn,
|
||||
let _ = db::folders::update_folder_sync_cursor(
|
||||
&transaction,
|
||||
folder,
|
||||
uid_validity,
|
||||
server_exists,
|
||||
server_modseq,
|
||||
);
|
||||
if let Some(unseen) = server_unseen {
|
||||
let _ = db::folders::set_unread_count(&conn, folder, unseen);
|
||||
} else {
|
||||
let _ = db::folders::refresh_unread_count(&conn, folder);
|
||||
}
|
||||
if has_new && new_count == 0 {
|
||||
let _ = db::folders::invalidate_folder_freshness(&conn, folder);
|
||||
let _ = db::folders::invalidate_folder_freshness(&transaction, folder);
|
||||
} else {
|
||||
let _ = db::folders::mark_folder_messages_fresh(&conn, folder);
|
||||
let _ = db::folders::mark_folder_messages_fresh(&transaction, folder);
|
||||
}
|
||||
let _ = transaction.commit();
|
||||
}
|
||||
@@ -470,53 +567,136 @@ async fn fetch_new_after_idle(
|
||||
latest_uid,
|
||||
latest_sender,
|
||||
latest_subject,
|
||||
flag_changes: flag_changes
|
||||
.into_iter()
|
||||
.map(|(uid, mut flags)| {
|
||||
flags.sort();
|
||||
MessageFlagChange { uid, flags }
|
||||
})
|
||||
.collect(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let cached_uids: std::collections::HashSet<u32> =
|
||||
match db::pool::open_user_db(&config.data_dir, user_hash) {
|
||||
Ok(conn) => {
|
||||
let uids_flags =
|
||||
db::messages::get_all_uids_and_flags(&conn, folder).unwrap_or_default();
|
||||
uids_flags.iter().map(|(uid, _)| *uid).collect()
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "IDLE fetch: failed to open DB for fallback");
|
||||
return (session, ReconcileResult::empty());
|
||||
}
|
||||
};
|
||||
let cached_state = match db::pool::open_user_db(&config.data_dir, user_hash) {
|
||||
Ok(conn) => db::messages::get_all_uids_and_flags(&conn, folder).unwrap_or_default(),
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "IDLE fetch: failed to open DB for fallback");
|
||||
return (session, ReconcileResult::empty());
|
||||
}
|
||||
};
|
||||
let cached_uids = cached_state
|
||||
.iter()
|
||||
.map(|(uid, _)| *uid)
|
||||
.collect::<std::collections::HashSet<_>>();
|
||||
let cached_flags = cached_state
|
||||
.into_iter()
|
||||
.map(|(uid, flags)| {
|
||||
let mut normalized = flags
|
||||
.split(',')
|
||||
.filter(|flag| !flag.is_empty())
|
||||
.map(str::to_string)
|
||||
.collect::<Vec<_>>();
|
||||
normalized.sort();
|
||||
normalized.dedup();
|
||||
(uid, normalized)
|
||||
})
|
||||
.collect::<std::collections::HashMap<_, _>>();
|
||||
|
||||
let imap_uids: Option<std::collections::HashSet<u32>> =
|
||||
match session.uid_fetch("1:*", "UID").await {
|
||||
Ok(stream) => {
|
||||
let mut stream = std::pin::pin!(stream);
|
||||
let mut uids = std::collections::HashSet::new();
|
||||
while let Some(result) = stream.next().await {
|
||||
match result {
|
||||
Ok(fetch) => {
|
||||
if let Some(uid) = fetch.uid {
|
||||
uids.insert(uid);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "IDLE fetch: error reading UID stream");
|
||||
break;
|
||||
let imap_state: Option<std::collections::HashMap<u32, Vec<String>>> = match session
|
||||
.uid_fetch("1:*", "UID FLAGS")
|
||||
.await
|
||||
{
|
||||
Ok(stream) => {
|
||||
let mut stream = std::pin::pin!(stream);
|
||||
let mut state = std::collections::HashMap::new();
|
||||
let mut complete = true;
|
||||
while let Some(result) = stream.next().await {
|
||||
match result {
|
||||
Ok(fetch) => {
|
||||
if let Some(uid) = fetch.uid {
|
||||
let mut flags = fetch
|
||||
.flags()
|
||||
.map(|flag| flag_to_string(&flag))
|
||||
.collect::<Vec<_>>();
|
||||
flags.sort();
|
||||
flags.dedup();
|
||||
state.insert(uid, flags);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "IDLE fetch: error reading UID/FLAGS stream");
|
||||
complete = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
Some(uids)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "IDLE fetch: uid_fetch(UIDs) failed");
|
||||
None
|
||||
}
|
||||
};
|
||||
complete.then_some(state)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "IDLE fetch: uid_fetch(UID FLAGS) failed");
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
let Some(imap_uids) = imap_uids else {
|
||||
let Some(imap_state) = imap_state else {
|
||||
return (session, ReconcileResult::empty());
|
||||
};
|
||||
let imap_uids = imap_state
|
||||
.keys()
|
||||
.copied()
|
||||
.collect::<std::collections::HashSet<_>>();
|
||||
|
||||
let mut flag_changes = cached_flags
|
||||
.iter()
|
||||
.filter_map(|(uid, cached)| {
|
||||
imap_state
|
||||
.get(uid)
|
||||
.filter(|server| *server != cached)
|
||||
.map(|server| (*uid, server.clone()))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
flag_changes.sort_by_key(|(uid, _)| *uid);
|
||||
let mut flags_updated = 0u32;
|
||||
if !flag_changes.is_empty()
|
||||
&& let Ok(conn) = db::pool::open_user_db(&config.data_dir, user_hash)
|
||||
{
|
||||
let sync_keywords = db::mail_preferences::get_preferences(&conn)
|
||||
.map(|prefs| prefs.sync_imap_keywords)
|
||||
.unwrap_or(false);
|
||||
match header_sync.begin_write(&conn) {
|
||||
Ok(transaction) => {
|
||||
for (uid, flags) in &flag_changes {
|
||||
if db::messages::update_message_flags(
|
||||
&transaction,
|
||||
folder,
|
||||
*uid,
|
||||
&flags.join(","),
|
||||
)
|
||||
.is_ok()
|
||||
{
|
||||
if sync_keywords
|
||||
&& let Err(error) =
|
||||
db::tags::import_imap_keywords(&transaction, *uid, folder, flags)
|
||||
{
|
||||
tracing::debug!(uid = uid, error = %error, "IDLE fallback keyword import failed");
|
||||
}
|
||||
flags_updated += 1;
|
||||
}
|
||||
}
|
||||
if let Err(error) = transaction.commit() {
|
||||
tracing::debug!(error = %error, "IDLE fallback flag reconciliation commit failed");
|
||||
flags_updated = 0;
|
||||
flag_changes.clear();
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::debug!(error = %error, "IDLE fallback flag reconciliation discarded stale cache generation");
|
||||
flag_changes.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let has_new = imap_uids.iter().any(|&uid| uid > max_cached_uid);
|
||||
let mut new_count = 0u32;
|
||||
@@ -524,12 +704,15 @@ async fn fetch_new_after_idle(
|
||||
let uid_range = format!("{}:*", max_cached_uid + 1);
|
||||
new_count = fetch_and_store_headers(
|
||||
&mut session,
|
||||
user_hash,
|
||||
folder,
|
||||
config,
|
||||
HeaderFetchContext {
|
||||
user_hash,
|
||||
folder,
|
||||
config,
|
||||
search_engine,
|
||||
header_sync,
|
||||
},
|
||||
&uid_range,
|
||||
max_cached_uid,
|
||||
header_sync,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@@ -553,22 +736,16 @@ async fn fetch_new_after_idle(
|
||||
if let Ok(conn) = db::pool::open_user_db(&config.data_dir, user_hash)
|
||||
&& let Ok(transaction) = header_sync.begin_write(&conn)
|
||||
{
|
||||
let _ = db::folders::update_folder_sync_status(
|
||||
&conn,
|
||||
let _ = db::folders::update_folder_sync_cursor(
|
||||
&transaction,
|
||||
folder,
|
||||
uid_validity,
|
||||
server_exists,
|
||||
server_modseq,
|
||||
);
|
||||
if let Some(unseen) = server_unseen {
|
||||
let _ = db::folders::set_unread_count(&conn, folder, unseen);
|
||||
} else {
|
||||
let _ = db::folders::refresh_unread_count(&conn, folder);
|
||||
}
|
||||
if has_new && new_count == 0 {
|
||||
let _ = db::folders::invalidate_folder_freshness(&conn, folder);
|
||||
let _ = db::folders::invalidate_folder_freshness(&transaction, folder);
|
||||
} else {
|
||||
let _ = db::folders::mark_folder_messages_fresh(&conn, folder);
|
||||
let _ = db::folders::mark_folder_messages_fresh(&transaction, folder);
|
||||
}
|
||||
let _ = transaction.commit();
|
||||
}
|
||||
@@ -579,10 +756,11 @@ async fn fetch_new_after_idle(
|
||||
(None, None, None)
|
||||
};
|
||||
|
||||
if new_count > 0 || deleted_count > 0 {
|
||||
if flags_updated > 0 || new_count > 0 || deleted_count > 0 {
|
||||
tracing::info!(
|
||||
user_hash = %user_hash,
|
||||
folder = %folder,
|
||||
flags_updated = flags_updated,
|
||||
new = new_count,
|
||||
deleted = deleted_count,
|
||||
"Reconciled cache after IDLE (full scan)"
|
||||
@@ -593,11 +771,15 @@ async fn fetch_new_after_idle(
|
||||
session,
|
||||
ReconcileResult {
|
||||
new_count,
|
||||
flags_updated: 0,
|
||||
flags_updated,
|
||||
deleted_count,
|
||||
latest_uid,
|
||||
latest_sender,
|
||||
latest_subject,
|
||||
flag_changes: flag_changes
|
||||
.into_iter()
|
||||
.map(|(uid, flags)| MessageFlagChange { uid, flags })
|
||||
.collect(),
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -634,15 +816,27 @@ fn lookup_latest_message(
|
||||
}
|
||||
}
|
||||
|
||||
struct HeaderFetchContext<'a> {
|
||||
user_hash: &'a str,
|
||||
folder: &'a str,
|
||||
config: &'a AppConfig,
|
||||
search_engine: &'a SearchEngine,
|
||||
header_sync: db::header_sync::HeaderSync,
|
||||
}
|
||||
|
||||
async fn fetch_and_store_headers(
|
||||
session: &mut async_imap::Session<ImapStream>,
|
||||
user_hash: &str,
|
||||
folder: &str,
|
||||
config: &AppConfig,
|
||||
context: HeaderFetchContext<'_>,
|
||||
uid_range: &str,
|
||||
min_uid: u32,
|
||||
header_sync: db::header_sync::HeaderSync,
|
||||
) -> u32 {
|
||||
let HeaderFetchContext {
|
||||
user_hash,
|
||||
folder,
|
||||
config,
|
||||
search_engine,
|
||||
header_sync,
|
||||
} = context;
|
||||
let fetches: Option<Vec<_>> = match session
|
||||
.uid_fetch(
|
||||
uid_range,
|
||||
@@ -653,16 +847,18 @@ async fn fetch_and_store_headers(
|
||||
Ok(stream) => {
|
||||
let mut stream = std::pin::pin!(stream);
|
||||
let mut items = Vec::new();
|
||||
let mut complete = true;
|
||||
while let Some(result) = stream.next().await {
|
||||
match result {
|
||||
Ok(fetch) => items.push(fetch),
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "IDLE fetch: error reading header stream");
|
||||
complete = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(items)
|
||||
complete.then_some(items)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "IDLE fetch: uid_fetch(headers) failed");
|
||||
@@ -684,6 +880,11 @@ async fn fetch_and_store_headers(
|
||||
let sync_keywords = db::mail_preferences::get_preferences(&conn)
|
||||
.map(|prefs| prefs.sync_imap_keywords)
|
||||
.unwrap_or(false);
|
||||
let user_index = if search_engine.is_enabled() && !UserIndex::is_excluded_folder(folder) {
|
||||
search_engine.open_user_index(user_hash).ok()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
for fetch in &fetches {
|
||||
let uid = match fetch.uid {
|
||||
Some(u) if u > min_uid => u,
|
||||
@@ -787,7 +988,7 @@ async fn fetch_and_store_headers(
|
||||
let flags_csv = flags.join(",");
|
||||
|
||||
if let Err(e) = db::messages::upsert_message(
|
||||
&conn,
|
||||
&transaction,
|
||||
folder,
|
||||
uid,
|
||||
message_id.as_deref(),
|
||||
@@ -808,7 +1009,7 @@ async fn fetch_and_store_headers(
|
||||
tracing::warn!(uid = uid, error = %e, "IDLE fetch: failed to upsert message");
|
||||
} else {
|
||||
if let Err(e) = db::contacts::populate_known_addresses(
|
||||
&conn,
|
||||
&transaction,
|
||||
from_address,
|
||||
from_name,
|
||||
&to_json,
|
||||
@@ -817,10 +1018,30 @@ async fn fetch_and_store_headers(
|
||||
tracing::warn!(uid = uid, error = %e, "IDLE fetch: failed to populate known addresses");
|
||||
}
|
||||
if sync_keywords
|
||||
&& let Err(error) = db::tags::import_imap_keywords(&conn, uid, folder, &flags)
|
||||
&& let Err(error) =
|
||||
db::tags::import_imap_keywords(&transaction, uid, folder, &flags)
|
||||
{
|
||||
tracing::debug!(uid = uid, error = %error, "IDLE keyword import failed");
|
||||
}
|
||||
if let Some(index) = user_index.as_ref()
|
||||
&& let Err(error) = index.index_message_with_connection(
|
||||
&transaction,
|
||||
&IndexableMessage {
|
||||
uid,
|
||||
folder: folder.to_string(),
|
||||
subject: subject_str.to_string(),
|
||||
from_address: from_address.to_string(),
|
||||
from_name: from_name.to_string(),
|
||||
to_addresses: to_json.clone(),
|
||||
body_text: String::new(),
|
||||
date_epoch: db::messages::parse_date_to_epoch_public(date_str),
|
||||
has_attachments: has_attach,
|
||||
},
|
||||
)
|
||||
{
|
||||
tracing::warn!(uid = uid, error = %error, "IDLE fetch: failed to enqueue search indexing");
|
||||
return 0;
|
||||
}
|
||||
new_count += 1;
|
||||
}
|
||||
}
|
||||
@@ -888,6 +1109,7 @@ async fn fetch_uids_and_remove_deleted(
|
||||
Ok(stream) => {
|
||||
let mut stream = std::pin::pin!(stream);
|
||||
let mut uids = std::collections::HashSet::new();
|
||||
let mut complete = true;
|
||||
while let Some(result) = stream.next().await {
|
||||
match result {
|
||||
Ok(fetch) => {
|
||||
@@ -900,11 +1122,12 @@ async fn fetch_uids_and_remove_deleted(
|
||||
error = %e,
|
||||
"IDLE fetch: error reading UID stream for deletion check"
|
||||
);
|
||||
complete = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(uids)
|
||||
complete.then_some(uids)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
|
||||
+132
-39
@@ -5,12 +5,10 @@ use crate::config::AppConfig;
|
||||
use crate::db;
|
||||
use crate::email_theme;
|
||||
use crate::imap::client::{ImapClient, ImapCredentials};
|
||||
use crate::realtime::events::{EventBus, MailEvent};
|
||||
use crate::realtime::events::{EventBus, MailEvent, MessageFlagChange};
|
||||
use crate::search::engine::UserIndex;
|
||||
use crate::search::engine::{IndexableMessage, SearchEngine};
|
||||
|
||||
const SYNC_INTERVAL_SECS: u64 = 30;
|
||||
|
||||
pub async fn sync_loop(
|
||||
user_hash: String,
|
||||
creds: ImapCredentials,
|
||||
@@ -19,7 +17,8 @@ pub async fn sync_loop(
|
||||
event_bus: Arc<EventBus>,
|
||||
search_engine: Arc<SearchEngine>,
|
||||
) {
|
||||
let mut interval = tokio::time::interval(std::time::Duration::from_secs(SYNC_INTERVAL_SECS));
|
||||
let mut interval =
|
||||
tokio::time::interval(std::time::Duration::from_secs(config.sync.interval_secs));
|
||||
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
|
||||
loop {
|
||||
@@ -62,28 +61,81 @@ async fn run_sync(
|
||||
uid_validity: f.uid_validity,
|
||||
highest_modseq: f.highest_modseq,
|
||||
total_count: f.total_count,
|
||||
unread_count: f.unread_count,
|
||||
counter_generation: f.counter_generation,
|
||||
flags: f.flags,
|
||||
})
|
||||
.collect::<Vec<_>>())
|
||||
})
|
||||
.await?;
|
||||
|
||||
let mut any_changes = false;
|
||||
let folder_names = folder_snapshots
|
||||
.iter()
|
||||
.filter(|folder| !folder.is_noselect())
|
||||
.map(|folder| folder.name.clone())
|
||||
.collect::<Vec<_>>();
|
||||
let mut batched_statuses = imap_client
|
||||
.folder_statuses_extended(creds, &folder_names)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.collect::<HashMap<_, _>>();
|
||||
|
||||
for folder in &folder_snapshots {
|
||||
let folder_name = &folder.name;
|
||||
if folder.is_noselect() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let status = match imap_client.folder_status_extended(creds, folder_name).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
tracing::debug!(
|
||||
folder = %folder_name,
|
||||
error = %e,
|
||||
"Skipping folder sync (STATUS failed)"
|
||||
);
|
||||
continue;
|
||||
let status = if let Some(status) = batched_statuses.remove(folder_name) {
|
||||
status
|
||||
} else {
|
||||
match imap_client.folder_status_extended(creds, folder_name).await {
|
||||
Ok(status) => status,
|
||||
Err(error) => {
|
||||
tracing::debug!(
|
||||
folder = %folder_name,
|
||||
error = %error,
|
||||
"Skipping folder sync (STATUS failed)"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if status.exists != folder.total_count || status.unseen != folder.unread_count {
|
||||
let name = folder_name.clone();
|
||||
let expected_generation = folder.counter_generation;
|
||||
let total_count = status.exists;
|
||||
let unread_count = status.unseen;
|
||||
let applied = database
|
||||
.run(move |conn| {
|
||||
db::folders::set_folder_counts_if_unchanged(
|
||||
conn,
|
||||
&name,
|
||||
expected_generation,
|
||||
total_count,
|
||||
unread_count,
|
||||
)
|
||||
})
|
||||
.await?;
|
||||
if applied {
|
||||
event_bus
|
||||
.publish(
|
||||
user_hash,
|
||||
MailEvent::FolderStatus {
|
||||
folder: folder_name.clone(),
|
||||
total_count,
|
||||
unread_count,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
} else {
|
||||
tracing::debug!(folder = %folder_name, "Skipped stale periodic STATUS counters");
|
||||
}
|
||||
}
|
||||
|
||||
if folder.uid_validity != 0 && status.uid_validity != folder.uid_validity {
|
||||
tracing::info!(
|
||||
folder = %folder_name,
|
||||
@@ -162,7 +214,13 @@ async fn run_sync(
|
||||
}
|
||||
|
||||
if any_changes {
|
||||
event_bus.publish(user_hash, MailEvent::FolderUpdated).await;
|
||||
tracing::debug!(user_hash = %user_hash, "Periodic sync applied message changes");
|
||||
}
|
||||
|
||||
if let Ok(pending) = database.run(db::folders::pending_message_sync_count).await {
|
||||
event_bus
|
||||
.publish(user_hash, MailEvent::SyncInventory { pending })
|
||||
.await;
|
||||
}
|
||||
|
||||
if search_engine.is_enabled()
|
||||
@@ -182,8 +240,6 @@ async fn run_sync(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
const DEEP_INDEX_BATCH: u32 = 10;
|
||||
|
||||
async fn index_message_bodies(
|
||||
user_hash: &str,
|
||||
creds: &ImapCredentials,
|
||||
@@ -193,12 +249,18 @@ async fn index_message_bodies(
|
||||
search_engine: Arc<SearchEngine>,
|
||||
) -> Result<(), String> {
|
||||
let database = db::pool::UserDb::new(&config.data_dir, user_hash);
|
||||
let unindexed = database
|
||||
.run(|conn| {
|
||||
if !db::display_preferences::get_preferences(conn)?.deep_index {
|
||||
return Ok(Vec::new());
|
||||
let force_deep_index = config.search.deep_index;
|
||||
let deep_index_batch_size = config.sync.deep_index_batch_size;
|
||||
let (pending_before, unindexed) = database
|
||||
.run(move |conn| {
|
||||
let account_deep_index = db::display_preferences::get_preferences(conn)?.deep_index;
|
||||
if !force_deep_index && !account_deep_index {
|
||||
return Ok((0, Vec::new()));
|
||||
}
|
||||
db::messages::get_unindexed_messages(conn, DEEP_INDEX_BATCH)
|
||||
Ok((
|
||||
db::messages::count_unindexed_messages(conn)?,
|
||||
db::messages::get_unindexed_messages(conn, deep_index_batch_size)?,
|
||||
))
|
||||
})
|
||||
.await?;
|
||||
|
||||
@@ -208,7 +270,7 @@ async fn index_message_bodies(
|
||||
user_hash,
|
||||
MailEvent::IndexingStatus {
|
||||
active: false,
|
||||
pending: 0,
|
||||
pending: pending_before,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
@@ -220,7 +282,7 @@ async fn index_message_bodies(
|
||||
user_hash,
|
||||
MailEvent::IndexingStatus {
|
||||
active: true,
|
||||
pending: unindexed.len() as u32,
|
||||
pending: pending_before,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
@@ -297,12 +359,16 @@ async fn index_message_bodies(
|
||||
}
|
||||
.await;
|
||||
|
||||
let pending_after = database
|
||||
.run(db::messages::count_unindexed_messages)
|
||||
.await
|
||||
.unwrap_or(pending_before);
|
||||
event_bus
|
||||
.publish(
|
||||
user_hash,
|
||||
MailEvent::IndexingStatus {
|
||||
active: false,
|
||||
pending: 0,
|
||||
pending: pending_after,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
@@ -314,6 +380,17 @@ struct FolderSnapshot {
|
||||
uid_validity: u32,
|
||||
highest_modseq: u64,
|
||||
total_count: u32,
|
||||
unread_count: u32,
|
||||
counter_generation: u64,
|
||||
flags: String,
|
||||
}
|
||||
|
||||
impl FolderSnapshot {
|
||||
fn is_noselect(&self) -> bool {
|
||||
self.flags
|
||||
.split(',')
|
||||
.any(|flag| flag.trim().eq_ignore_ascii_case("\\Noselect"))
|
||||
}
|
||||
}
|
||||
|
||||
fn store_headers(
|
||||
@@ -551,7 +628,7 @@ async fn sync_condstore(
|
||||
let search_engine_for_write = Arc::clone(search_engine);
|
||||
let search_user_hash = user_hash.to_owned();
|
||||
let headers_for_write = new_headers;
|
||||
let (folder_changed, non_new_change) = database
|
||||
let (folder_changed, non_new_change, flag_changes) = database
|
||||
.run(move |conn| {
|
||||
let sync_keywords = db::mail_preferences::get_preferences(conn)
|
||||
.map(|prefs| prefs.sync_imap_keywords)
|
||||
@@ -560,9 +637,14 @@ async fn sync_condstore(
|
||||
.unchecked_transaction()
|
||||
.map_err(|error| error.to_string())?;
|
||||
let mut non_new_change = !changed.is_empty();
|
||||
let mut flag_changes = Vec::with_capacity(changed.len());
|
||||
for (uid, mut flags) in changed {
|
||||
flags.sort();
|
||||
db::messages::update_message_flags(&transaction, &name, uid, &flags.join(","))?;
|
||||
flag_changes.push(MessageFlagChange {
|
||||
uid,
|
||||
flags: flags.clone(),
|
||||
});
|
||||
if sync_keywords {
|
||||
db::tags::import_imap_keywords(&transaction, uid, &name, &flags)?;
|
||||
}
|
||||
@@ -588,19 +670,18 @@ async fn sync_condstore(
|
||||
&search_engine_for_write,
|
||||
&search_user_hash,
|
||||
)?;
|
||||
|
||||
let modseq = if new_modseq > 0 {
|
||||
new_modseq
|
||||
} else {
|
||||
status.highest_modseq
|
||||
cached_modseq
|
||||
};
|
||||
db::folders::update_folder_sync_status(
|
||||
db::folders::update_folder_sync_cursor(
|
||||
&transaction,
|
||||
&name,
|
||||
status.uid_validity,
|
||||
status.exists,
|
||||
modseq,
|
||||
)?;
|
||||
db::folders::set_unread_count(&transaction, &name, status.unseen)?;
|
||||
if has_unfetched_new {
|
||||
db::folders::invalidate_folder_freshness(&transaction, &name)?;
|
||||
} else if cache_initialized {
|
||||
@@ -610,6 +691,7 @@ async fn sync_condstore(
|
||||
Ok((
|
||||
non_new_change || !headers_for_write.is_empty(),
|
||||
non_new_change,
|
||||
flag_changes,
|
||||
))
|
||||
})
|
||||
.await?;
|
||||
@@ -635,6 +717,7 @@ async fn sync_condstore(
|
||||
user_hash,
|
||||
MailEvent::FlagsChanged {
|
||||
folder: folder_name.to_string(),
|
||||
changes: flag_changes,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
@@ -703,16 +786,21 @@ async fn sync_full(
|
||||
let status = status.clone();
|
||||
database
|
||||
.run(move |conn| {
|
||||
db::folders::update_folder_sync_status(
|
||||
let safe_modseq = if status.exists == 0 {
|
||||
status.highest_modseq
|
||||
} else {
|
||||
0
|
||||
};
|
||||
db::folders::update_folder_sync_cursor(
|
||||
conn,
|
||||
&name,
|
||||
status.uid_validity,
|
||||
status.exists,
|
||||
status.highest_modseq,
|
||||
safe_modseq,
|
||||
)?;
|
||||
db::folders::set_unread_count(conn, &name, status.unseen)?;
|
||||
if has_unfetched_new {
|
||||
if has_unfetched_new || status.exists > 0 {
|
||||
db::folders::invalidate_folder_freshness(conn, &name)?;
|
||||
} else {
|
||||
db::folders::mark_folder_messages_fresh(conn, &name)?;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
@@ -740,7 +828,7 @@ async fn sync_full(
|
||||
let search_engine_for_write = Arc::clone(search_engine);
|
||||
let search_user_hash = user_hash.to_owned();
|
||||
let headers_for_write = new_headers;
|
||||
let non_new_change = database
|
||||
let (non_new_change, flag_changes) = database
|
||||
.run(move |conn| {
|
||||
let folder_name = &name;
|
||||
let sync_keywords = db::mail_preferences::get_preferences(conn)
|
||||
@@ -758,6 +846,7 @@ async fn sync_full(
|
||||
.collect();
|
||||
|
||||
let mut non_new_change = false;
|
||||
let mut flag_changes = Vec::new();
|
||||
for (uid, cached_flags_csv) in &cached {
|
||||
match imap_map.get(uid) {
|
||||
None => {
|
||||
@@ -788,6 +877,10 @@ async fn sync_full(
|
||||
*uid,
|
||||
&imap_flags_csv,
|
||||
)?;
|
||||
flag_changes.push(MessageFlagChange {
|
||||
uid: *uid,
|
||||
flags: imap_flags.clone(),
|
||||
});
|
||||
non_new_change = true;
|
||||
}
|
||||
if sync_keywords {
|
||||
@@ -809,21 +902,20 @@ async fn sync_full(
|
||||
&search_engine_for_write,
|
||||
&search_user_hash,
|
||||
)?;
|
||||
db::folders::update_folder_sync_status(
|
||||
|
||||
db::folders::update_folder_sync_cursor(
|
||||
&transaction,
|
||||
folder_name,
|
||||
status.uid_validity,
|
||||
status.exists,
|
||||
status.highest_modseq,
|
||||
)?;
|
||||
db::folders::set_unread_count(&transaction, folder_name, status.unseen)?;
|
||||
if has_unfetched_new {
|
||||
db::folders::invalidate_folder_freshness(&transaction, folder_name)?;
|
||||
} else if cache_initialized {
|
||||
db::folders::mark_folder_messages_fresh(&transaction, folder_name)?;
|
||||
}
|
||||
transaction.commit().map_err(|error| error.to_string())?;
|
||||
Ok(non_new_change)
|
||||
Ok((non_new_change, flag_changes))
|
||||
})
|
||||
.await?;
|
||||
|
||||
@@ -848,6 +940,7 @@ async fn sync_full(
|
||||
user_hash,
|
||||
MailEvent::FlagsChanged {
|
||||
folder: folder_name.to_string(),
|
||||
changes: flag_changes,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
+15
-7
@@ -18,19 +18,27 @@ pub const BROWSER_COOKIE: &str = "rustbox_browser";
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AuthPolicyResponse {
|
||||
remember_me_enabled: bool,
|
||||
remember_me_days: u64,
|
||||
session_timeout_hours: u64,
|
||||
}
|
||||
|
||||
pub async fn auth_policy(Extension(config): Extension<Arc<AppConfig>>) -> Json<AuthPolicyResponse> {
|
||||
Json(AuthPolicyResponse {
|
||||
remember_me_enabled: config.remember_me_enabled,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SessionPolicyResponse {
|
||||
pgp_enabled: bool,
|
||||
sieve_enabled: bool,
|
||||
diagnostics_enabled: bool,
|
||||
about_enabled: bool,
|
||||
}
|
||||
|
||||
pub async fn auth_policy(Extension(config): Extension<Arc<AppConfig>>) -> Json<AuthPolicyResponse> {
|
||||
Json(AuthPolicyResponse {
|
||||
remember_me_enabled: config.remember_me_enabled,
|
||||
remember_me_days: config.remember_me_days,
|
||||
session_timeout_hours: config.session_timeout_hours,
|
||||
pub async fn session_policy(
|
||||
Extension(config): Extension<Arc<AppConfig>>,
|
||||
) -> Json<SessionPolicyResponse> {
|
||||
Json(SessionPolicyResponse {
|
||||
pgp_enabled: config.pgp_enabled,
|
||||
sieve_enabled: config.sieve_enabled,
|
||||
diagnostics_enabled: config.diagnostics_enabled,
|
||||
|
||||
@@ -624,7 +624,6 @@ pub async fn get_contact_photo_handler(
|
||||
Response::builder()
|
||||
.header("content-type", content_type)
|
||||
.header("cache-control", "private, max-age=3600")
|
||||
.header("x-content-type-options", "nosniff")
|
||||
.body(axum::body::Body::from(data))
|
||||
.map_err(|e| AppError::InternalError(format!("Failed to build photo response: {e}")))
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ use crate::config::AppConfig;
|
||||
use crate::db;
|
||||
use crate::error::AppError;
|
||||
use crate::imap::client::{ImapClient, ImapCredentials};
|
||||
use crate::realtime::events::{EventBus, MailEvent};
|
||||
use crate::realtime::idle::IdleManager;
|
||||
use crate::search::engine::{IndexableMessage, SearchEngine, UserIndex};
|
||||
use crate::smtp::client::{SmtpClient, SmtpCredentials};
|
||||
@@ -45,6 +46,7 @@ pub struct SmtpDiagnosticsResponse {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SearchDiagnosticsResponse {
|
||||
backend: String,
|
||||
deep_index_enabled: bool,
|
||||
indexed_documents: usize,
|
||||
cached_messages: usize,
|
||||
cached_bodies: usize,
|
||||
@@ -259,11 +261,14 @@ pub async fn search_diagnostics(
|
||||
let data_dir = config.data_dir.clone();
|
||||
let user_hash = session.user_hash.clone();
|
||||
let db_user_hash = user_hash.clone();
|
||||
let force_deep_index = config.search.deep_index;
|
||||
|
||||
let (cached_messages, cached_bodies, excluded_messages, outbox) =
|
||||
let (deep_index_enabled, cached_messages, cached_bodies, excluded_messages, outbox) =
|
||||
tokio::task::spawn_blocking(move || -> Result<_, String> {
|
||||
let conn = db::pool::open_user_db(&data_dir, &db_user_hash)
|
||||
.map_err(|e| format!("Database error: {e}"))?;
|
||||
let account_deep_index = db::display_preferences::get_preferences(&conn)?.deep_index;
|
||||
let deep_index_enabled = force_deep_index || account_deep_index;
|
||||
let sources = db::messages::list_index_source_messages(&conn)?;
|
||||
let cached_messages = sources.len();
|
||||
let excluded_messages = sources
|
||||
@@ -274,7 +279,13 @@ pub async fn search_diagnostics(
|
||||
.query_row(crate::dbq!(routes_diagnostics::Q_003), [], |row| row.get(0))
|
||||
.map_err(|e| format!("Failed to count cached bodies: {e}"))?;
|
||||
let outbox = db::search_outbox::stats(&conn).unwrap_or_default();
|
||||
Ok((cached_messages, cached_bodies, excluded_messages, outbox))
|
||||
Ok((
|
||||
deep_index_enabled,
|
||||
cached_messages,
|
||||
cached_bodies,
|
||||
excluded_messages,
|
||||
outbox,
|
||||
))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| AppError::InternalError(format!("Search diagnostics task failed: {e}")))?
|
||||
@@ -293,6 +304,7 @@ pub async fn search_diagnostics(
|
||||
|
||||
let stats = SearchDiagnosticsResponse {
|
||||
backend: search_engine.backend_name().to_string(),
|
||||
deep_index_enabled,
|
||||
indexed_documents,
|
||||
cached_messages,
|
||||
cached_bodies,
|
||||
@@ -309,6 +321,7 @@ pub async fn rebuild_search_index(
|
||||
Extension(session): Extension<SessionState>,
|
||||
Extension(config): Extension<Arc<AppConfig>>,
|
||||
Extension(search_engine): Extension<Arc<SearchEngine>>,
|
||||
Extension(event_bus): Extension<Arc<EventBus>>,
|
||||
) -> Result<Response, AppError> {
|
||||
if !search_engine.is_enabled() {
|
||||
return Err(AppError::ServiceUnavailable(
|
||||
@@ -330,13 +343,32 @@ pub async fn rebuild_search_index(
|
||||
.map_err(|e| AppError::InternalError(format!("Search rebuild task failed: {e}")))?
|
||||
.map_err(AppError::InternalError)?;
|
||||
|
||||
let index = search_engine
|
||||
.open_user_index(&user_hash)
|
||||
.map_err(AppError::InternalError)?;
|
||||
let indexed_documents = index
|
||||
.rebuild_from_messages(&messages)
|
||||
.await
|
||||
.map_err(AppError::InternalError)?;
|
||||
event_bus
|
||||
.publish(
|
||||
&user_hash,
|
||||
MailEvent::IndexingStatus {
|
||||
active: true,
|
||||
pending: source_messages.min(u32::MAX as usize) as u32,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
let rebuild_result = match search_engine.open_user_index(&user_hash) {
|
||||
Ok(index) => index.rebuild_from_messages(&messages).await,
|
||||
Err(error) => Err(error),
|
||||
};
|
||||
|
||||
event_bus
|
||||
.publish(
|
||||
&user_hash,
|
||||
MailEvent::IndexingStatus {
|
||||
active: false,
|
||||
pending: 0,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
let indexed_documents = rebuild_result.map_err(AppError::InternalError)?;
|
||||
|
||||
Ok(Json(SearchRebuildResponse {
|
||||
status: "rebuilt",
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
use crate::config::AppConfig;
|
||||
use crate::db;
|
||||
use crate::error::AppError;
|
||||
use crate::imap::client::{ImapClient, ImapCredentials};
|
||||
|
||||
pub(crate) type FolderCountSnapshot = Option<(u32, u32, u64)>;
|
||||
|
||||
pub(crate) fn snapshot(
|
||||
config: &AppConfig,
|
||||
user_hash: &str,
|
||||
folder: &str,
|
||||
) -> Result<FolderCountSnapshot, AppError> {
|
||||
let conn = db::pool::open_user_db(&config.data_dir, user_hash)
|
||||
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
|
||||
let state = db::folders::get_folder(&conn, folder)
|
||||
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
|
||||
Ok(state.map(|folder| {
|
||||
(
|
||||
folder.total_count,
|
||||
folder.unread_count,
|
||||
folder.counter_generation,
|
||||
)
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) fn current_counts(
|
||||
config: &AppConfig,
|
||||
user_hash: &str,
|
||||
folder: &str,
|
||||
) -> Result<Option<(u32, u32)>, AppError> {
|
||||
Ok(snapshot(config, user_hash, folder)?.map(|(total, unread, _generation)| (total, unread)))
|
||||
}
|
||||
|
||||
pub(crate) async fn reconcile_after_mutation(
|
||||
config: &AppConfig,
|
||||
user_hash: &str,
|
||||
imap_client: &dyn ImapClient,
|
||||
creds: &ImapCredentials,
|
||||
folder: &str,
|
||||
mut expected: FolderCountSnapshot,
|
||||
) -> Result<Option<(u32, u32)>, AppError> {
|
||||
for attempt in 0..2 {
|
||||
let status = match imap_client.folder_status_extended(creds, folder).await {
|
||||
Ok(status) => status,
|
||||
Err(error) => {
|
||||
tracing::debug!(
|
||||
folder = %folder,
|
||||
attempt,
|
||||
error = %error,
|
||||
"Could not refresh authoritative folder counters after IMAP mutation"
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
|
||||
let conn = db::pool::open_user_db(&config.data_dir, user_hash)
|
||||
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
|
||||
|
||||
let applied =
|
||||
if let Some((_expected_total, _expected_unread, expected_generation)) = expected {
|
||||
db::folders::set_folder_counts_if_unchanged(
|
||||
&conn,
|
||||
folder,
|
||||
expected_generation,
|
||||
status.exists,
|
||||
status.unseen,
|
||||
)
|
||||
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?
|
||||
} else {
|
||||
db::folders::set_folder_counts(&conn, folder, status.exists, status.unseen)
|
||||
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
|
||||
true
|
||||
};
|
||||
|
||||
if applied {
|
||||
return Ok(Some((status.exists, status.unseen)));
|
||||
}
|
||||
|
||||
expected = db::folders::get_folder(&conn, folder)
|
||||
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?
|
||||
.map(|state| {
|
||||
(
|
||||
state.total_count,
|
||||
state.unread_count,
|
||||
state.counter_generation,
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
current_counts(config, user_hash, folder)
|
||||
}
|
||||
|
||||
pub(crate) fn set_if_snapshot_matches(
|
||||
config: &AppConfig,
|
||||
user_hash: &str,
|
||||
folder: &str,
|
||||
expected: FolderCountSnapshot,
|
||||
total_count: u32,
|
||||
unread_count: u32,
|
||||
) -> Result<(u32, u32), AppError> {
|
||||
let conn = db::pool::open_user_db(&config.data_dir, user_hash)
|
||||
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
|
||||
|
||||
if let Some((_expected_total, _expected_unread, expected_generation)) = expected {
|
||||
let _ = db::folders::set_folder_counts_if_unchanged(
|
||||
&conn,
|
||||
folder,
|
||||
expected_generation,
|
||||
total_count,
|
||||
unread_count,
|
||||
)
|
||||
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
|
||||
}
|
||||
|
||||
let state = db::folders::get_folder(&conn, folder)
|
||||
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
|
||||
Ok(state
|
||||
.map(|state| (state.total_count, state.unread_count))
|
||||
.unwrap_or((total_count, unread_count)))
|
||||
}
|
||||
+157
-24
@@ -10,6 +10,8 @@ use crate::config::AppConfig;
|
||||
use crate::db;
|
||||
use crate::error::AppError;
|
||||
use crate::imap::client::{ImapClient, ImapCredentials};
|
||||
use crate::realtime::events::{EventBus, MailEvent};
|
||||
use crate::routes::folder_counts;
|
||||
use crate::search::engine::SearchEngine;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -32,6 +34,14 @@ struct MessageResponse {
|
||||
status: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct FolderCounterResponse {
|
||||
status: &'static str,
|
||||
total_count: u32,
|
||||
unread_count: u32,
|
||||
through_uid: Option<u32>,
|
||||
}
|
||||
|
||||
const SYSTEM_FOLDERS: &[&str] = &["INBOX", "Sent", "Drafts", "Trash", "Junk", "Spam"];
|
||||
|
||||
fn is_system_folder(name: &str) -> bool {
|
||||
@@ -188,25 +198,96 @@ pub async fn mark_all_read(
|
||||
Extension(session): Extension<SessionState>,
|
||||
Extension(config): Extension<Arc<AppConfig>>,
|
||||
Extension(imap_client): Extension<Arc<dyn ImapClient>>,
|
||||
Extension(event_bus): Extension<Arc<EventBus>>,
|
||||
Path(name): Path<String>,
|
||||
) -> Result<Response, AppError> {
|
||||
let creds = build_creds(&session, &config)?;
|
||||
let counter_snapshot = folder_counts::snapshot(&config, &session.user_hash, &name)?;
|
||||
let cached_flags_snapshot = {
|
||||
let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash)
|
||||
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
|
||||
db::messages::get_all_uids_and_flags(&conn, &name)
|
||||
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?
|
||||
};
|
||||
let through_uid = cached_flags_snapshot.iter().map(|(uid, _)| *uid).max();
|
||||
imap_client
|
||||
.mark_all_read(&creds, &name)
|
||||
.await
|
||||
.map_err(|e| AppError::ServiceUnavailable(format!("IMAP mark-all-read failed: {e}")))?;
|
||||
|
||||
let authoritative_counts = folder_counts::reconcile_after_mutation(
|
||||
&config,
|
||||
&session.user_hash,
|
||||
imap_client.as_ref(),
|
||||
&creds,
|
||||
&name,
|
||||
counter_snapshot,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash)
|
||||
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
|
||||
conn.execute(
|
||||
crate::dbq!(routes_folder_mgmt::Q_001),
|
||||
crate::db::pool::params![&name, "\\Seen"],
|
||||
)
|
||||
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
|
||||
conn.execute(crate::dbq!(routes_folder_mgmt::Q_002), [&name])
|
||||
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
|
||||
for (uid, flags_csv) in cached_flags_snapshot {
|
||||
let mut flags = flags_csv
|
||||
.split(',')
|
||||
.filter(|flag| !flag.is_empty())
|
||||
.map(str::to_string)
|
||||
.collect::<Vec<_>>();
|
||||
if !flags.iter().any(|flag| flag == "\\Seen") {
|
||||
flags.push("\\Seen".to_string());
|
||||
flags.sort();
|
||||
flags.dedup();
|
||||
db::messages::update_message_flags(&conn, &name, uid, &flags.join(","))
|
||||
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
|
||||
}
|
||||
}
|
||||
let (total_count, unread_count) = if let Some(counts) = authoritative_counts {
|
||||
counts
|
||||
} else if let Some((total_count, _unread_count, _generation)) = counter_snapshot {
|
||||
folder_counts::set_if_snapshot_matches(
|
||||
&config,
|
||||
&session.user_hash,
|
||||
&name,
|
||||
counter_snapshot,
|
||||
total_count,
|
||||
0,
|
||||
)?
|
||||
} else {
|
||||
db::folders::get_folder(&conn, &name)
|
||||
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?
|
||||
.map(|folder| (folder.total_count, folder.unread_count))
|
||||
.unwrap_or((0, 0))
|
||||
};
|
||||
let (total_count, unread_count) =
|
||||
folder_counts::current_counts(&config, &session.user_hash, &name)?
|
||||
.unwrap_or((total_count, unread_count));
|
||||
event_bus
|
||||
.publish(
|
||||
&session.user_hash,
|
||||
MailEvent::FolderStatus {
|
||||
folder: name.clone(),
|
||||
total_count,
|
||||
unread_count,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
event_bus
|
||||
.publish(
|
||||
&session.user_hash,
|
||||
MailEvent::FolderAllRead {
|
||||
folder: name,
|
||||
through_uid,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(Json(MessageResponse { status: "read" }).into_response())
|
||||
Ok(Json(FolderCounterResponse {
|
||||
status: "read",
|
||||
total_count,
|
||||
unread_count,
|
||||
through_uid,
|
||||
})
|
||||
.into_response())
|
||||
}
|
||||
|
||||
pub async fn empty_folder(
|
||||
@@ -214,21 +295,16 @@ pub async fn empty_folder(
|
||||
Extension(config): Extension<Arc<AppConfig>>,
|
||||
Extension(imap_client): Extension<Arc<dyn ImapClient>>,
|
||||
Extension(search_engine): Extension<Arc<SearchEngine>>,
|
||||
Extension(event_bus): Extension<Arc<EventBus>>,
|
||||
Path(name): Path<String>,
|
||||
) -> Result<Response, AppError> {
|
||||
let creds = build_creds(&session, &config)?;
|
||||
imap_client
|
||||
.empty_folder(&creds, &name)
|
||||
.await
|
||||
.map_err(|e| AppError::ServiceUnavailable(format!("IMAP empty-folder failed: {e}")))?;
|
||||
let counter_snapshot = folder_counts::snapshot(&config, &session.user_hash, &name)?;
|
||||
|
||||
let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash)
|
||||
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
|
||||
let transaction = conn
|
||||
.unchecked_transaction()
|
||||
.map_err(|e| AppError::InternalError(format!("Database transaction error: {e}")))?;
|
||||
let uids: Vec<u32> = {
|
||||
let mut statement = transaction
|
||||
let cached_uids: Vec<u32> = {
|
||||
let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash)
|
||||
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
|
||||
let mut statement = conn
|
||||
.prepare(crate::dbq!(routes_folder_mgmt::Q_003))
|
||||
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
|
||||
let rows = statement
|
||||
@@ -236,16 +312,41 @@ pub async fn empty_folder(
|
||||
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
|
||||
rows.filter_map(Result::ok).collect()
|
||||
};
|
||||
db::messages::delete_folder_messages(&transaction, &name)
|
||||
|
||||
imap_client
|
||||
.empty_folder(&creds, &name)
|
||||
.await
|
||||
.map_err(|e| AppError::ServiceUnavailable(format!("IMAP empty-folder failed: {e}")))?;
|
||||
|
||||
let authoritative_counts = folder_counts::reconcile_after_mutation(
|
||||
&config,
|
||||
&session.user_hash,
|
||||
imap_client.as_ref(),
|
||||
&creds,
|
||||
&name,
|
||||
counter_snapshot,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash)
|
||||
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
|
||||
transaction
|
||||
.execute(crate::dbq!(routes_folder_mgmt::Q_004), [&name])
|
||||
let transaction = conn
|
||||
.unchecked_transaction()
|
||||
.map_err(|e| AppError::InternalError(format!("Database transaction error: {e}")))?;
|
||||
|
||||
for uid in &cached_uids {
|
||||
db::messages::delete_message(&transaction, &name, *uid)
|
||||
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
|
||||
}
|
||||
db::header_sync::invalidate(&transaction)
|
||||
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
|
||||
db::folders::invalidate_folder_freshness(&transaction, &name)
|
||||
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
|
||||
|
||||
if let Ok(index) = search_engine.open_user_index(&session.user_hash) {
|
||||
for uid in uids {
|
||||
for uid in &cached_uids {
|
||||
index
|
||||
.delete_message_with_connection(&transaction, uid, &name)
|
||||
.delete_message_with_connection(&transaction, *uid, &name)
|
||||
.map_err(|e| AppError::InternalError(format!("Search outbox error: {e}")))?;
|
||||
}
|
||||
}
|
||||
@@ -253,6 +354,38 @@ pub async fn empty_folder(
|
||||
.commit()
|
||||
.map_err(|e| AppError::InternalError(format!("Database commit error: {e}")))?;
|
||||
|
||||
let (total_count, unread_count) = if let Some(counts) = authoritative_counts {
|
||||
counts
|
||||
} else if counter_snapshot.is_some() {
|
||||
folder_counts::set_if_snapshot_matches(
|
||||
&config,
|
||||
&session.user_hash,
|
||||
&name,
|
||||
counter_snapshot,
|
||||
0,
|
||||
0,
|
||||
)?
|
||||
} else {
|
||||
folder_counts::current_counts(&config, &session.user_hash, &name)?.unwrap_or((0, 0))
|
||||
};
|
||||
|
||||
let (total_count, unread_count) =
|
||||
folder_counts::current_counts(&config, &session.user_hash, &name)?
|
||||
.unwrap_or((total_count, unread_count));
|
||||
event_bus
|
||||
.publish(
|
||||
&session.user_hash,
|
||||
MailEvent::FolderStatus {
|
||||
folder: name.clone(),
|
||||
total_count,
|
||||
unread_count,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
event_bus
|
||||
.publish(&session.user_hash, MailEvent::FolderUpdated)
|
||||
.await;
|
||||
|
||||
Ok(Json(MessageResponse { status: "emptied" }).into_response())
|
||||
}
|
||||
|
||||
|
||||
+108
-12
@@ -1,8 +1,9 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::Query;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::{Extension, Json};
|
||||
use serde::Serialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::auth::session::SessionState;
|
||||
use crate::config::AppConfig;
|
||||
@@ -13,6 +14,8 @@ use crate::imap::client::{ImapClient, ImapCredentials};
|
||||
#[derive(Serialize)]
|
||||
struct FoldersResponse {
|
||||
folders: Vec<FolderEntry>,
|
||||
pending_sync_count: u32,
|
||||
pending_index_count: u32,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -27,10 +30,23 @@ struct FolderEntry {
|
||||
|
||||
const FOLDER_LIST_TTL_SECS: u32 = 300;
|
||||
|
||||
fn is_noselect(flags: &str) -> bool {
|
||||
flags
|
||||
.split(',')
|
||||
.any(|flag| flag.trim().eq_ignore_ascii_case("\\Noselect"))
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
pub(crate) struct ListFoldersQuery {
|
||||
#[serde(default)]
|
||||
refresh: bool,
|
||||
}
|
||||
|
||||
pub async fn list_folders(
|
||||
Extension(session): Extension<SessionState>,
|
||||
Extension(config): Extension<Arc<AppConfig>>,
|
||||
Extension(imap_client): Extension<Arc<dyn ImapClient>>,
|
||||
Query(query): Query<ListFoldersQuery>,
|
||||
) -> Result<Response, AppError> {
|
||||
let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash)
|
||||
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
|
||||
@@ -38,16 +54,16 @@ pub async fn list_folders(
|
||||
let cache_fresh = db::folders::is_folder_cache_fresh(&conn, FOLDER_LIST_TTL_SECS)
|
||||
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
|
||||
|
||||
if !cache_fresh {
|
||||
let creds = ImapCredentials {
|
||||
host: session.imap_host.clone(),
|
||||
port: session.imap_port,
|
||||
tls: session.imap_tls,
|
||||
tls_verify: session.imap_tls_verify,
|
||||
email: session.email.clone(),
|
||||
password: session.password.clone(),
|
||||
};
|
||||
let creds = ImapCredentials {
|
||||
host: session.imap_host.clone(),
|
||||
port: session.imap_port,
|
||||
tls: session.imap_tls,
|
||||
tls_verify: session.imap_tls_verify,
|
||||
email: session.email.clone(),
|
||||
password: session.password.clone(),
|
||||
};
|
||||
|
||||
if !cache_fresh {
|
||||
let imap_folders = imap_client
|
||||
.list_folders(&creds)
|
||||
.await
|
||||
@@ -72,9 +88,84 @@ pub async fn list_folders(
|
||||
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
|
||||
}
|
||||
|
||||
let cached = db::folders::get_all_folders(&conn)
|
||||
let mut cached = db::folders::get_all_folders(&conn)
|
||||
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
|
||||
|
||||
if query.refresh || !cache_fresh {
|
||||
let names = cached
|
||||
.iter()
|
||||
.filter(|folder| !is_noselect(&folder.flags))
|
||||
.map(|folder| folder.name.clone())
|
||||
.collect::<Vec<_>>();
|
||||
for folder in cached.iter().filter(|folder| is_noselect(&folder.flags)) {
|
||||
db::folders::set_folder_counts(&conn, &folder.name, 0, 0)
|
||||
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
|
||||
}
|
||||
let mut statuses = imap_client
|
||||
.folder_statuses_extended(&creds, &names)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.collect::<std::collections::HashMap<_, _>>();
|
||||
|
||||
for name in &names {
|
||||
let status = if let Some(status) = statuses.remove(name) {
|
||||
Some(status)
|
||||
} else {
|
||||
match imap_client.folder_status_extended(&creds, name).await {
|
||||
Ok(status) => Some(status),
|
||||
Err(error) => {
|
||||
tracing::debug!(folder = %name, error = %error, "Folder STATUS refresh failed; keeping cached counters");
|
||||
None
|
||||
}
|
||||
}
|
||||
};
|
||||
let Some(status) = status else { continue };
|
||||
let cached_folder = cached
|
||||
.iter()
|
||||
.find(|folder| folder.name.as_str() == name.as_str());
|
||||
if let Some(cached_folder) = cached_folder
|
||||
&& cached_folder.uid_validity != 0
|
||||
&& status.uid_validity != 0
|
||||
&& cached_folder.uid_validity != status.uid_validity
|
||||
{
|
||||
db::folders::invalidate_folder_freshness(&conn, name)
|
||||
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
|
||||
}
|
||||
|
||||
if let Some(cached_folder) = cached_folder {
|
||||
let applied = db::folders::set_folder_counts_if_unchanged(
|
||||
&conn,
|
||||
name,
|
||||
cached_folder.counter_generation,
|
||||
status.exists,
|
||||
status.unseen,
|
||||
)
|
||||
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
|
||||
if !applied {
|
||||
tracing::debug!(folder = %name, "Skipped stale folder STATUS counters");
|
||||
}
|
||||
} else {
|
||||
db::folders::set_folder_counts(&conn, name, status.exists, status.unseen)
|
||||
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
|
||||
}
|
||||
}
|
||||
cached = db::folders::get_all_folders(&conn)
|
||||
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
|
||||
}
|
||||
|
||||
let pending_sync_count = db::folders::pending_message_sync_count(&conn)
|
||||
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
|
||||
let account_deep_index = db::display_preferences::get_preferences(&conn)
|
||||
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?
|
||||
.deep_index;
|
||||
let pending_index_count = if config.search.deep_index || account_deep_index {
|
||||
db::messages::count_unindexed_messages(&conn)
|
||||
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
let folders: Vec<FolderEntry> = cached
|
||||
.into_iter()
|
||||
.map(|f| {
|
||||
@@ -94,5 +185,10 @@ pub async fn list_folders(
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(FoldersResponse { folders }).into_response())
|
||||
Ok(Json(FoldersResponse {
|
||||
folders,
|
||||
pending_sync_count,
|
||||
pending_index_count,
|
||||
})
|
||||
.into_response())
|
||||
}
|
||||
+633
-129
File diff suppressed because it is too large.
Load diff
+98
-20
@@ -6,6 +6,7 @@ pub mod contacts;
|
||||
pub mod diagnostics;
|
||||
pub mod display_preferences;
|
||||
pub mod drafts;
|
||||
pub mod folder_counts;
|
||||
pub mod folder_mgmt;
|
||||
pub mod folder_preferences;
|
||||
pub mod folders;
|
||||
@@ -22,6 +23,7 @@ pub mod send;
|
||||
pub mod settings;
|
||||
pub mod sieve;
|
||||
pub mod special_folders;
|
||||
pub mod system_diagnostics;
|
||||
pub mod tags;
|
||||
pub mod trusted_senders;
|
||||
pub mod unsubscribe;
|
||||
@@ -32,7 +34,7 @@ use std::time::Duration;
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::extract::{ConnectInfo, DefaultBodyLimit};
|
||||
use axum::http::{HeaderValue, Request};
|
||||
use axum::http::{HeaderValue, Request, header};
|
||||
use axum::response::Response;
|
||||
use axum::routing::{delete, get, patch, post, put};
|
||||
use axum::{Extension, Router, middleware};
|
||||
@@ -104,29 +106,70 @@ impl KeyExtractor for ProxyAwareIpExtractor {
|
||||
}
|
||||
}
|
||||
|
||||
fn response_content_type_is(response: &Response, expected: &str) -> bool {
|
||||
response
|
||||
.headers()
|
||||
.get(header::CONTENT_TYPE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(|value| value.split(';').next())
|
||||
.is_some_and(|value| value.trim().eq_ignore_ascii_case(expected))
|
||||
}
|
||||
|
||||
async fn add_security_headers(request: Request<Body>, next: middleware::Next) -> Response {
|
||||
let request_path = request.uri().path().to_owned();
|
||||
let is_api =
|
||||
request_path == "/api" || request_path.ends_with("/api") || request_path.contains("/api/");
|
||||
let mut response = next.run(request).await;
|
||||
let is_html = response_content_type_is(&response, "text/html")
|
||||
|| response_content_type_is(&response, "application/xhtml+xml");
|
||||
let is_css = response_content_type_is(&response, "text/css");
|
||||
let headers = response.headers_mut();
|
||||
|
||||
headers.insert(
|
||||
"x-content-type-options",
|
||||
HeaderValue::from_static("nosniff"),
|
||||
);
|
||||
headers.insert("x-frame-options", HeaderValue::from_static("DENY"));
|
||||
headers.insert("referrer-policy", HeaderValue::from_static("no-referrer"));
|
||||
headers.insert(
|
||||
"permissions-policy",
|
||||
HeaderValue::from_static("camera=(), microphone=(), geolocation=()"),
|
||||
);
|
||||
headers.insert(
|
||||
"cross-origin-opener-policy",
|
||||
HeaderValue::from_static("same-origin"),
|
||||
);
|
||||
headers.insert(
|
||||
"content-security-policy",
|
||||
HeaderValue::from_static(
|
||||
"default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http: https:; font-src 'self' data:; connect-src 'self' ws: wss:",
|
||||
),
|
||||
);
|
||||
|
||||
if is_api {
|
||||
headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
|
||||
headers.insert(
|
||||
"cross-origin-resource-policy",
|
||||
HeaderValue::from_static("same-origin"),
|
||||
);
|
||||
headers.insert("x-frame-options", HeaderValue::from_static("DENY"));
|
||||
headers.insert(
|
||||
"content-security-policy",
|
||||
HeaderValue::from_static("frame-ancestors 'none'"),
|
||||
);
|
||||
} else if is_html || is_css {
|
||||
headers.insert("referrer-policy", HeaderValue::from_static("no-referrer"));
|
||||
}
|
||||
|
||||
if request_path.contains("/assets/") || request_path.ends_with("/favicon.svg") {
|
||||
headers.insert(
|
||||
"cross-origin-resource-policy",
|
||||
HeaderValue::from_static("same-origin"),
|
||||
);
|
||||
}
|
||||
|
||||
if is_html {
|
||||
headers.insert("x-frame-options", HeaderValue::from_static("DENY"));
|
||||
headers.insert(
|
||||
"permissions-policy",
|
||||
HeaderValue::from_static("camera=(), microphone=(), geolocation=()"),
|
||||
);
|
||||
headers.insert(
|
||||
"cross-origin-opener-policy",
|
||||
HeaderValue::from_static("same-origin"),
|
||||
);
|
||||
headers.insert(
|
||||
"content-security-policy",
|
||||
HeaderValue::from_static(
|
||||
"default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; script-src 'self'; script-src-attr 'none'; style-src 'self'; style-src-attr 'unsafe-inline'; img-src 'self' data: blob: http: https:; font-src 'self' data:; connect-src 'self' ws: wss:; frame-src 'self' blob:",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
response
|
||||
}
|
||||
|
||||
@@ -176,6 +219,8 @@ pub fn create_router_with_storage(config: Arc<AppConfig>, services: RouterServic
|
||||
event_bus,
|
||||
idle_manager,
|
||||
} = services;
|
||||
let system_diagnostics_telemetry =
|
||||
Arc::new(system_diagnostics::SystemDiagnosticsTelemetry::new());
|
||||
let governor_conf = GovernorConfigBuilder::default()
|
||||
.key_extractor(ProxyAwareIpExtractor {
|
||||
trusted_proxies: config.parsed_trusted_proxies(),
|
||||
@@ -200,6 +245,7 @@ pub fn create_router_with_storage(config: Arc<AppConfig>, services: RouterServic
|
||||
|
||||
let protected_auth = Router::new()
|
||||
.route("/session", get(auth::get_session))
|
||||
.route("/session-policy", get(auth::session_policy))
|
||||
.route("/sessions", get(auth::list_active_sessions))
|
||||
.route("/sessions/revoke-others", post(auth::revoke_other_sessions))
|
||||
.route("/sessions/{id}", delete(auth::revoke_active_session))
|
||||
@@ -532,13 +578,27 @@ pub fn create_router_with_storage(config: Arc<AppConfig>, services: RouterServic
|
||||
|
||||
let ws_route = Router::new().route("/ws", get(crate::realtime::ws::ws_handler));
|
||||
|
||||
let api_router = Router::new()
|
||||
let mut api_router = Router::new()
|
||||
.route("/health", get(health::health_check))
|
||||
.route("/ready", get(health::readiness_check))
|
||||
.nest("/auth", auth_router)
|
||||
.merge(ws_route)
|
||||
.merge(protected_data)
|
||||
.fallback(|| async { axum::http::StatusCode::NOT_FOUND });
|
||||
.merge(protected_data);
|
||||
|
||||
if config.diagnostics_endpoint.enabled {
|
||||
let diagnostics_path = config
|
||||
.diagnostics_endpoint
|
||||
.path
|
||||
.trim()
|
||||
.strip_prefix("/api")
|
||||
.unwrap_or(config.diagnostics_endpoint.path.trim());
|
||||
api_router = api_router.route(
|
||||
diagnostics_path,
|
||||
get(system_diagnostics::system_diagnostics),
|
||||
);
|
||||
}
|
||||
|
||||
let api_router = api_router.fallback(|| async { axum::http::StatusCode::NOT_FOUND });
|
||||
|
||||
let inner = Router::new()
|
||||
.nest("/api", api_router)
|
||||
@@ -555,6 +615,24 @@ pub fn create_router_with_storage(config: Arc<AppConfig>, services: RouterServic
|
||||
}
|
||||
};
|
||||
|
||||
let router = if config.diagnostics_endpoint.enabled {
|
||||
let system_diagnostics_dependencies =
|
||||
Arc::new(system_diagnostics::SystemDiagnosticsDependencies::new(
|
||||
config.clone(),
|
||||
store.clone(),
|
||||
search_engine.clone(),
|
||||
storage.clone(),
|
||||
idle_manager.clone(),
|
||||
system_diagnostics_telemetry.clone(),
|
||||
));
|
||||
router
|
||||
.layer(middleware::from_fn(system_diagnostics::observe_requests))
|
||||
.layer(Extension(system_diagnostics_dependencies))
|
||||
.layer(Extension(system_diagnostics_telemetry))
|
||||
} else {
|
||||
router
|
||||
};
|
||||
|
||||
let router = router
|
||||
.layer(Extension(idle_manager))
|
||||
.layer(Extension(event_bus))
|
||||
|
||||
@@ -378,10 +378,6 @@ pub async fn export_key(
|
||||
HeaderValue::from_static("application/pgp-keys; charset=utf-8"),
|
||||
);
|
||||
headers.insert("cache-control", HeaderValue::from_static("no-store"));
|
||||
headers.insert(
|
||||
"x-content-type-options",
|
||||
HeaderValue::from_static("nosniff"),
|
||||
);
|
||||
headers.insert(
|
||||
"content-disposition",
|
||||
HeaderValue::from_str(&format!("attachment; filename=\"{filename}\""))
|
||||
|
||||
@@ -0,0 +1,896 @@
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::extract::ConnectInfo;
|
||||
use axum::http::{HeaderMap, HeaderValue, Request, StatusCode, header};
|
||||
use axum::middleware;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::{Extension, Json};
|
||||
use base64::Engine as _;
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
use serde::Serialize;
|
||||
use serde_json::{Value, json};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::auth::session::SessionStore;
|
||||
use crate::config::{AppConfig, TrustedProxy};
|
||||
use crate::db;
|
||||
use crate::realtime::idle::IdleManager;
|
||||
use crate::search::engine::SearchEngine;
|
||||
use crate::storage::BlobStorage;
|
||||
|
||||
const MAX_SECURITY_EVENTS: usize = 4096;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
enum SecurityEventKind {
|
||||
LoginRejected,
|
||||
RateLimited,
|
||||
Forbidden,
|
||||
ServerError,
|
||||
DiagnosticsDenied,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct SecurityEvent {
|
||||
at_unix: u64,
|
||||
kind: SecurityEventKind,
|
||||
source_ip: IpAddr,
|
||||
path: String,
|
||||
}
|
||||
|
||||
pub struct SystemDiagnosticsTelemetry {
|
||||
started_at_unix: u64,
|
||||
started: Instant,
|
||||
total_requests: AtomicU64,
|
||||
client_errors: AtomicU64,
|
||||
server_errors: AtomicU64,
|
||||
login_attempts: AtomicU64,
|
||||
events: Mutex<VecDeque<SecurityEvent>>,
|
||||
}
|
||||
|
||||
pub struct SystemDiagnosticsDependencies {
|
||||
config: Arc<AppConfig>,
|
||||
store: Arc<SessionStore>,
|
||||
search: Arc<SearchEngine>,
|
||||
storage: Arc<dyn BlobStorage>,
|
||||
idle_manager: Arc<IdleManager>,
|
||||
telemetry: Arc<SystemDiagnosticsTelemetry>,
|
||||
}
|
||||
|
||||
impl SystemDiagnosticsDependencies {
|
||||
pub fn new(
|
||||
config: Arc<AppConfig>,
|
||||
store: Arc<SessionStore>,
|
||||
search: Arc<SearchEngine>,
|
||||
storage: Arc<dyn BlobStorage>,
|
||||
idle_manager: Arc<IdleManager>,
|
||||
telemetry: Arc<SystemDiagnosticsTelemetry>,
|
||||
) -> Self {
|
||||
Self {
|
||||
config,
|
||||
store,
|
||||
search,
|
||||
storage,
|
||||
idle_manager,
|
||||
telemetry,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SystemDiagnosticsTelemetry {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
started_at_unix: now_unix(),
|
||||
started: Instant::now(),
|
||||
total_requests: AtomicU64::new(0),
|
||||
client_errors: AtomicU64::new(0),
|
||||
server_errors: AtomicU64::new(0),
|
||||
login_attempts: AtomicU64::new(0),
|
||||
events: Mutex::new(VecDeque::with_capacity(MAX_SECURITY_EVENTS)),
|
||||
}
|
||||
}
|
||||
|
||||
fn record_event(&self, kind: SecurityEventKind, source_ip: IpAddr, path: &str) {
|
||||
let mut events = self
|
||||
.events
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
if events.len() >= MAX_SECURITY_EVENTS {
|
||||
events.pop_front();
|
||||
}
|
||||
events.push_back(SecurityEvent {
|
||||
at_unix: now_unix(),
|
||||
kind,
|
||||
source_ip,
|
||||
path: path.chars().take(256).collect(),
|
||||
});
|
||||
}
|
||||
|
||||
fn security_snapshot(&self, window_secs: u64) -> Value {
|
||||
let now = now_unix();
|
||||
let cutoff = now.saturating_sub(window_secs);
|
||||
let events = self
|
||||
.events
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
let mut by_kind: HashMap<SecurityEventKind, u64> = HashMap::new();
|
||||
let mut by_ip: HashMap<IpAddr, u64> = HashMap::new();
|
||||
let mut recent = Vec::new();
|
||||
for event in events.iter().filter(|event| event.at_unix >= cutoff) {
|
||||
*by_kind.entry(event.kind).or_default() += 1;
|
||||
*by_ip.entry(event.source_ip).or_default() += 1;
|
||||
}
|
||||
for event in events
|
||||
.iter()
|
||||
.rev()
|
||||
.filter(|event| event.at_unix >= cutoff)
|
||||
.take(50)
|
||||
{
|
||||
recent.push(json!({
|
||||
"timestampUnix": event.at_unix,
|
||||
"kind": event.kind,
|
||||
"sourceIp": event.source_ip.to_string(),
|
||||
"path": event.path,
|
||||
}));
|
||||
}
|
||||
let mut top_sources: Vec<(IpAddr, u64)> = by_ip.into_iter().collect();
|
||||
top_sources.sort_by_key(|source| std::cmp::Reverse(source.1));
|
||||
top_sources.truncate(20);
|
||||
|
||||
let login_rejected = *by_kind.get(&SecurityEventKind::LoginRejected).unwrap_or(&0);
|
||||
let rate_limited = *by_kind.get(&SecurityEventKind::RateLimited).unwrap_or(&0);
|
||||
let forbidden = *by_kind.get(&SecurityEventKind::Forbidden).unwrap_or(&0);
|
||||
let server_errors = *by_kind.get(&SecurityEventKind::ServerError).unwrap_or(&0);
|
||||
let diagnostics_denied = *by_kind
|
||||
.get(&SecurityEventKind::DiagnosticsDenied)
|
||||
.unwrap_or(&0);
|
||||
|
||||
let brute_force_signal = login_rejected >= 10 || rate_limited >= 3;
|
||||
let diagnostics_attack_signal = diagnostics_denied >= 5;
|
||||
json!({
|
||||
"windowSeconds": window_secs,
|
||||
"loginRejected": login_rejected,
|
||||
"rateLimited": rate_limited,
|
||||
"forbidden": forbidden,
|
||||
"serverErrors": server_errors,
|
||||
"diagnosticsDenied": diagnostics_denied,
|
||||
"bruteForceSignal": brute_force_signal,
|
||||
"diagnosticsAttackSignal": diagnostics_attack_signal,
|
||||
"securityAlert": brute_force_signal || diagnostics_attack_signal,
|
||||
"topSources": top_sources.into_iter().map(|(ip, count)| json!({
|
||||
"ip": ip.to_string(), "events": count
|
||||
})).collect::<Vec<_>>(),
|
||||
"recentEvents": recent,
|
||||
"bufferCapacity": MAX_SECURITY_EVENTS,
|
||||
"bufferUsed": events.len(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SystemDiagnosticsTelemetry {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
fn now_unix() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs()
|
||||
}
|
||||
|
||||
fn trusted_contains(trusted: &[TrustedProxy], ip: &IpAddr) -> bool {
|
||||
trusted.iter().any(|entry| entry.contains(ip))
|
||||
}
|
||||
|
||||
fn effective_client_ip(config: &AppConfig, headers: &HeaderMap, peer_ip: IpAddr) -> IpAddr {
|
||||
let trusted = config.parsed_trusted_proxies();
|
||||
if !trusted_contains(&trusted, &peer_ip) {
|
||||
return peer_ip;
|
||||
}
|
||||
|
||||
let mut current = peer_ip;
|
||||
for value in headers.get_all("x-forwarded-for").iter().rev() {
|
||||
let Ok(value) = value.to_str() else {
|
||||
return current;
|
||||
};
|
||||
for entry in value.split(',').rev() {
|
||||
if !trusted_contains(&trusted, ¤t) {
|
||||
return current;
|
||||
}
|
||||
let Ok(parsed) = entry.trim().parse::<IpAddr>() else {
|
||||
return current;
|
||||
};
|
||||
current = parsed;
|
||||
}
|
||||
}
|
||||
current
|
||||
}
|
||||
|
||||
fn diagnostics_client_ip(
|
||||
config: &AppConfig,
|
||||
headers: &HeaderMap,
|
||||
peer_ip: IpAddr,
|
||||
) -> Result<IpAddr, &'static str> {
|
||||
let has_xff = headers.contains_key("x-forwarded-for");
|
||||
let has_other_forwarding =
|
||||
headers.contains_key("forwarded") || headers.contains_key("x-real-ip");
|
||||
if !has_xff && !has_other_forwarding {
|
||||
return Ok(peer_ip);
|
||||
}
|
||||
|
||||
let trusted = config.parsed_trusted_proxies();
|
||||
if !trusted_contains(&trusted, &peer_ip) {
|
||||
return Err("proxy headers received from an untrusted peer");
|
||||
}
|
||||
if !has_xff {
|
||||
return Err("diagnostics requires X-Forwarded-For when accessed through a trusted proxy");
|
||||
}
|
||||
|
||||
let mut current = peer_ip;
|
||||
let mut saw_forwarded_address = false;
|
||||
for value in headers.get_all("x-forwarded-for").iter().rev() {
|
||||
let value = value
|
||||
.to_str()
|
||||
.map_err(|_| "invalid X-Forwarded-For header encoding")?;
|
||||
for entry in value.split(',').rev() {
|
||||
if !trusted_contains(&trusted, ¤t) {
|
||||
return Ok(current);
|
||||
}
|
||||
current = entry
|
||||
.trim()
|
||||
.parse::<IpAddr>()
|
||||
.map_err(|_| "invalid X-Forwarded-For address")?;
|
||||
saw_forwarded_address = true;
|
||||
}
|
||||
}
|
||||
if !saw_forwarded_address {
|
||||
return Err("empty X-Forwarded-For header");
|
||||
}
|
||||
Ok(current)
|
||||
}
|
||||
|
||||
pub async fn observe_requests(request: Request<Body>, next: middleware::Next) -> Response {
|
||||
let telemetry = request
|
||||
.extensions()
|
||||
.get::<Arc<SystemDiagnosticsTelemetry>>()
|
||||
.cloned();
|
||||
let config = request.extensions().get::<Arc<AppConfig>>().cloned();
|
||||
let path = request.uri().path().to_string();
|
||||
let peer_ip = request
|
||||
.extensions()
|
||||
.get::<ConnectInfo<SocketAddr>>()
|
||||
.map(|value| value.0.ip())
|
||||
.unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST));
|
||||
let source_ip = config
|
||||
.as_deref()
|
||||
.map(|cfg| effective_client_ip(cfg, request.headers(), peer_ip))
|
||||
.unwrap_or(peer_ip);
|
||||
let is_login = path.ends_with("/api/auth/login");
|
||||
|
||||
if let Some(telemetry) = telemetry.as_ref() {
|
||||
telemetry.total_requests.fetch_add(1, Ordering::Relaxed);
|
||||
if is_login {
|
||||
telemetry.login_attempts.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
let response = next.run(request).await;
|
||||
if let Some(telemetry) = telemetry.as_ref() {
|
||||
let status = response.status();
|
||||
if status.is_client_error() {
|
||||
telemetry.client_errors.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
if status.is_server_error() {
|
||||
telemetry.server_errors.fetch_add(1, Ordering::Relaxed);
|
||||
telemetry.record_event(SecurityEventKind::ServerError, source_ip, &path);
|
||||
}
|
||||
if status == StatusCode::TOO_MANY_REQUESTS {
|
||||
telemetry.record_event(SecurityEventKind::RateLimited, source_ip, &path);
|
||||
tracing::warn!(source_ip = %source_ip, path = %path, "request rate limited");
|
||||
}
|
||||
if status == StatusCode::FORBIDDEN {
|
||||
telemetry.record_event(SecurityEventKind::Forbidden, source_ip, &path);
|
||||
}
|
||||
if is_login && matches!(status, StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN) {
|
||||
telemetry.record_event(SecurityEventKind::LoginRejected, source_ip, &path);
|
||||
tracing::warn!(source_ip = %source_ip, status = %status, "login rejected");
|
||||
}
|
||||
}
|
||||
response
|
||||
}
|
||||
|
||||
fn constant_time_secret_eq(expected: &str, supplied: &str) -> bool {
|
||||
let expected = Sha256::digest(expected.as_bytes());
|
||||
let supplied = Sha256::digest(supplied.as_bytes());
|
||||
expected
|
||||
.iter()
|
||||
.zip(supplied.iter())
|
||||
.fold(0u8, |diff, (left, right)| diff | (left ^ right))
|
||||
== 0
|
||||
}
|
||||
|
||||
fn bearer_token(headers: &HeaderMap) -> Option<&str> {
|
||||
let authorization = headers
|
||||
.get(header::AUTHORIZATION)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(|value| {
|
||||
let mut parts = value.split_whitespace();
|
||||
let scheme = parts.next()?;
|
||||
let credentials = parts.next()?;
|
||||
if parts.next().is_some() {
|
||||
return None;
|
||||
}
|
||||
scheme.eq_ignore_ascii_case("bearer").then_some(credentials)
|
||||
});
|
||||
authorization.or_else(|| {
|
||||
headers
|
||||
.get("x-rustbox-diagnostics-token")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(str::trim)
|
||||
})
|
||||
}
|
||||
|
||||
fn basic_credentials(headers: &HeaderMap) -> Option<(String, String)> {
|
||||
let raw = headers.get(header::AUTHORIZATION)?.to_str().ok()?;
|
||||
let mut parts = raw.split_whitespace();
|
||||
let scheme = parts.next()?;
|
||||
let encoded = parts.next()?;
|
||||
if parts.next().is_some() || !scheme.eq_ignore_ascii_case("basic") {
|
||||
return None;
|
||||
}
|
||||
let decoded = STANDARD.decode(encoded).ok()?;
|
||||
let decoded = String::from_utf8(decoded).ok()?;
|
||||
let (username, password) = decoded.split_once(':')?;
|
||||
Some((username.to_string(), password.to_string()))
|
||||
}
|
||||
|
||||
fn harden_diagnostics_response(mut response: Response) -> Response {
|
||||
let headers = response.headers_mut();
|
||||
headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
|
||||
headers.insert("pragma", HeaderValue::from_static("no-cache"));
|
||||
headers.insert(
|
||||
"x-content-type-options",
|
||||
HeaderValue::from_static("nosniff"),
|
||||
);
|
||||
headers.insert(
|
||||
"cross-origin-resource-policy",
|
||||
HeaderValue::from_static("same-origin"),
|
||||
);
|
||||
response
|
||||
}
|
||||
|
||||
fn unauthorized(auth_mode: &str) -> Response {
|
||||
let challenge = if auth_mode.eq_ignore_ascii_case("basic") {
|
||||
"Basic realm=\"RustBox diagnostics\", charset=\"UTF-8\""
|
||||
} else {
|
||||
"Bearer realm=\"RustBox diagnostics\""
|
||||
};
|
||||
let mut response = (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({"error": "diagnostics authentication required"})),
|
||||
)
|
||||
.into_response();
|
||||
response.headers_mut().insert(
|
||||
header::WWW_AUTHENTICATE,
|
||||
HeaderValue::from_str(challenge).unwrap_or_else(|_| HeaderValue::from_static("Bearer")),
|
||||
);
|
||||
harden_diagnostics_response(response)
|
||||
}
|
||||
|
||||
fn forbidden() -> Response {
|
||||
harden_diagnostics_response(
|
||||
(
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(json!({"error": "diagnostics endpoint is not available from this network"})),
|
||||
)
|
||||
.into_response(),
|
||||
)
|
||||
}
|
||||
|
||||
fn sanitize_url(raw: Option<&str>) -> Option<String> {
|
||||
let raw = raw?.trim();
|
||||
if raw.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if let Ok(url) = url::Url::parse(raw) {
|
||||
if url.scheme() == "sqlite" {
|
||||
return Some(format!("sqlite:{}", url.path()));
|
||||
}
|
||||
let host = url.host_str().unwrap_or("<unknown-host>");
|
||||
let port = url
|
||||
.port()
|
||||
.map(|value| format!(":{value}"))
|
||||
.unwrap_or_default();
|
||||
let path = if url.path() == "/" { "" } else { url.path() };
|
||||
return Some(format!("{}://{}{}{}", url.scheme(), host, port, path));
|
||||
}
|
||||
Some("<configured; redacted>".to_string())
|
||||
}
|
||||
|
||||
fn process_snapshot() -> Value {
|
||||
// Keep this intentionally limited to portable, process-local information.
|
||||
// Rust std does not expose cross-platform RSS/thread/FD metrics, and the
|
||||
// diagnostics endpoint must not depend on host-specific /proc probes.
|
||||
json!({
|
||||
"pid": std::process::id(),
|
||||
})
|
||||
}
|
||||
|
||||
fn sanitized_config(config: &AppConfig) -> Value {
|
||||
json!({
|
||||
"environment": config.environment,
|
||||
"pathsAndLogging": {
|
||||
"dataDir": config.data_dir,
|
||||
"logDir": config.log_dir,
|
||||
"logLevel": config.log_level,
|
||||
},
|
||||
"ui": {
|
||||
"defaultTheme": config.default_theme,
|
||||
"defaultInterfaceSkin": config.default_interface_skin,
|
||||
},
|
||||
"listen": {"host": config.host, "port": config.port, "basePath": config.base_path},
|
||||
"mail": {
|
||||
"imapHost": config.imap_host,
|
||||
"imapPort": config.imap_port,
|
||||
"smtpHost": config.smtp_host,
|
||||
"smtpPort": config.smtp_port,
|
||||
"tlsEnabled": config.tls_enabled,
|
||||
"tlsVerifyOverride": config.tls_verify,
|
||||
"sieveEnabled": config.sieve_enabled,
|
||||
"sieveHost": config.sieve_host,
|
||||
"sievePort": config.sieve_port,
|
||||
"sieveTlsMode": config.sieve_tls_mode,
|
||||
"sieveAllowInsecureAuth": config.sieve_allow_insecure_auth,
|
||||
"sieveTimeoutSecs": config.sieve_timeout_secs,
|
||||
},
|
||||
"limits": {
|
||||
"maxAttachmentSizeMiB": config.max_attachment_size_mb,
|
||||
"maxMessageSizeMiB": config.max_message_size_mb,
|
||||
"sessionTimeoutHours": config.session_timeout_hours,
|
||||
"rememberMeEnabled": config.remember_me_enabled,
|
||||
"rememberMeDays": config.remember_me_days,
|
||||
},
|
||||
"database": {
|
||||
"backend": config.database_backend,
|
||||
"url": sanitize_url(config.database_url.as_deref()),
|
||||
},
|
||||
"sessions": {
|
||||
"backend": config.session_backend,
|
||||
"redisUrl": sanitize_url(config.redis_url.as_deref()),
|
||||
"redisSentinelNodes": config.redis_sentinel_nodes.iter().map(|value| sanitize_url(Some(value)).unwrap_or_else(|| value.clone())).collect::<Vec<_>>(),
|
||||
"redisSentinelMaster": config.redis_sentinel_master,
|
||||
"redisPasswordConfigured": config.redis_password.as_deref().is_some_and(|value| !value.is_empty()),
|
||||
"redisSentinelPasswordConfigured": config.redis_sentinel_password.as_deref().is_some_and(|value| !value.is_empty()),
|
||||
"sessionEncryptionKeyConfigured": config.session_encryption_key.as_deref().is_some_and(|value| !value.is_empty()),
|
||||
},
|
||||
"clusterMode": config.cluster_mode,
|
||||
"storage": {
|
||||
"backend": config.storage.backend,
|
||||
"path": config.storage.path,
|
||||
"shared": config.storage.shared,
|
||||
"bucket": config.storage.bucket,
|
||||
"region": config.storage.region,
|
||||
"endpoint": sanitize_url(config.storage.endpoint.as_deref()),
|
||||
"prefix": config.storage.prefix,
|
||||
"pathStyle": config.storage.path_style,
|
||||
"allowHttp": config.storage.allow_http,
|
||||
"accessKeyConfigured": config.storage.access_key_id.as_deref().is_some_and(|value| !value.is_empty()),
|
||||
"secretKeyConfigured": config.storage.secret_access_key.as_deref().is_some_and(|value| !value.is_empty()),
|
||||
"sessionTokenConfigured": config.storage.session_token.as_deref().is_some_and(|value| !value.is_empty()),
|
||||
"requestTimeoutSecs": config.storage.request_timeout_secs,
|
||||
"connectTimeoutSecs": config.storage.connect_timeout_secs,
|
||||
"maxRetries": config.storage.max_retries,
|
||||
"retryTimeoutSecs": config.storage.retry_timeout_secs,
|
||||
"serverSideEncryption": config.storage.server_side_encryption,
|
||||
"kmsKeyConfigured": config.storage.kms_key_id.as_deref().is_some_and(|value| !value.is_empty()),
|
||||
},
|
||||
"search": {
|
||||
"backend": config.search.backend,
|
||||
"path": config.search.path,
|
||||
"urls": config.search.urls.iter().map(|value| sanitize_url(Some(value)).unwrap_or_else(|| "<invalid url>".to_string())).collect::<Vec<_>>(),
|
||||
"indexPrefix": config.search.index_prefix,
|
||||
"usernameConfigured": config.search.username.as_deref().is_some_and(|value| !value.is_empty()),
|
||||
"passwordConfigured": config.search.password.as_deref().is_some_and(|value| !value.is_empty()),
|
||||
"required": config.search.required,
|
||||
"allowInsecureHttp": config.search.allow_insecure_http,
|
||||
"requestTimeoutSecs": config.search.request_timeout_secs,
|
||||
"deepIndex": config.search.deep_index,
|
||||
"workerIntervalSecs": config.search.worker_interval_secs,
|
||||
"workerBatchSize": config.search.worker_batch_size,
|
||||
"workerMaxAttempts": config.search.worker_max_attempts,
|
||||
},
|
||||
"sync": {
|
||||
"intervalSecs": config.sync.interval_secs,
|
||||
"deepIndexBatchSize": config.sync.deep_index_batch_size,
|
||||
},
|
||||
"features": {
|
||||
"userDiagnostics": config.diagnostics_enabled,
|
||||
"about": config.about_enabled,
|
||||
"pgp": config.pgp_enabled,
|
||||
"securityHeaders": config.security_headers_enabled,
|
||||
},
|
||||
"pgp": {
|
||||
"enabled": config.pgp_enabled,
|
||||
"wkdEnabled": config.pgp_wkd_enabled,
|
||||
"hkpEnabled": config.pgp_hkp_enabled,
|
||||
"hkpUrl": sanitize_url(Some(&config.pgp_hkp_url)),
|
||||
"networkTimeoutSecs": config.pgp_network_timeout_secs,
|
||||
"maxKeyBytes": config.pgp_max_key_bytes,
|
||||
"allowPrivateExport": config.pgp_allow_private_export,
|
||||
},
|
||||
"specialFolders": {
|
||||
"sent": config.default_sent_folder,
|
||||
"drafts": config.default_drafts_folder,
|
||||
"trash": config.default_trash_folder,
|
||||
"junk": config.default_junk_folder,
|
||||
"archive": config.default_archive_folder,
|
||||
},
|
||||
"outgoingHeaders": {
|
||||
"count": config.outgoing_headers.len(),
|
||||
"names": config.outgoing_headers.iter().filter_map(|entry| entry.split_once(':').map(|(name, _)| name.trim().to_string())).collect::<Vec<_>>(),
|
||||
},
|
||||
"trustedProxies": config.trusted_proxies,
|
||||
"diagnosticsEndpoint": {
|
||||
"enabled": config.diagnostics_endpoint.enabled,
|
||||
"path": config.diagnostics_endpoint.path,
|
||||
"allowedNetworks": config.diagnostics_endpoint.allowed_networks,
|
||||
"auth": config.diagnostics_endpoint.auth,
|
||||
"credentialConfigured": if config.diagnostics_endpoint.auth.eq_ignore_ascii_case("basic") {
|
||||
config.diagnostics_endpoint.basic_username.as_deref().is_some_and(|value| !value.is_empty())
|
||||
&& config.diagnostics_endpoint.basic_password.as_deref().is_some_and(|value| !value.is_empty())
|
||||
} else {
|
||||
config.diagnostics_endpoint.token.as_deref().is_some_and(|value| !value.is_empty())
|
||||
},
|
||||
"includeUserIdentifiers": config.diagnostics_endpoint.include_user_identifiers,
|
||||
"probeTimeoutSecs": config.diagnostics_endpoint.probe_timeout_secs,
|
||||
"securityWindowSecs": config.diagnostics_endpoint.security_window_secs,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn configuration_checks(config: &AppConfig) -> Vec<Value> {
|
||||
let mut checks = Vec::new();
|
||||
if config.environment.eq_ignore_ascii_case("production") && !config.security_headers_enabled {
|
||||
checks.push(json!({"severity":"warning","code":"security_headers_disabled","message":"Browser security headers are disabled in production."}));
|
||||
}
|
||||
if config.search.allow_insecure_http {
|
||||
checks.push(json!({"severity":"warning","code":"opensearch_http_allowed","message":"OpenSearch permits plain HTTP."}));
|
||||
}
|
||||
if config.storage.allow_http {
|
||||
checks.push(json!({"severity":"warning","code":"storage_http_allowed","message":"S3-compatible storage permits plain HTTP."}));
|
||||
}
|
||||
if config
|
||||
.diagnostics_endpoint
|
||||
.allowed_networks
|
||||
.iter()
|
||||
.any(|network| matches!(network.trim(), "0.0.0.0/0" | "::/0"))
|
||||
{
|
||||
checks.push(json!({"severity":"warning","code":"diagnostics_allowlist_global","message":"Diagnostics allowlist contains a global network; restrict it at the application and firewall/reverse-proxy layers."}));
|
||||
}
|
||||
if config
|
||||
.diagnostics_endpoint
|
||||
.auth
|
||||
.eq_ignore_ascii_case("basic")
|
||||
{
|
||||
checks.push(json!({"severity":"info","code":"diagnostics_basic_auth","message":"Diagnostics uses Basic Auth; transport must be protected by HTTPS/TLS at RustBox or its trusted reverse proxy."}));
|
||||
}
|
||||
if config.diagnostics_endpoint.include_user_identifiers {
|
||||
checks.push(json!({"severity":"info","code":"diagnostics_user_identifiers","message":"Diagnostics may expose authenticated user e-mail addresses and stable user hashes to authorized operators."}));
|
||||
}
|
||||
if config.search.deep_index {
|
||||
checks.push(json!({"severity":"info","code":"deep_index_enabled","message":"Server-wide deep index is enabled; full message bodies may be fetched and stored/indexed automatically."}));
|
||||
}
|
||||
if config.tls_verify == Some(false) {
|
||||
checks.push(json!({"severity":"warning","code":"tls_verification_disabled","message":"TLS certificate verification is globally disabled for configured mail connections."}));
|
||||
}
|
||||
checks
|
||||
}
|
||||
|
||||
async fn timed_probe<F, T>(timeout: Duration, future: F) -> (Result<T, String>, u128)
|
||||
where
|
||||
F: std::future::Future<Output = Result<T, String>>,
|
||||
{
|
||||
let started = Instant::now();
|
||||
let result = match tokio::time::timeout(timeout, future).await {
|
||||
Ok(result) => result,
|
||||
Err(_) => Err(format!("probe timed out after {} ms", timeout.as_millis())),
|
||||
};
|
||||
(result, started.elapsed().as_millis())
|
||||
}
|
||||
|
||||
pub async fn system_diagnostics(
|
||||
ConnectInfo(peer): ConnectInfo<SocketAddr>,
|
||||
headers: HeaderMap,
|
||||
Extension(dependencies): Extension<Arc<SystemDiagnosticsDependencies>>,
|
||||
) -> Response {
|
||||
let config = dependencies.config.as_ref();
|
||||
let store = dependencies.store.as_ref();
|
||||
let search = dependencies.search.as_ref();
|
||||
let storage = dependencies.storage.as_ref();
|
||||
let idle_manager = dependencies.idle_manager.as_ref();
|
||||
let telemetry = dependencies.telemetry.as_ref();
|
||||
let source_ip = match diagnostics_client_ip(config, &headers, peer.ip()) {
|
||||
Ok(source_ip) => source_ip,
|
||||
Err(_) => {
|
||||
telemetry.record_event(
|
||||
SecurityEventKind::DiagnosticsDenied,
|
||||
peer.ip(),
|
||||
&config.diagnostics_endpoint.path,
|
||||
);
|
||||
tracing::warn!(peer_ip = %peer.ip(), "administrator diagnostics proxy/source validation denied");
|
||||
return forbidden();
|
||||
}
|
||||
};
|
||||
let allowed = config
|
||||
.parsed_diagnostics_allowed_networks()
|
||||
.map(|networks| networks.iter().any(|network| network.contains(&source_ip)))
|
||||
.unwrap_or(false);
|
||||
if !allowed {
|
||||
telemetry.record_event(
|
||||
SecurityEventKind::DiagnosticsDenied,
|
||||
source_ip,
|
||||
&config.diagnostics_endpoint.path,
|
||||
);
|
||||
tracing::warn!(source_ip = %source_ip, "administrator diagnostics denied by network allowlist");
|
||||
return forbidden();
|
||||
}
|
||||
|
||||
let authenticated = if config
|
||||
.diagnostics_endpoint
|
||||
.auth
|
||||
.eq_ignore_ascii_case("basic")
|
||||
{
|
||||
match (
|
||||
config.diagnostics_endpoint.basic_username.as_deref(),
|
||||
config.diagnostics_endpoint.basic_password.as_deref(),
|
||||
basic_credentials(&headers),
|
||||
) {
|
||||
(Some(expected_user), Some(expected_password), Some((user, password))) => {
|
||||
constant_time_secret_eq(expected_user, &user)
|
||||
&& constant_time_secret_eq(expected_password, &password)
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
} else {
|
||||
match (
|
||||
config.diagnostics_endpoint.token.as_deref(),
|
||||
bearer_token(&headers),
|
||||
) {
|
||||
(Some(expected), Some(supplied)) => constant_time_secret_eq(expected, supplied),
|
||||
_ => false,
|
||||
}
|
||||
};
|
||||
if !authenticated {
|
||||
telemetry.record_event(
|
||||
SecurityEventKind::DiagnosticsDenied,
|
||||
source_ip,
|
||||
&config.diagnostics_endpoint.path,
|
||||
);
|
||||
tracing::warn!(source_ip = %source_ip, auth_mode = %config.diagnostics_endpoint.auth, "administrator diagnostics authentication rejected");
|
||||
return unauthorized(&config.diagnostics_endpoint.auth);
|
||||
}
|
||||
|
||||
let timeout = Duration::from_secs(config.diagnostics_endpoint.probe_timeout_secs);
|
||||
let db_future = async { Ok::<_, String>(db::pool::diagnostics().await) };
|
||||
let storage_future = async { storage.health().await };
|
||||
let search_future = async { search.diagnostics().await };
|
||||
let sessions_future = store.persistence_diagnostics();
|
||||
let (db_probe, storage_probe, search_probe, session_probe) = tokio::join!(
|
||||
timed_probe(timeout, db_future),
|
||||
timed_probe(timeout, storage_future),
|
||||
timed_probe(timeout, search_future),
|
||||
timed_probe(timeout, sessions_future),
|
||||
);
|
||||
|
||||
let db_diag = db_probe
|
||||
.0
|
||||
.unwrap_or_else(|error| db::pool::DatabaseDiagnostics {
|
||||
backend: config.database_backend.clone(),
|
||||
status: "error".to_string(),
|
||||
latency_ms: db_probe.1,
|
||||
pool_size: None,
|
||||
idle_connections: None,
|
||||
database_bytes: None,
|
||||
engine_version: None,
|
||||
account_count: None,
|
||||
schema_version_min: None,
|
||||
schema_version_max: None,
|
||||
schema_aligned: None,
|
||||
expected_schema_version: db::pool::latest_schema_version(),
|
||||
error: Some(error),
|
||||
});
|
||||
let storage_status = match storage_probe.0 {
|
||||
Ok(()) => json!({"status":"ok","latencyMs":storage_probe.1}),
|
||||
Err(error) => json!({"status":"error","latencyMs":storage_probe.1,"error":error}),
|
||||
};
|
||||
let search_status = match search_probe.0 {
|
||||
Ok(details) => {
|
||||
let detail_status = details
|
||||
.get("status")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("ok");
|
||||
let status = if detail_status == "degraded" {
|
||||
"degraded"
|
||||
} else {
|
||||
"ok"
|
||||
};
|
||||
json!({"status":status,"latencyMs":search_probe.1,"backend":search.backend_name(),"required":config.search.required,"details":details})
|
||||
}
|
||||
Err(error) => {
|
||||
json!({"status": if config.search.required {"error"} else {"degraded"},"latencyMs":search_probe.1,"backend":search.backend_name(),"required":config.search.required,"error":error})
|
||||
}
|
||||
};
|
||||
let session_status = match session_probe.0 {
|
||||
Ok(value) => json!({"status":"ok","latencyMs":session_probe.1,"persistence":value}),
|
||||
Err(error) => {
|
||||
json!({"status":"error","latencyMs":session_probe.1,"backend":config.session_backend,"error":error})
|
||||
}
|
||||
};
|
||||
|
||||
let local_sessions =
|
||||
store.diagnostics_snapshot(config.diagnostics_endpoint.include_user_identifiers);
|
||||
let security = telemetry.security_snapshot(config.diagnostics_endpoint.security_window_secs);
|
||||
let security_alert = security
|
||||
.get("securityAlert")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false);
|
||||
let config_checks = configuration_checks(config);
|
||||
let critical = db_diag.status != "ok"
|
||||
|| storage_status.get("status").and_then(Value::as_str) != Some("ok")
|
||||
|| session_status.get("status").and_then(Value::as_str) != Some("ok")
|
||||
|| (config.search.required
|
||||
&& search_status.get("status").and_then(Value::as_str) != Some("ok"));
|
||||
let degraded = !critical
|
||||
&& (security_alert
|
||||
|| db_diag.schema_aligned == Some(false)
|
||||
|| search_status.get("status").and_then(Value::as_str) == Some("degraded")
|
||||
|| config_checks
|
||||
.iter()
|
||||
.any(|check| check.get("severity").and_then(Value::as_str) == Some("warning")));
|
||||
|
||||
let response = json!({
|
||||
"status": if critical { "error" } else if degraded { "degraded" } else { "ok" },
|
||||
"timestampUnix": now_unix(),
|
||||
"request": {"sourceIp": source_ip.to_string()},
|
||||
"application": {
|
||||
"name": "RustBox",
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
"uptimeSeconds": telemetry.started.elapsed().as_secs(),
|
||||
"startedAtUnix": telemetry.started_at_unix,
|
||||
"process": process_snapshot(),
|
||||
"clusterMode": config.cluster_mode,
|
||||
},
|
||||
"runtimeCounters": {
|
||||
"totalRequests": telemetry.total_requests.load(Ordering::Relaxed),
|
||||
"clientErrors": telemetry.client_errors.load(Ordering::Relaxed),
|
||||
"serverErrors": telemetry.server_errors.load(Ordering::Relaxed),
|
||||
"loginAttempts": telemetry.login_attempts.load(Ordering::Relaxed),
|
||||
"idleTasks": idle_manager.task_count(),
|
||||
"idleSubscribers": idle_manager.subscriber_count(),
|
||||
"idleConnections": idle_manager.diagnostics_snapshot(config.diagnostics_endpoint.include_user_identifiers),
|
||||
},
|
||||
"configuration": sanitized_config(config),
|
||||
"checks": config_checks,
|
||||
"database": db_diag,
|
||||
"storage": {
|
||||
"backend": config.effective_storage_backend(),
|
||||
"health": storage_status,
|
||||
},
|
||||
"search": search_status,
|
||||
"sessions": {
|
||||
"backend": config.session_backend,
|
||||
"health": session_status,
|
||||
"localNode": local_sessions,
|
||||
},
|
||||
"security": security,
|
||||
});
|
||||
|
||||
harden_diagnostics_response(
|
||||
(
|
||||
if critical {
|
||||
StatusCode::SERVICE_UNAVAILABLE
|
||||
} else {
|
||||
StatusCode::OK
|
||||
},
|
||||
Json(response),
|
||||
)
|
||||
.into_response(),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn diagnostics_secret_compare_matches_only_exact_value() {
|
||||
assert!(constant_time_secret_eq(
|
||||
"abcdefghijklmnopqrstuvwx",
|
||||
"abcdefghijklmnopqrstuvwx"
|
||||
));
|
||||
assert!(!constant_time_secret_eq(
|
||||
"abcdefghijklmnopqrstuvwx",
|
||||
"abcdefghijklmnopqrstuvwy"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diagnostics_url_redaction_removes_credentials_and_query() {
|
||||
assert_eq!(
|
||||
sanitize_url(Some(
|
||||
"postgres://user:secret@db.example.test:5432/rustbox?sslmode=require"
|
||||
)),
|
||||
Some("postgres://db.example.test:5432/rustbox".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
sanitize_url(Some("redis://:secret@redis.example.test:6379/2")),
|
||||
Some("redis://redis.example.test:6379/2".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diagnostics_client_ip_ignores_untrusted_forwarded_header() {
|
||||
let config = AppConfig {
|
||||
trusted_proxies: Some("127.0.0.1/32".to_string()),
|
||||
..AppConfig::default()
|
||||
};
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("x-forwarded-for", HeaderValue::from_static("203.0.113.42"));
|
||||
let peer: IpAddr = "198.51.100.10".parse().unwrap();
|
||||
assert_eq!(effective_client_ip(&config, &headers, peer), peer);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diagnostics_client_ip_uses_forwarded_header_only_from_trusted_proxy() {
|
||||
let config = AppConfig {
|
||||
trusted_proxies: Some("127.0.0.1/32".to_string()),
|
||||
..AppConfig::default()
|
||||
};
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("x-forwarded-for", HeaderValue::from_static("203.0.113.42"));
|
||||
assert_eq!(
|
||||
effective_client_ip(&config, &headers, "127.0.0.1".parse().unwrap()),
|
||||
"203.0.113.42".parse::<IpAddr>().unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diagnostics_endpoint_rejects_proxy_headers_from_untrusted_peer() {
|
||||
let config = AppConfig::default();
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("x-forwarded-for", HeaderValue::from_static("203.0.113.42"));
|
||||
assert!(diagnostics_client_ip(&config, &headers, "127.0.0.1".parse().unwrap(),).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diagnostics_endpoint_rejects_malformed_xff_from_trusted_proxy() {
|
||||
let config = AppConfig {
|
||||
trusted_proxies: Some("127.0.0.1/32".to_string()),
|
||||
..AppConfig::default()
|
||||
};
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("x-forwarded-for", HeaderValue::from_static("not-an-ip"));
|
||||
assert!(diagnostics_client_ip(&config, &headers, "127.0.0.1".parse().unwrap(),).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diagnostics_endpoint_accepts_xff_from_configured_trusted_proxy() {
|
||||
let config = AppConfig {
|
||||
trusted_proxies: Some("127.0.0.1/32".to_string()),
|
||||
..AppConfig::default()
|
||||
};
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("x-forwarded-for", HeaderValue::from_static("203.0.113.42"));
|
||||
assert_eq!(
|
||||
diagnostics_client_ip(&config, &headers, "127.0.0.1".parse().unwrap()).unwrap(),
|
||||
"203.0.113.42".parse::<IpAddr>().unwrap()
|
||||
);
|
||||
}
|
||||
}
|
||||
+236
-30
@@ -12,7 +12,8 @@ use tower_governor::key_extractor::KeyExtractor;
|
||||
|
||||
use crate::imap::client::mock::MockImapClient;
|
||||
use crate::imap::client::{
|
||||
EmailAddress, ImapAttachment, ImapError, ImapFolder, ImapMessageBody, ImapMessageHeader,
|
||||
EmailAddress, FolderStatusExtended, ImapAttachment, ImapError, ImapFolder, ImapMessageBody,
|
||||
ImapMessageHeader,
|
||||
};
|
||||
use crate::smtp::client::SmtpError;
|
||||
use crate::smtp::client::mock::MockSmtpClient;
|
||||
@@ -304,15 +305,18 @@ async fn disabled_optional_features_are_not_registered() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn auth_policy_exposes_optional_feature_switches() {
|
||||
async fn auth_policy_exposes_only_login_requirements_before_authentication() {
|
||||
let dir = setup_static_dir();
|
||||
let mut config = (*test_config(dir.path().to_str().unwrap())).clone();
|
||||
config.remember_me_enabled = true;
|
||||
config.sieve_enabled = false;
|
||||
config.diagnostics_enabled = false;
|
||||
config.about_enabled = false;
|
||||
let store = test_store();
|
||||
let (browser_id, account_id, token) = setup_test_account(&store, "alice@example.com");
|
||||
let app = create_router(
|
||||
Arc::new(config),
|
||||
test_store(),
|
||||
store,
|
||||
test_imap_client(),
|
||||
test_smtp_client(),
|
||||
test_search_engine("/tmp/oxi-test"),
|
||||
@@ -321,6 +325,7 @@ async fn auth_policy_exposes_optional_feature_switches() {
|
||||
);
|
||||
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/auth/policy")
|
||||
@@ -332,13 +337,52 @@ async fn auth_policy_exposes_optional_feature_switches() {
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||
assert_eq!(json["rememberMeEnabled"], true);
|
||||
for key in [
|
||||
"rememberMeDays",
|
||||
"sessionTimeoutHours",
|
||||
"pgpEnabled",
|
||||
"sieveEnabled",
|
||||
"diagnosticsEnabled",
|
||||
"aboutEnabled",
|
||||
] {
|
||||
assert!(json.get(key).is_none(), "public policy leaked {key}");
|
||||
}
|
||||
|
||||
let unauth = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/auth/session-policy")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(unauth.status(), StatusCode::UNAUTHORIZED);
|
||||
|
||||
let mut request = Request::builder()
|
||||
.uri("/api/auth/session-policy")
|
||||
.header("x-requested-with", "XMLHttpRequest");
|
||||
for (name, value) in auth_headers(&browser_id, &account_id, &token) {
|
||||
request = request.header(name, value);
|
||||
}
|
||||
let response = app
|
||||
.oneshot(request.body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||
assert_eq!(json["sieveEnabled"], false);
|
||||
assert_eq!(json["diagnosticsEnabled"], false);
|
||||
assert_eq!(json["aboutEnabled"], false);
|
||||
assert!(json.get("rememberMeDays").is_none());
|
||||
assert!(json.get("sessionTimeoutHours").is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn security_headers_are_opt_in() {
|
||||
async fn security_headers_are_opt_in_and_content_type_aware() {
|
||||
let dir = setup_static_dir();
|
||||
let app = create_router(
|
||||
test_config(dir.path().to_str().unwrap()),
|
||||
@@ -350,43 +394,134 @@ async fn security_headers_are_opt_in() {
|
||||
test_idle_manager(),
|
||||
);
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/health")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(response.headers().get("content-security-policy").is_none());
|
||||
|
||||
let mut config = (*test_config(dir.path().to_str().unwrap())).clone();
|
||||
config.security_headers_enabled = true;
|
||||
let app = create_router(
|
||||
Arc::new(config),
|
||||
assert!(response.headers().get("x-content-type-options").is_none());
|
||||
let disabled_api = create_router(
|
||||
test_config(dir.path().to_str().unwrap()),
|
||||
test_store(),
|
||||
test_imap_client(),
|
||||
test_smtp_client(),
|
||||
test_search_engine("/tmp/oxi-test"),
|
||||
test_event_bus(),
|
||||
test_idle_manager(),
|
||||
);
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/health")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.headers()["x-frame-options"], "DENY");
|
||||
)
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/health")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
response.headers()["content-security-policy"]
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.contains("default-src 'self'")
|
||||
disabled_api
|
||||
.headers()
|
||||
.get("x-content-type-options")
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
disabled_api
|
||||
.headers()
|
||||
.get("cross-origin-resource-policy")
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
disabled_api
|
||||
.headers()
|
||||
.get("content-security-policy")
|
||||
.is_none()
|
||||
);
|
||||
assert!(disabled_api.headers().get("x-frame-options").is_none());
|
||||
|
||||
let mut config = (*test_config(dir.path().to_str().unwrap())).clone();
|
||||
config.security_headers_enabled = true;
|
||||
let config = Arc::new(config);
|
||||
|
||||
let html = create_router(
|
||||
config.clone(),
|
||||
test_store(),
|
||||
test_imap_client(),
|
||||
test_smtp_client(),
|
||||
test_search_engine("/tmp/oxi-test"),
|
||||
test_event_bus(),
|
||||
test_idle_manager(),
|
||||
)
|
||||
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(html.headers()["x-content-type-options"], "nosniff");
|
||||
assert_eq!(html.headers()["x-frame-options"], "DENY");
|
||||
assert_eq!(html.headers()["referrer-policy"], "no-referrer");
|
||||
assert_eq!(
|
||||
html.headers()["permissions-policy"],
|
||||
"camera=(), microphone=(), geolocation=()"
|
||||
);
|
||||
assert_eq!(html.headers()["cross-origin-opener-policy"], "same-origin");
|
||||
let html_csp = html.headers()["content-security-policy"].to_str().unwrap();
|
||||
assert!(html_csp.contains("default-src 'self'"));
|
||||
assert!(html_csp.contains("script-src 'self';"));
|
||||
assert!(html_csp.contains("script-src-attr 'none'"));
|
||||
assert!(html_csp.contains("style-src 'self'; style-src-attr 'unsafe-inline'"));
|
||||
assert!(!html_csp.contains("style-src 'self' 'unsafe-inline'"));
|
||||
assert!(!html_csp.contains("unsafe-inline'; script-src"));
|
||||
assert!(!html_csp.contains("script-src 'self' 'unsafe-inline'"));
|
||||
|
||||
let css = create_router(
|
||||
config.clone(),
|
||||
test_store(),
|
||||
test_imap_client(),
|
||||
test_smtp_client(),
|
||||
test_search_engine("/tmp/oxi-test"),
|
||||
test_event_bus(),
|
||||
test_idle_manager(),
|
||||
)
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/assets/app.css")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(css.headers()["x-content-type-options"], "nosniff");
|
||||
assert_eq!(css.headers()["referrer-policy"], "no-referrer");
|
||||
assert_eq!(css.headers()["cross-origin-resource-policy"], "same-origin");
|
||||
assert!(css.headers().get("x-frame-options").is_none());
|
||||
assert!(css.headers().get("permissions-policy").is_none());
|
||||
assert!(css.headers().get("cross-origin-opener-policy").is_none());
|
||||
assert!(css.headers().get("content-security-policy").is_none());
|
||||
|
||||
let api = create_router(
|
||||
config,
|
||||
test_store(),
|
||||
test_imap_client(),
|
||||
test_smtp_client(),
|
||||
test_search_engine("/tmp/oxi-test"),
|
||||
test_event_bus(),
|
||||
test_idle_manager(),
|
||||
)
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/health")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(api.headers()["x-content-type-options"], "nosniff");
|
||||
assert_eq!(api.headers()["cache-control"], "no-store");
|
||||
assert_eq!(api.headers()["cross-origin-resource-policy"], "same-origin");
|
||||
assert_eq!(
|
||||
api.headers()["content-security-policy"],
|
||||
"frame-ancestors 'none'"
|
||||
);
|
||||
assert_eq!(api.headers()["x-frame-options"], "DENY");
|
||||
assert!(api.headers().get("referrer-policy").is_none());
|
||||
assert!(api.headers().get("permissions-policy").is_none());
|
||||
assert!(api.headers().get("cross-origin-opener-policy").is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -945,6 +1080,77 @@ async fn get_folders_returns_200_with_folder_list() {
|
||||
assert_eq!(folders[1]["name"], "Sent");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn folder_refresh_uses_server_counts_without_relisting_fresh_folder_tree() {
|
||||
let static_dir = setup_static_dir();
|
||||
let data_dir = TempDir::new().unwrap();
|
||||
let config = test_config_with_imap(
|
||||
static_dir.path().to_str().unwrap(),
|
||||
data_dir.path().to_str().unwrap(),
|
||||
);
|
||||
let store = test_store();
|
||||
let (browser_id, account_id, token) = setup_test_account(&store, "alice@example.com");
|
||||
let user_hash = crate::auth::user_data::hash_email("alice@example.com");
|
||||
provision_user_db(data_dir.path().to_str().unwrap(), &user_hash);
|
||||
|
||||
let conn = crate::db::pool::open_user_db(data_dir.path().to_str().unwrap(), &user_hash)
|
||||
.expect("open user DB");
|
||||
crate::db::folders::upsert_folder(
|
||||
&conn,
|
||||
"INBOX",
|
||||
Some("/"),
|
||||
None,
|
||||
"\\HasNoChildren",
|
||||
true,
|
||||
3444,
|
||||
1,
|
||||
1,
|
||||
10,
|
||||
)
|
||||
.unwrap();
|
||||
crate::db::folders::touch_all_folders(&conn).unwrap();
|
||||
drop(conn);
|
||||
|
||||
let mock = MockImapClient::new().with_folder_status_extended(FolderStatusExtended {
|
||||
uid_validity: 1,
|
||||
exists: 3444,
|
||||
uid_next: 3445,
|
||||
unseen: 12,
|
||||
highest_modseq: 11,
|
||||
});
|
||||
let app = create_router(
|
||||
config,
|
||||
store,
|
||||
Arc::new(mock),
|
||||
test_smtp_client(),
|
||||
test_search_engine(data_dir.path().to_str().unwrap()),
|
||||
test_event_bus(),
|
||||
test_idle_manager(),
|
||||
);
|
||||
|
||||
let mut request = Request::builder()
|
||||
.uri("/api/folders?refresh=true")
|
||||
.header("x-requested-with", "XMLHttpRequest");
|
||||
for (name, value) in auth_headers(&browser_id, &account_id, &token) {
|
||||
request = request.header(name, value);
|
||||
}
|
||||
let response = app
|
||||
.oneshot(request.body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||
let inbox = json["folders"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|folder| folder["name"] == "INBOX")
|
||||
.expect("INBOX remains in the cached folder tree");
|
||||
assert_eq!(inbox["total_count"], 3444);
|
||||
assert_eq!(inbox["unread_count"], 12);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_folders_returns_503_when_imap_fails() {
|
||||
let static_dir = setup_static_dir();
|
||||
|
||||
+74
-3
@@ -219,6 +219,25 @@ impl SearchEngine {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn diagnostics(&self) -> Result<JsonValue, String> {
|
||||
match &self.backend {
|
||||
SearchBackend::Disabled => Ok(json!({
|
||||
"backend": "disabled",
|
||||
"status": "disabled"
|
||||
})),
|
||||
SearchBackend::LocalTantivy { base_dir } => {
|
||||
std::fs::create_dir_all(base_dir)
|
||||
.map_err(|e| format!("failed to prepare local search directory: {e}"))?;
|
||||
Ok(json!({
|
||||
"backend": "local_tantivy",
|
||||
"status": "ok",
|
||||
"path": base_dir,
|
||||
}))
|
||||
}
|
||||
SearchBackend::OpenSearch { backend, .. } => backend.diagnostics().await,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn open_user_index(&self, user_hash: &str) -> Result<UserIndex, String> {
|
||||
match &self.backend {
|
||||
SearchBackend::Disabled => Err("search index is disabled by configuration".to_string()),
|
||||
@@ -695,9 +714,6 @@ impl LocalUserIndex {
|
||||
|
||||
impl OpenSearchBackend {
|
||||
fn event_lease_secs(&self) -> u64 {
|
||||
// A mutation can probe/create the index and then write the document.
|
||||
// Account for failover across all configured URLs and keep a safety margin so
|
||||
// another worker cannot reclaim the same document while this request is alive.
|
||||
self.request_timeout_secs
|
||||
.saturating_mul(self.urls.len().max(1) as u64)
|
||||
.saturating_mul(4)
|
||||
@@ -796,6 +812,61 @@ impl OpenSearchBackend {
|
||||
}
|
||||
}
|
||||
|
||||
async fn diagnostics(&self) -> Result<JsonValue, String> {
|
||||
let (status, body) = self
|
||||
.request(Method::GET, "_cluster/health?local=true", None)
|
||||
.await?;
|
||||
if !status.is_success() {
|
||||
return Err(format!("OpenSearch health check failed ({status}): {body}"));
|
||||
}
|
||||
self.ensure_index().await?;
|
||||
let health: JsonValue = serde_json::from_str(&body)
|
||||
.map_err(|e| format!("invalid OpenSearch health response: {e}"))?;
|
||||
let (count_status, count_body) = self
|
||||
.request(Method::GET, &format!("{}/_count", self.index_name), None)
|
||||
.await?;
|
||||
if !count_status.is_success() {
|
||||
return Err(format!(
|
||||
"OpenSearch index count failed ({count_status}): {count_body}"
|
||||
));
|
||||
}
|
||||
let document_count = serde_json::from_str::<JsonValue>(&count_body)
|
||||
.map_err(|e| format!("invalid OpenSearch count response: {e}"))?
|
||||
.get("count")
|
||||
.and_then(JsonValue::as_u64)
|
||||
.ok_or_else(|| {
|
||||
"OpenSearch count response does not contain a numeric count".to_string()
|
||||
})?;
|
||||
let cluster_status = health
|
||||
.get("status")
|
||||
.and_then(JsonValue::as_str)
|
||||
.unwrap_or("unknown");
|
||||
if cluster_status.eq_ignore_ascii_case("red") {
|
||||
return Err("OpenSearch cluster health is red".to_string());
|
||||
}
|
||||
let diagnostics_status = if cluster_status.eq_ignore_ascii_case("green") {
|
||||
"ok"
|
||||
} else {
|
||||
"degraded"
|
||||
};
|
||||
Ok(json!({
|
||||
"backend": "opensearch",
|
||||
"status": diagnostics_status,
|
||||
"clusterStatus": cluster_status,
|
||||
"clusterName": health.get("cluster_name").and_then(JsonValue::as_str),
|
||||
"numberOfNodes": health.get("number_of_nodes").and_then(JsonValue::as_u64),
|
||||
"numberOfDataNodes": health.get("number_of_data_nodes").and_then(JsonValue::as_u64),
|
||||
"activePrimaryShards": health.get("active_primary_shards").and_then(JsonValue::as_u64),
|
||||
"activeShards": health.get("active_shards").and_then(JsonValue::as_u64),
|
||||
"relocatingShards": health.get("relocating_shards").and_then(JsonValue::as_u64),
|
||||
"initializingShards": health.get("initializing_shards").and_then(JsonValue::as_u64),
|
||||
"unassignedShards": health.get("unassigned_shards").and_then(JsonValue::as_u64),
|
||||
"index": self.index_name,
|
||||
"documentCount": document_count,
|
||||
"configuredUrls": self.urls.len(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn document_id(user_hash: &str, uid: u32, folder: &str) -> String {
|
||||
format!(
|
||||
"{:x}",
|
||||
|
||||
@@ -4,7 +4,7 @@ use async_trait::async_trait;
|
||||
use rusqlite::{Connection, params};
|
||||
use sqlx::{FromRow, MySqlPool, PgPool};
|
||||
|
||||
use super::{PersistedSession, SessionPersistence};
|
||||
use super::{PersistedSession, SessionPersistence, SessionPersistenceDiagnostics};
|
||||
use crate::config::AppConfig;
|
||||
use crate::queries;
|
||||
|
||||
@@ -148,11 +148,6 @@ pub struct DatabaseSessionStore {
|
||||
|
||||
impl DatabaseSessionStore {
|
||||
pub async fn connect(config: &AppConfig) -> Result<Self, String> {
|
||||
// Schema ownership is centralized in db::pool::configure(). Calling it here
|
||||
// keeps direct/test construction safe, while the normal application startup
|
||||
// hits the OnceLock fast path. Do not run schema.sql again from this store:
|
||||
// that used to duplicate DDL for SQLite/MySQL/PostgreSQL and produced repeated
|
||||
// PostgreSQL "relation already exists, skipping" NOTICE messages.
|
||||
crate::db::pool::configure(config).await?;
|
||||
|
||||
let pool = match config.database_backend.as_str() {
|
||||
@@ -425,6 +420,61 @@ impl SessionPersistence for DatabaseSessionStore {
|
||||
}
|
||||
}
|
||||
|
||||
async fn diagnostics(&self, now_unix: u64) -> Result<SessionPersistenceDiagnostics, String> {
|
||||
let (backend, active_sessions, unique_users, unique_browsers, latest_accessed_unix) =
|
||||
match &self.pool {
|
||||
Pool::Sqlite(path) => {
|
||||
let path = path.clone();
|
||||
let row = sqlite_task(path, move |conn| {
|
||||
conn.query_row(
|
||||
"SELECT COUNT(*), COUNT(DISTINCT user_hash), COUNT(DISTINCT browser_id), MAX(last_accessed_unix) FROM sessions WHERE (last_accessed_unix + timeout_secs) >= ?1",
|
||||
params![now_unix as i64],
|
||||
|row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?, row.get::<_, i64>(2)?, row.get::<_, Option<i64>>(3)?)),
|
||||
)
|
||||
.map_err(|e| format!("failed to query SQLite session diagnostics: {e}"))
|
||||
})
|
||||
.await?;
|
||||
("database/sqlite", row.0, row.1, row.2, row.3)
|
||||
}
|
||||
Pool::Mysql(pool) => {
|
||||
let row: (i64, i64, i64, Option<i64>) = sqlx::query_as(
|
||||
"SELECT COUNT(*), COUNT(DISTINCT user_hash), COUNT(DISTINCT browser_id), MAX(last_accessed_unix) FROM sessions WHERE (last_accessed_unix + timeout_secs) >= ?"
|
||||
)
|
||||
.bind(now_unix as i64)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|e| format!("failed to query MySQL session diagnostics: {e}"))?;
|
||||
("database/mysql", row.0, row.1, row.2, row.3)
|
||||
}
|
||||
Pool::Pgsql(pool) => {
|
||||
let row: (i64, i64, i64, Option<i64>) = sqlx::query_as(
|
||||
"SELECT COUNT(*), COUNT(DISTINCT user_hash), COUNT(DISTINCT browser_id), MAX(last_accessed_unix) FROM sessions WHERE (last_accessed_unix + timeout_secs) >= $1"
|
||||
)
|
||||
.bind(now_unix as i64)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|e| format!("failed to query PostgreSQL session diagnostics: {e}"))?;
|
||||
("database/pgsql", row.0, row.1, row.2, row.3)
|
||||
}
|
||||
};
|
||||
Ok(SessionPersistenceDiagnostics {
|
||||
backend: backend.to_string(),
|
||||
status: "ok".to_string(),
|
||||
active_sessions: active_sessions.max(0) as usize,
|
||||
unique_users: unique_users.max(0) as usize,
|
||||
unique_browsers: unique_browsers.max(0) as usize,
|
||||
latest_accessed_unix: latest_accessed_unix.and_then(|value| u64::try_from(value).ok()),
|
||||
truncated: false,
|
||||
engine_version: None,
|
||||
connected_clients: None,
|
||||
blocked_clients: None,
|
||||
used_memory_bytes: None,
|
||||
server_uptime_secs: None,
|
||||
role: None,
|
||||
master_link_status: None,
|
||||
})
|
||||
}
|
||||
|
||||
async fn purge_expired(&self, now_unix: u64) -> Result<u64, String> {
|
||||
match &self.pool {
|
||||
Pool::Sqlite(path) => {
|
||||
|
||||
@@ -7,6 +7,24 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::config::AppConfig;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct SessionPersistenceDiagnostics {
|
||||
pub backend: String,
|
||||
pub status: String,
|
||||
pub active_sessions: usize,
|
||||
pub unique_users: usize,
|
||||
pub unique_browsers: usize,
|
||||
pub latest_accessed_unix: Option<u64>,
|
||||
pub truncated: bool,
|
||||
pub engine_version: Option<String>,
|
||||
pub connected_clients: Option<u64>,
|
||||
pub blocked_clients: Option<u64>,
|
||||
pub used_memory_bytes: Option<u64>,
|
||||
pub server_uptime_secs: Option<u64>,
|
||||
pub role: Option<String>,
|
||||
pub master_link_status: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PersistedSession {
|
||||
pub token: String,
|
||||
@@ -45,6 +63,7 @@ pub trait SessionPersistence: Send + Sync {
|
||||
async fn delete_account(&self, account_id: &str) -> Result<bool, String>;
|
||||
async fn delete_browser(&self, browser_id: &str) -> Result<(), String>;
|
||||
async fn purge_expired(&self, now_unix: u64) -> Result<u64, String>;
|
||||
async fn diagnostics(&self, now_unix: u64) -> Result<SessionPersistenceDiagnostics, String>;
|
||||
}
|
||||
|
||||
pub async fn from_config(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use async_trait::async_trait;
|
||||
use redis::AsyncCommands;
|
||||
|
||||
use super::{PersistedSession, SessionPersistence};
|
||||
use super::{PersistedSession, SessionPersistence, SessionPersistenceDiagnostics};
|
||||
use crate::config::AppConfig;
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -23,6 +23,14 @@ pub struct RedisSessionStore {
|
||||
mode: RedisMode,
|
||||
}
|
||||
|
||||
fn redis_info_value<'a>(info: &'a str, key: &str) -> Option<&'a str> {
|
||||
info.lines().find_map(|line| {
|
||||
let line = line.trim_end_matches('\r');
|
||||
let (name, value) = line.split_once(':')?;
|
||||
(name == key).then_some(value.trim())
|
||||
})
|
||||
}
|
||||
|
||||
impl RedisSessionStore {
|
||||
pub async fn connect(config: &AppConfig) -> Result<Self, String> {
|
||||
let mode = if let Some(url) = config
|
||||
@@ -274,6 +282,107 @@ impl SessionPersistence for RedisSessionStore {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn diagnostics(&self, now_unix: u64) -> Result<SessionPersistenceDiagnostics, String> {
|
||||
use std::collections::HashSet;
|
||||
|
||||
const MAX_SCAN_SESSIONS: usize = 5000;
|
||||
let mut con = self.connection().await?;
|
||||
let pong: String = redis::cmd("PING")
|
||||
.query_async(&mut con)
|
||||
.await
|
||||
.map_err(|e| format!("Redis PING failed: {e}"))?;
|
||||
if !pong.eq_ignore_ascii_case("PONG") {
|
||||
return Err(format!("unexpected Redis PING response: {pong}"));
|
||||
}
|
||||
let info: String = redis::cmd("INFO")
|
||||
.query_async(&mut con)
|
||||
.await
|
||||
.map_err(|e| format!("Redis INFO failed: {e}"))?;
|
||||
|
||||
let mut cursor = 0u64;
|
||||
let mut seen = 0usize;
|
||||
let mut active = 0usize;
|
||||
let mut users = HashSet::new();
|
||||
let mut browsers = HashSet::new();
|
||||
let mut latest = None;
|
||||
let mut truncated = false;
|
||||
|
||||
loop {
|
||||
let (next_cursor, keys): (u64, Vec<String>) = redis::cmd("SCAN")
|
||||
.arg(cursor)
|
||||
.arg("MATCH")
|
||||
.arg("rustbox:session:*")
|
||||
.arg("COUNT")
|
||||
.arg(250)
|
||||
.query_async(&mut con)
|
||||
.await
|
||||
.map_err(|e| format!("Redis SCAN failed: {e}"))?;
|
||||
|
||||
if !keys.is_empty() {
|
||||
let remaining = MAX_SCAN_SESSIONS.saturating_sub(seen);
|
||||
if remaining == 0 {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
let keys = if keys.len() > remaining {
|
||||
truncated = true;
|
||||
&keys[..remaining]
|
||||
} else {
|
||||
&keys[..]
|
||||
};
|
||||
let mut command = redis::cmd("MGET");
|
||||
for key in keys {
|
||||
command.arg(key);
|
||||
}
|
||||
let values: Vec<Option<String>> = command
|
||||
.query_async(&mut con)
|
||||
.await
|
||||
.map_err(|e| format!("Redis MGET diagnostics failed: {e}"))?;
|
||||
seen += keys.len();
|
||||
for raw in values.into_iter().flatten() {
|
||||
let Ok(session) = serde_json::from_str::<PersistedSession>(&raw) else {
|
||||
continue;
|
||||
};
|
||||
if session.expired_at(now_unix) {
|
||||
continue;
|
||||
}
|
||||
active += 1;
|
||||
users.insert(session.user_hash);
|
||||
browsers.insert(session.browser_id);
|
||||
latest = Some(latest.map_or(session.last_accessed_unix, |value: u64| {
|
||||
value.max(session.last_accessed_unix)
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
if truncated || next_cursor == 0 {
|
||||
break;
|
||||
}
|
||||
cursor = next_cursor;
|
||||
}
|
||||
|
||||
Ok(SessionPersistenceDiagnostics {
|
||||
backend: "redis".to_string(),
|
||||
status: "ok".to_string(),
|
||||
active_sessions: active,
|
||||
unique_users: users.len(),
|
||||
unique_browsers: browsers.len(),
|
||||
latest_accessed_unix: latest,
|
||||
truncated,
|
||||
engine_version: redis_info_value(&info, "redis_version").map(str::to_string),
|
||||
connected_clients: redis_info_value(&info, "connected_clients")
|
||||
.and_then(|value| value.parse().ok()),
|
||||
blocked_clients: redis_info_value(&info, "blocked_clients")
|
||||
.and_then(|value| value.parse().ok()),
|
||||
used_memory_bytes: redis_info_value(&info, "used_memory")
|
||||
.and_then(|value| value.parse().ok()),
|
||||
server_uptime_secs: redis_info_value(&info, "uptime_in_seconds")
|
||||
.and_then(|value| value.parse().ok()),
|
||||
role: redis_info_value(&info, "role").map(str::to_string),
|
||||
master_link_status: redis_info_value(&info, "master_link_status").map(str::to_string),
|
||||
})
|
||||
}
|
||||
|
||||
async fn purge_expired(&self, _now_unix: u64) -> Result<u64, String> {
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ use crate::config::AppConfig;
|
||||
const INDEX_HTML: &str = include_str!("web/index.html");
|
||||
const APP_CSS: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/rustbox-ui.css"));
|
||||
const APP_JS: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/rustbox-ui.js"));
|
||||
const THEME_BOOTSTRAP_JS: &[u8] = include_bytes!("web/theme-bootstrap.js");
|
||||
const ICONS: &[u8] = include_bytes!("web/icons.svg");
|
||||
const I18N_EN: &[u8] = include_bytes!("web/i18n/en.json");
|
||||
const I18N_PL: &[u8] = include_bytes!("web/i18n/pl.json");
|
||||
@@ -27,7 +28,6 @@ fn bytes_response(
|
||||
[
|
||||
(header::CONTENT_TYPE, content_type),
|
||||
(header::CACHE_CONTROL, cache),
|
||||
(header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
|
||||
],
|
||||
body,
|
||||
)
|
||||
@@ -48,6 +48,7 @@ pub async fn serve_ui(uri: Uri, Extension(config): Extension<Arc<AppConfig>>) ->
|
||||
|
||||
let versioned_css = format!("assets/app.{ASSET_ID}.css");
|
||||
let versioned_js = format!("assets/app.{ASSET_ID}.js");
|
||||
let versioned_theme_bootstrap = format!("assets/theme-bootstrap.{ASSET_ID}.js");
|
||||
if relative == versioned_css.as_str() {
|
||||
return bytes_response(
|
||||
"text/css; charset=utf-8",
|
||||
@@ -62,10 +63,22 @@ pub async fn serve_ui(uri: Uri, Extension(config): Extension<Arc<AppConfig>>) ->
|
||||
APP_JS,
|
||||
);
|
||||
}
|
||||
if relative == versioned_theme_bootstrap.as_str() {
|
||||
return bytes_response(
|
||||
"text/javascript; charset=utf-8",
|
||||
"public, max-age=31536000, immutable",
|
||||
THEME_BOOTSTRAP_JS,
|
||||
);
|
||||
}
|
||||
|
||||
match relative {
|
||||
"assets/app.css" => bytes_response("text/css; charset=utf-8", "no-store", APP_CSS),
|
||||
"assets/app.js" => bytes_response("text/javascript; charset=utf-8", "no-store", APP_JS),
|
||||
"assets/theme-bootstrap.js" => bytes_response(
|
||||
"text/javascript; charset=utf-8",
|
||||
"no-store",
|
||||
THEME_BOOTSTRAP_JS,
|
||||
),
|
||||
"assets/icons.svg" => bytes_response("image/svg+xml", "no-store", ICONS),
|
||||
"assets/i18n/en.json" => {
|
||||
bytes_response("application/json; charset=utf-8", "no-store", I18N_EN)
|
||||
@@ -78,7 +91,6 @@ pub async fn serve_ui(uri: Uri, Extension(config): Extension<Arc<AppConfig>>) ->
|
||||
[
|
||||
(header::CONTENT_TYPE, "image/svg+xml"),
|
||||
(header::CACHE_CONTROL, "no-store"),
|
||||
(header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
|
||||
],
|
||||
FAVICON,
|
||||
)
|
||||
@@ -121,7 +133,6 @@ pub async fn serve_ui(uri: Uri, Extension(config): Extension<Arc<AppConfig>>) ->
|
||||
[
|
||||
(header::CONTENT_TYPE, "text/html; charset=utf-8"),
|
||||
(header::CACHE_CONTROL, "no-store"),
|
||||
(header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
|
||||
],
|
||||
html,
|
||||
)
|
||||
|
||||
@@ -158,15 +158,22 @@
|
||||
.indexing-indicator svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.indexing-indicator.active svg {
|
||||
animation: rustbox-indexing-spin .9s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes rustbox-indexing-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.indexing-indicator svg { animation: none; }
|
||||
.indexing-indicator svg {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.account-summary {
|
||||
@@ -216,6 +223,8 @@
|
||||
}
|
||||
|
||||
.folder-pane {
|
||||
container-name: folder-pane;
|
||||
container-type: inline-size;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 10px;
|
||||
|
||||
@@ -49,10 +49,47 @@
|
||||
}
|
||||
|
||||
.folder-count.unread {
|
||||
color: var(--subtle);
|
||||
}
|
||||
|
||||
.folder-count .folder-unread,
|
||||
.folder-count.unread .folder-count-compact {
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.folder-count-wide {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.folder-count-compact {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.folder-count .folder-total,
|
||||
.folder-count .folder-unread,
|
||||
.folder-count-compact {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.folder-count .folder-separator {
|
||||
color: var(--subtle);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
@container folder-pane (max-width: 205px) {
|
||||
.folder-count-wide {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.folder-count-compact {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
|
||||
.message-pane {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||
|
||||
+5
-14
@@ -12,16 +12,7 @@
|
||||
<meta name="color-scheme" content="dark light">
|
||||
<meta name="description" content="RustBox webmail">
|
||||
<title>RustBox</title>
|
||||
<script>
|
||||
(() => {
|
||||
const root = document.documentElement;
|
||||
const configured = root.dataset.defaultTheme || "system";
|
||||
root.dataset.theme = configured === "system"
|
||||
? (matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark")
|
||||
: configured;
|
||||
root.dataset.interfaceSkin = root.dataset.defaultInterfaceSkin || "rustbox";
|
||||
})();
|
||||
</script>
|
||||
<script src="__RUSTBOX_ASSET_PREFIX__/assets/theme-bootstrap.__RUSTBOX_ASSET_ID__.js"></script>
|
||||
<link rel="icon" href="__RUSTBOX_ASSET_PREFIX__/favicon.svg" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="__RUSTBOX_ASSET_PREFIX__/assets/app.__RUSTBOX_ASSET_ID__.css">
|
||||
</head>
|
||||
@@ -77,13 +68,13 @@
|
||||
<kbd>/</kbd>
|
||||
</form>
|
||||
<div class="appbar-actions">
|
||||
<span id="indexing-indicator" class="indexing-indicator hidden" role="status" aria-live="polite"
|
||||
title="Mail synchronization" aria-label="Mail synchronization"><svg aria-hidden="true">
|
||||
<use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-refresh-cw"></use>
|
||||
</svg></span>
|
||||
<button id="contacts-btn" class="icon-button" type="button" title="Contacts" aria-label="Contacts"><svg>
|
||||
<use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-user-round"></use>
|
||||
</svg></button>
|
||||
<span id="indexing-indicator" class="indexing-indicator hidden" role="status" aria-live="polite"
|
||||
title="Indexing messages" aria-label="Indexing messages"><svg aria-hidden="true">
|
||||
<use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-refresh-cw"></use>
|
||||
</svg></span>
|
||||
<button id="calendar-btn" class="icon-button" type="button" title="Calendar" aria-label="Calendar"><svg>
|
||||
<use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-calendar-days"></use>
|
||||
</svg></button>
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
pgpKeys: [],
|
||||
pgpPrefs: { wkdEnabled: true, hkpEnabled: false, requireTrustedEncryption: false },
|
||||
pgpRecipientStatus: [],
|
||||
authPolicy: { rememberMeEnabled: true, rememberMeDays: 30, sessionTimeoutHours: 24, pgpEnabled: true, sieveEnabled: true, diagnosticsEnabled: true, aboutEnabled: true },
|
||||
authPolicy: { rememberMeEnabled: true, pgpEnabled: false, sieveEnabled: false, diagnosticsEnabled: false, aboutEnabled: false },
|
||||
contacts: [],
|
||||
trustedSenders: new Set(),
|
||||
contactGroups: [],
|
||||
@@ -63,6 +63,11 @@
|
||||
realtimeNotifiedMessages: new Map(),
|
||||
realtimeWatchedFolder: null,
|
||||
indexingActive: false,
|
||||
indexingPending: 0,
|
||||
imapSyncActive: false,
|
||||
imapSyncFolder: null,
|
||||
imapSyncPending: 0,
|
||||
imapSyncInventoryPending: 0,
|
||||
calendarReminderTimer: null,
|
||||
composeDraftId: null,
|
||||
composeAttachments: [],
|
||||
|
||||
@@ -481,17 +481,40 @@ function applyFeatureAvailability() {
|
||||
|
||||
async function loadAuthPolicy() {
|
||||
try {
|
||||
state.authPolicy = await request("/auth/policy", { method: "GET" });
|
||||
const policy = await request("/auth/policy", { method: "GET" });
|
||||
state.authPolicy = { ...state.authPolicy, ...policy };
|
||||
} catch (_) {
|
||||
state.authPolicy = { rememberMeEnabled: true, rememberMeDays: 30, sessionTimeoutHours: 24, pgpEnabled: true, sieveEnabled: true, diagnosticsEnabled: true, aboutEnabled: true };
|
||||
state.authPolicy = { ...state.authPolicy, rememberMeEnabled: true };
|
||||
}
|
||||
const enabled = state.authPolicy.rememberMeEnabled !== false;
|
||||
["#login-remember-row", "#add-account-remember-row"].forEach((selector) => $(selector)?.classList.toggle("hidden", !enabled));
|
||||
["#login-remember", "#add-account-remember"].forEach((selector) => {
|
||||
const input = $(selector);
|
||||
if (!input) return;
|
||||
input.disabled = !enabled;
|
||||
if (!enabled) input.checked = false;
|
||||
});
|
||||
["#login-remember-label", "#add-account-remember-label"].forEach((selector) => {
|
||||
const label = $(selector);
|
||||
if (label) label.textContent = "Keep me signed in";
|
||||
});
|
||||
}
|
||||
|
||||
async function loadSessionPolicy() {
|
||||
try {
|
||||
const policy = await request("/auth/session-policy", { method: "GET" });
|
||||
state.authPolicy = { ...state.authPolicy, ...policy };
|
||||
} catch (_) {
|
||||
state.authPolicy = {
|
||||
...state.authPolicy,
|
||||
pgpEnabled: false,
|
||||
sieveEnabled: false,
|
||||
diagnosticsEnabled: false,
|
||||
aboutEnabled: false,
|
||||
};
|
||||
}
|
||||
setOpenPgpAvailability(state.authPolicy.pgpEnabled !== false);
|
||||
applyFeatureAvailability();
|
||||
const enabled = state.authPolicy.rememberMeEnabled !== false;
|
||||
["#login-remember-row", "#add-account-remember-row"].forEach((selector) => $(selector)?.classList.toggle("hidden", !enabled));
|
||||
["#login-remember", "#add-account-remember"].forEach((selector) => { const input = $(selector); if (input && !enabled) { input.checked = false; input.disabled = true; } });
|
||||
const days = Number(state.authPolicy.rememberMeDays || 30);
|
||||
["#login-remember-label", "#add-account-remember-label"].forEach((selector) => { const label = $(selector); if (label) label.textContent = `Keep me signed in (${days} days)`; });
|
||||
}
|
||||
|
||||
async function loadAccounts() {
|
||||
@@ -607,9 +630,11 @@ async function reloadActiveAccount() {
|
||||
state.selectedMessages.clear();
|
||||
state.selectionMode = false;
|
||||
closeReader();
|
||||
await loadSessionPolicy();
|
||||
await loadPreferences();
|
||||
await loadMailPreferences();
|
||||
await loadOpenPgp();
|
||||
if (state.authPolicy.pgpEnabled !== false) await loadOpenPgp();
|
||||
else setOpenPgpAvailability(false);
|
||||
await loadIdentities();
|
||||
await loadFolders(true);
|
||||
await loadSpecialFolders();
|
||||
@@ -684,9 +709,11 @@ async function login(event) {
|
||||
state.accountId = data.account.id;
|
||||
localStorage.setItem("rustbox.activeAccount", state.accountId);
|
||||
await loadAccounts();
|
||||
await loadSessionPolicy();
|
||||
await loadPreferences();
|
||||
await loadMailPreferences();
|
||||
await loadOpenPgp();
|
||||
if (state.authPolicy.pgpEnabled !== false) await loadOpenPgp();
|
||||
else setOpenPgpAvailability(false);
|
||||
await loadIdentities();
|
||||
await loadFolders(true);
|
||||
await loadSpecialFolders();
|
||||
|
||||
@@ -86,6 +86,43 @@ function toggleFolderDrawer() {
|
||||
setFolderDrawerOpen(!$("#folder-pane")?.classList.contains("open"));
|
||||
}
|
||||
|
||||
function folderCounterParts(folder) {
|
||||
const total = Math.max(0, Number(folder?.total_count) || 0);
|
||||
const unread = Math.max(0, Number(folder?.unread_count) || 0);
|
||||
const title = unread > 0 ? `${total} messages · ${unread} unread` : `${total} messages · no unread`;
|
||||
const wide = unread > 0
|
||||
? `<span class="folder-count-wide"><span class="folder-total">${total}</span><span class="folder-separator"> / </span><span class="folder-unread">${unread}</span></span>`
|
||||
: `<span class="folder-count-wide"><span class="folder-total">${total}</span></span>`;
|
||||
const compactValue = unread > 0 ? unread : total;
|
||||
const compact = `<span class="folder-count-compact">${compactValue}</span>`;
|
||||
return { total, unread, title, html: `${wide}${compact}` };
|
||||
}
|
||||
|
||||
function updateFolderCounter(folder) {
|
||||
if (!folder) return;
|
||||
const parts = folderCounterParts(folder);
|
||||
const button = $$(".folder-button").find((item) => item.dataset.folder === folder.name);
|
||||
const counter = button?.querySelector(".folder-count");
|
||||
if (!counter) return;
|
||||
counter.classList.toggle("unread", parts.unread > 0);
|
||||
counter.title = parts.title;
|
||||
counter.innerHTML = parts.html;
|
||||
}
|
||||
|
||||
function setFolderCounts(folderName, totalCount, unreadCount) {
|
||||
const folder = state.folders.find((item) => item.name === folderName);
|
||||
if (!folder) return;
|
||||
if (Number.isFinite(Number(totalCount))) folder.total_count = Math.max(0, Number(totalCount));
|
||||
if (Number.isFinite(Number(unreadCount))) folder.unread_count = Math.max(0, Number(unreadCount));
|
||||
updateFolderCounter(folder);
|
||||
if (folderName === state.folder && !state.searchContext) {
|
||||
const total = Math.max(0, Number(folder.total_count) || 0);
|
||||
const count = $("#message-count");
|
||||
if (count) count.textContent = `${total} ${total === 1 ? "message" : "messages"}`;
|
||||
}
|
||||
updateUnreadAppBadge();
|
||||
}
|
||||
|
||||
function renderFolders() {
|
||||
const list = $("#folder-list");
|
||||
list.replaceChildren();
|
||||
@@ -109,7 +146,9 @@ function renderFolders() {
|
||||
|
||||
const button = el("button", `folder-button ${folder.name === state.folder ? "active" : ""}`);
|
||||
button.type = "button";
|
||||
button.innerHTML = `${svgIcon(folderIconName(folder))}<span class="folder-name" title="${escapeHtml(folder.name)}">${escapeHtml(folderLeafName(folder))}</span><span class="folder-count ${folder.unread_count ? "unread" : ""}">${folder.unread_count || folder.total_count || ""}</span>`;
|
||||
button.dataset.folder = folder.name;
|
||||
const counter = folderCounterParts(folder);
|
||||
button.innerHTML = `${svgIcon(folderIconName(folder))}<span class="folder-name" title="${escapeHtml(folder.name)}">${escapeHtml(folderLeafName(folder))}</span><span class="folder-count ${counter.unread > 0 ? "unread" : ""}" title="${escapeHtml(counter.title)}">${counter.html}</span>`;
|
||||
button.addEventListener("contextmenu", (event) => { event.preventDefault(); openFolderContextMenu(event.clientX, event.clientY, folder); });
|
||||
button.addEventListener("dragover", (event) => {
|
||||
if (!event.dataTransfer?.types?.includes("application/x-rustbox-message")) return;
|
||||
@@ -160,6 +199,8 @@ async function loadFolders(restoreView = false) {
|
||||
try {
|
||||
const data = await request("/folders", { method: "GET" });
|
||||
state.folders = data.folders || [];
|
||||
setImapSyncInventoryPending(data.pending_sync_count);
|
||||
setIndexingIndicator(state.indexingActive, data.pending_index_count);
|
||||
updateUnreadAppBadge();
|
||||
loadCollapsedFolders();
|
||||
const inbox = state.folders.find((folder) => folder.name.toUpperCase() === "INBOX");
|
||||
@@ -172,6 +213,7 @@ async function loadFolders(restoreView = false) {
|
||||
}
|
||||
renderFolders();
|
||||
$("#connection-label").textContent = "Connected";
|
||||
setTimeout(() => { void refreshFolderListOnly({ forceServer: true }); }, 0);
|
||||
if (restoreView) {
|
||||
const restoredSearch = readActiveSearchContext();
|
||||
if (restoredSearch) {
|
||||
|
||||
@@ -6,10 +6,12 @@ async function loadMessages(reset = false, options = {}) {
|
||||
const previousScrollTop = reset && !restoreState && list ? list.scrollTop : null;
|
||||
if (reset) {
|
||||
state.page = 0;
|
||||
state.messages = [];
|
||||
state.selectedMessages.clear();
|
||||
clearActiveSearchContext();
|
||||
list.innerHTML = `<div class="empty-list">Loading messages…</div>`;
|
||||
if (!options.background) {
|
||||
state.messages = [];
|
||||
state.selectedMessages.clear();
|
||||
clearActiveSearchContext();
|
||||
list.innerHTML = `<div class="empty-list">Loading messages…</div>`;
|
||||
}
|
||||
}
|
||||
$("#folder-title").textContent = state.folder;
|
||||
try {
|
||||
@@ -22,6 +24,7 @@ async function loadMessages(reset = false, options = {}) {
|
||||
let flaggedOnTop = state.currentFolderFlaggedOnTop;
|
||||
let folderTotalCount = null;
|
||||
let folderUnreadCount = null;
|
||||
let syncing = false;
|
||||
let lastLoadedPage = state.page;
|
||||
for (const page of pages) {
|
||||
const forceRefresh = options.force ? "&refresh=true" : "";
|
||||
@@ -30,6 +33,7 @@ async function loadMessages(reset = false, options = {}) {
|
||||
flaggedOnTop = Boolean(data.flagged_on_top);
|
||||
folderTotalCount = Number(data.folder_total_count);
|
||||
folderUnreadCount = Number(data.unread_count);
|
||||
syncing = syncing || Boolean(data.syncing);
|
||||
loaded.push(...(data.messages || []));
|
||||
lastLoadedPage = page;
|
||||
if (loaded.length >= totalCount) break;
|
||||
@@ -38,12 +42,14 @@ async function loadMessages(reset = false, options = {}) {
|
||||
state.total = totalCount;
|
||||
state.currentFolderFlaggedOnTop = flaggedOnTop;
|
||||
state.messages = reset ? loaded : [...state.messages, ...loaded];
|
||||
const folderState = state.folders.find((item) => item.name === state.folder);
|
||||
if (folderState) {
|
||||
if (Number.isFinite(folderTotalCount)) folderState.total_count = folderTotalCount;
|
||||
if (Number.isFinite(folderUnreadCount)) folderState.unread_count = folderUnreadCount;
|
||||
updateUnreadAppBadge();
|
||||
renderFolders();
|
||||
if (state.folders.some((item) => item.name === state.folder)) {
|
||||
setFolderCounts(state.folder, folderTotalCount, folderUnreadCount);
|
||||
}
|
||||
if (syncing) {
|
||||
const pending = Number.isFinite(folderTotalCount) ? Math.max(0, folderTotalCount - state.messages.length) : 0;
|
||||
setImapSyncIndicator(true, state.folder, pending);
|
||||
} else if (state.imapSyncFolder === state.folder) {
|
||||
setImapSyncIndicator(false, null, 0);
|
||||
}
|
||||
$("#connection-label").textContent = "Connected";
|
||||
renderMessages();
|
||||
@@ -59,28 +65,122 @@ async function loadMessages(reset = false, options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizedMessageFlags(value) {
|
||||
const source = Array.isArray(value) ? value : String(value || "").split(/[,\s]+/).filter(Boolean);
|
||||
return [...new Set(source.map((flag) => String(flag).trim()).filter(Boolean))].sort();
|
||||
}
|
||||
|
||||
function findMessageRow(folder, uid) {
|
||||
return $$(".message-row").find((row) => row.dataset.folder === String(folder) && Number(row.dataset.uid) === Number(uid)) || null;
|
||||
}
|
||||
|
||||
function updateMessageRowState(folder, uid, flags) {
|
||||
const row = findMessageRow(folder, uid);
|
||||
if (!row) return;
|
||||
const normalized = normalizedMessageFlags(flags);
|
||||
const unread = !normalized.includes("\\Seen");
|
||||
const flagged = normalized.includes("\\Flagged");
|
||||
row.classList.toggle("unread", unread);
|
||||
row.classList.toggle("flagged", flagged);
|
||||
const readButton = row.querySelector(".message-quick-action:not(.message-flag):not(.message-fullscreen-action)");
|
||||
if (readButton) {
|
||||
readButton.title = unread ? "Mark read" : "Mark unread";
|
||||
readButton.setAttribute("aria-label", readButton.title);
|
||||
}
|
||||
const flagButton = row.querySelector(".message-flag");
|
||||
if (flagButton) {
|
||||
flagButton.classList.toggle("active", flagged);
|
||||
flagButton.title = flagged ? "Unflag" : "Flag";
|
||||
flagButton.setAttribute("aria-label", flagButton.title);
|
||||
}
|
||||
}
|
||||
|
||||
function applyLocalMessageFlags(folder, uid, flags) {
|
||||
const normalized = normalizedMessageFlags(flags);
|
||||
const item = state.messages.find((message) => Number(message.uid) === Number(uid) && (message.folder || state.folder) === folder);
|
||||
if (item) item.flags = normalized.join(" ");
|
||||
if (state.currentDetail?.folder === folder && Number(state.currentDetail?.uid) === Number(uid)) {
|
||||
state.currentDetail.flags = [...normalized];
|
||||
$("#flag-btn")?.classList.toggle("active", normalized.includes("\\Flagged"));
|
||||
}
|
||||
updateMessageRowState(folder, uid, normalized);
|
||||
}
|
||||
|
||||
function applyOptimisticMessageFlag(folder, uid, flag, add) {
|
||||
const message = state.messages.find((item) => Number(item.uid) === Number(uid) && (item.folder || state.folder) === folder);
|
||||
const detailMatches = state.currentDetail?.folder === folder && Number(state.currentDetail?.uid) === Number(uid);
|
||||
const originalFlags = normalizedMessageFlags(message?.flags ?? (detailMatches ? state.currentDetail.flags : []));
|
||||
const next = new Set(originalFlags);
|
||||
add ? next.add(flag) : next.delete(flag);
|
||||
const optimisticFlags = normalizedMessageFlags([...next]);
|
||||
if (originalFlags.join("\u0000") === optimisticFlags.join("\u0000")) return () => {};
|
||||
|
||||
const folderState = state.folders.find((item) => item.name === folder);
|
||||
const originalTotal = Number(folderState?.total_count);
|
||||
const originalUnread = Number(folderState?.unread_count);
|
||||
let optimisticUnread = originalUnread;
|
||||
if (flag === "\\Seen" && Number.isFinite(originalUnread)) {
|
||||
const wasSeen = originalFlags.includes("\\Seen");
|
||||
const isSeen = optimisticFlags.includes("\\Seen");
|
||||
if (wasSeen !== isSeen) optimisticUnread = Math.max(0, originalUnread + (isSeen ? -1 : 1));
|
||||
}
|
||||
|
||||
applyLocalMessageFlags(folder, uid, optimisticFlags);
|
||||
if (Number.isFinite(originalTotal) && Number.isFinite(optimisticUnread) && optimisticUnread !== originalUnread) {
|
||||
setFolderCounts(folder, originalTotal, optimisticUnread);
|
||||
}
|
||||
|
||||
return () => {
|
||||
const current = state.messages.find((item) => Number(item.uid) === Number(uid) && (item.folder || state.folder) === folder);
|
||||
const currentFlags = normalizedMessageFlags(current?.flags ?? (detailMatches ? state.currentDetail?.flags : []));
|
||||
if (currentFlags.join("\u0000") === optimisticFlags.join("\u0000")) applyLocalMessageFlags(folder, uid, originalFlags);
|
||||
const latestFolder = state.folders.find((item) => item.name === folder);
|
||||
if (Number.isFinite(originalTotal) && Number.isFinite(originalUnread) && Number(latestFolder?.unread_count) === optimisticUnread) {
|
||||
setFolderCounts(folder, originalTotal, originalUnread);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function updateActiveMessageRow() {
|
||||
for (const row of $$(".message-row")) {
|
||||
row.classList.toggle(
|
||||
"active",
|
||||
Boolean(state.selected) && row.dataset.folder === state.selected.folder && Number(row.dataset.uid) === Number(state.selected.uid),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function quickUpdateMessageFlag(message, flag, add, successMessage) {
|
||||
const folder = message.folder || state.folder;
|
||||
const rollback = applyOptimisticMessageFlag(folder, message.uid, flag, add);
|
||||
try {
|
||||
await request(`/messages/${encodeURIComponent(folder)}/${message.uid}/flags`, { method: "PATCH", body: JSON.stringify({ flags: [flag], add }) });
|
||||
const flags = new Set(Array.isArray(message.flags) ? message.flags : String(message.flags || "").split(/[,\s]+/).filter(Boolean));
|
||||
add ? flags.add(flag) : flags.delete(flag);
|
||||
message.flags = [...flags].join(" ");
|
||||
if (state.currentDetail?.uid === message.uid && state.currentDetail?.folder === folder) {
|
||||
const detailFlags = new Set(state.currentDetail.flags || []);
|
||||
add ? detailFlags.add(flag) : detailFlags.delete(flag);
|
||||
state.currentDetail.flags = [...detailFlags];
|
||||
renderMessageDetail(state.currentDetail);
|
||||
const result = await request(`/messages/${encodeURIComponent(folder)}/${message.uid}/flags`, { method: "PATCH", body: JSON.stringify({ flags: [flag], add }) });
|
||||
const flags = Array.isArray(result.flags) ? result.flags : normalizedMessageFlags(message.flags);
|
||||
if (!Array.isArray(result.flags)) {
|
||||
const next = new Set(flags);
|
||||
add ? next.add(flag) : next.delete(flag);
|
||||
applyLocalMessageFlags(folder, message.uid, [...next]);
|
||||
} else {
|
||||
applyLocalMessageFlags(folder, message.uid, flags);
|
||||
}
|
||||
if (Number.isFinite(Number(result.total_count)) && Number.isFinite(Number(result.unread_count))) {
|
||||
setFolderCounts(folder, Number(result.total_count), Number(result.unread_count));
|
||||
}
|
||||
if (flag === "\\Flagged" && state.currentFolderFlaggedOnTop && !state.searchContext && folder === state.folder) {
|
||||
await loadMessages(true, { background: true });
|
||||
}
|
||||
if (flag === "\\Flagged" && state.currentFolderFlaggedOnTop && !state.searchContext && folder === state.folder) await loadMessages(true);
|
||||
else renderMessages();
|
||||
toast(successMessage, "success");
|
||||
} catch (error) { toast(error.message, "error"); }
|
||||
} catch (error) {
|
||||
rollback();
|
||||
toast(error.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
function renderMessages() {
|
||||
$("#folder-title").textContent = state.folder;
|
||||
$("#message-count").textContent = `${state.total} ${state.total === 1 ? "message" : "messages"}`;
|
||||
const folderTotal = Number(state.folders.find((folder) => folder.name === state.folder)?.total_count);
|
||||
const displayTotal = !state.searchContext && Number.isFinite(folderTotal) ? folderTotal : state.total;
|
||||
$("#message-count").textContent = `${displayTotal} ${displayTotal === 1 ? "message" : "messages"}`;
|
||||
const list = $("#message-list");
|
||||
const highlightTerms = activeSearchHighlightTerms();
|
||||
list.classList.toggle("selection-mode", state.selectionMode);
|
||||
@@ -94,6 +194,8 @@ function renderMessages() {
|
||||
const checked = state.selectedMessages.has(messageKey(message));
|
||||
const active = state.selected?.uid === message.uid && state.selected?.folder === folder;
|
||||
const row = el("div", `message-row ${unread ? "unread" : ""} ${flagged ? "flagged" : ""} ${active ? "active" : ""} ${checked ? "selected" : ""}`);
|
||||
row.dataset.folder = folder;
|
||||
row.dataset.uid = String(message.uid);
|
||||
row.tabIndex = 0;
|
||||
row.setAttribute("role", "button");
|
||||
row.setAttribute("aria-label", `${message.from_name || message.from_address || "Unknown"}: ${message.subject || "(no subject)"}`);
|
||||
@@ -122,13 +224,21 @@ function renderMessages() {
|
||||
readButton.title = unread ? "Mark read" : "Mark unread";
|
||||
readButton.setAttribute("aria-label", readButton.title);
|
||||
readButton.innerHTML = svgIcon("mail");
|
||||
readButton.addEventListener("click", (event) => { event.stopPropagation(); quickUpdateMessageFlag(message, "\\Seen", unread, unread ? "Marked read" : "Marked unread"); });
|
||||
readButton.addEventListener("click", (event) => {
|
||||
event.stopPropagation();
|
||||
const currentUnread = !normalizedMessageFlags(message.flags).includes("\\Seen");
|
||||
quickUpdateMessageFlag(message, "\\Seen", currentUnread, currentUnread ? "Marked read" : "Marked unread");
|
||||
});
|
||||
const flagButton = el("button", `message-quick-action message-flag ${flagged ? "active" : ""}`);
|
||||
flagButton.type = "button";
|
||||
flagButton.title = flagged ? "Unflag" : "Flag";
|
||||
flagButton.setAttribute("aria-label", flagButton.title);
|
||||
flagButton.innerHTML = svgIcon("star");
|
||||
flagButton.addEventListener("click", (event) => { event.stopPropagation(); quickUpdateMessageFlag(message, "\\Flagged", !flagged, flagged ? "Flag removed" : "Message flagged"); });
|
||||
flagButton.addEventListener("click", (event) => {
|
||||
event.stopPropagation();
|
||||
const currentFlagged = normalizedMessageFlags(message.flags).includes("\\Flagged");
|
||||
quickUpdateMessageFlag(message, "\\Flagged", !currentFlagged, currentFlagged ? "Flag removed" : "Message flagged");
|
||||
});
|
||||
const fullscreenButton = el("button", "message-quick-action message-fullscreen-action");
|
||||
fullscreenButton.type = "button";
|
||||
fullscreenButton.title = "Open full screen";
|
||||
@@ -281,7 +391,7 @@ async function openMessage(folder, uid) {
|
||||
clearTimeout(state.markReadTimer);
|
||||
state.markReadTimer = null;
|
||||
state.selected = { folder, uid };
|
||||
renderMessages();
|
||||
updateActiveMessageRow();
|
||||
updateMessageNavigation();
|
||||
$("#reader-empty").classList.add("hidden");
|
||||
$("#reader-content").classList.remove("hidden");
|
||||
@@ -311,16 +421,15 @@ function scheduleMarkRead(detail) {
|
||||
const delay = Math.max(0, Number(state.mailPrefs.mark_read_delay_ms || 0));
|
||||
const run = async () => {
|
||||
if (!state.currentDetail || state.currentDetail.uid !== detail.uid || state.currentDetail.folder !== detail.folder) return;
|
||||
const rollback = applyOptimisticMessageFlag(detail.folder, detail.uid, "\\Seen", true);
|
||||
try {
|
||||
await request(`/messages/${encodeURIComponent(detail.folder)}/${detail.uid}/flags`, { method: "PATCH", body: JSON.stringify({ flags: ["\\Seen"], add: true }) });
|
||||
detail.flags = [...new Set([...(detail.flags || []), "\\Seen"])];
|
||||
const item = state.messages.find((msg) => msg.uid === detail.uid && (msg.folder || state.folder) === detail.folder);
|
||||
if (item) {
|
||||
const flags = Array.isArray(item.flags) ? item.flags : String(item.flags || "").split(/[,\s]+/).filter(Boolean);
|
||||
item.flags = [...new Set([...flags, "\\Seen"])].join(" ");
|
||||
const result = await request(`/messages/${encodeURIComponent(detail.folder)}/${detail.uid}/flags`, { method: "PATCH", body: JSON.stringify({ flags: ["\\Seen"], add: true }) });
|
||||
const flags = Array.isArray(result.flags) ? result.flags : [...new Set([...(detail.flags || []), "\\Seen"])];
|
||||
applyLocalMessageFlags(detail.folder, detail.uid, flags);
|
||||
if (Number.isFinite(Number(result.total_count)) && Number.isFinite(Number(result.unread_count))) {
|
||||
setFolderCounts(detail.folder, Number(result.total_count), Number(result.unread_count));
|
||||
}
|
||||
renderMessages();
|
||||
} catch (_) { }
|
||||
} catch (_) { rollback(); }
|
||||
};
|
||||
if (delay === 0) run(); else state.markReadTimer = setTimeout(run, delay);
|
||||
}
|
||||
|
||||
@@ -2,17 +2,30 @@
|
||||
async function updateFlag(flag, add) {
|
||||
if (!state.currentDetail) return;
|
||||
const detail = state.currentDetail;
|
||||
await request(`/messages/${encodeURIComponent(detail.folder)}/${detail.uid}/flags`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ flags: [flag], add }),
|
||||
});
|
||||
const flags = new Set(detail.flags || []);
|
||||
add ? flags.add(flag) : flags.delete(flag);
|
||||
detail.flags = [...flags];
|
||||
const item = state.messages.find((msg) => msg.uid === detail.uid && (msg.folder || state.folder) === detail.folder);
|
||||
if (item) item.flags = detail.flags.join(" ");
|
||||
renderMessages();
|
||||
renderMessageDetail(detail);
|
||||
const rollback = applyOptimisticMessageFlag(detail.folder, detail.uid, flag, add);
|
||||
try {
|
||||
const result = await request(`/messages/${encodeURIComponent(detail.folder)}/${detail.uid}/flags`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ flags: [flag], add }),
|
||||
});
|
||||
const flags = Array.isArray(result.flags) ? result.flags : normalizedMessageFlags(detail.flags);
|
||||
if (!Array.isArray(result.flags)) {
|
||||
const next = new Set(flags);
|
||||
add ? next.add(flag) : next.delete(flag);
|
||||
applyLocalMessageFlags(detail.folder, detail.uid, [...next]);
|
||||
} else {
|
||||
applyLocalMessageFlags(detail.folder, detail.uid, flags);
|
||||
}
|
||||
if (Number.isFinite(Number(result.total_count)) && Number.isFinite(Number(result.unread_count))) {
|
||||
setFolderCounts(detail.folder, Number(result.total_count), Number(result.unread_count));
|
||||
}
|
||||
if (flag === "\\Flagged" && state.currentFolderFlaggedOnTop && !state.searchContext && detail.folder === state.folder) {
|
||||
await loadMessages(true, { background: true });
|
||||
}
|
||||
} catch (error) {
|
||||
rollback();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleUnread() {
|
||||
@@ -231,7 +244,23 @@ async function performBulk(action, targetFolder = null) {
|
||||
if (action === "read" || action === "unread" || action === "flag" || action === "unflag") {
|
||||
const flag = action === "read" || action === "unread" ? "\\Seen" : "\\Flagged";
|
||||
const add = action === "read" || action === "flag";
|
||||
await request(`/messages/${encodeURIComponent(folder)}/${message.uid}/flags`, { method: "PATCH", body: JSON.stringify({ flags: [flag], add }) });
|
||||
const rollback = applyOptimisticMessageFlag(folder, message.uid, flag, add);
|
||||
let result;
|
||||
try {
|
||||
result = await request(`/messages/${encodeURIComponent(folder)}/${message.uid}/flags`, { method: "PATCH", body: JSON.stringify({ flags: [flag], add }) });
|
||||
} catch (error) {
|
||||
rollback();
|
||||
throw error;
|
||||
}
|
||||
const nextFlags = Array.isArray(result?.flags) ? result.flags : (() => {
|
||||
const flags = new Set(normalizedMessageFlags(message.flags));
|
||||
add ? flags.add(flag) : flags.delete(flag);
|
||||
return [...flags];
|
||||
})();
|
||||
applyLocalMessageFlags(folder, message.uid, nextFlags);
|
||||
if (Number.isFinite(Number(result?.total_count)) && Number.isFinite(Number(result?.unread_count))) {
|
||||
setFolderCounts(folder, Number(result.total_count), Number(result.unread_count));
|
||||
}
|
||||
} else if (action === "move" || action === "archive" || action === "junk" || action === "delete") {
|
||||
let destination = targetFolder;
|
||||
if (action === "archive") destination = detectSpecialFolder("archive");
|
||||
@@ -252,8 +281,17 @@ async function performBulk(action, targetFolder = null) {
|
||||
}
|
||||
state.selectedMessages.clear();
|
||||
state.searchAllResults = null;
|
||||
await refreshFolderListOnly();
|
||||
await refreshCurrentMessageView();
|
||||
const flagsOnly = ["read", "unread", "flag", "unflag"].includes(action);
|
||||
const orderingChanged = ["flag", "unflag"].includes(action) && state.currentFolderFlaggedOnTop && !state.searchContext;
|
||||
if (!flagsOnly) {
|
||||
await refreshFolderListOnly({ forceServer: true });
|
||||
await refreshCurrentMessageView();
|
||||
} else if (state.searchContext || orderingChanged) {
|
||||
await refreshCurrentMessageView();
|
||||
} else {
|
||||
renderBulkToolbar();
|
||||
updateUnreadAppBadge();
|
||||
}
|
||||
const bulkMessages = { junk: bulkJunkToInbox ? tr("marked_not_spam", "Marked as not spam") : tr("marked_spam", "Marked as spam"), archive: "Archived", read: "Marked read", unread: "Marked unread", flag: "Flagged", unflag: "Unflagged", delete: "Deleted", move: "Moved", copy: "Copied" };
|
||||
const undoAction = movedOperations > 0 && undoMoves.length === movedOperations ? bulkMoveUndoAction(undoMoves) : null;
|
||||
toast(`${bulkMessages[action] || "Updated"}: ${completed} message${completed === 1 ? "" : "s"}`, "success", undoAction ? 10000 : null, undoAction);
|
||||
@@ -264,12 +302,28 @@ async function performBulk(action, targetFolder = null) {
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshFolderListOnly() {
|
||||
async function refreshFolderListOnly(options = {}) {
|
||||
try {
|
||||
const data = await request("/folders", { method: "GET" });
|
||||
state.folders = data.folders || [];
|
||||
const suffix = options.forceServer ? "?refresh=true" : "";
|
||||
const data = await request(`/folders${suffix}`, { method: "GET" });
|
||||
const nextFolders = data.folders || [];
|
||||
setImapSyncInventoryPending(data.pending_sync_count);
|
||||
setIndexingIndicator(state.indexingActive, data.pending_index_count);
|
||||
const previousNames = state.folders.map((folder) => folder.name).join("\u0000");
|
||||
const nextNames = nextFolders.map((folder) => folder.name).join("\u0000");
|
||||
if (previousNames !== nextNames || state.folders.length !== nextFolders.length) {
|
||||
state.folders = nextFolders;
|
||||
renderFolders();
|
||||
} else {
|
||||
const byName = new Map(nextFolders.map((folder) => [folder.name, folder]));
|
||||
for (const folder of state.folders) {
|
||||
const fresh = byName.get(folder.name);
|
||||
if (!fresh) continue;
|
||||
Object.assign(folder, fresh);
|
||||
updateFolderCounter(folder);
|
||||
}
|
||||
}
|
||||
updateUnreadAppBadge();
|
||||
renderFolders();
|
||||
} catch (_) { }
|
||||
}
|
||||
|
||||
@@ -621,9 +675,22 @@ function showFolderProperties(folder) {
|
||||
|
||||
async function markFolderAllRead(name) {
|
||||
try {
|
||||
await request(`/folders/${encodeURIComponent(name)}/mark-all-read`, { method: "POST", body: "{}" });
|
||||
if (state.folder === name) await loadMessages(true);
|
||||
await refreshFolderListOnly();
|
||||
const result = await request(`/folders/${encodeURIComponent(name)}/mark-all-read`, { method: "POST", body: "{}" });
|
||||
if (state.folder === name) {
|
||||
const throughUid = Number(result?.through_uid);
|
||||
for (const message of state.messages) {
|
||||
if (Number.isFinite(throughUid) && Number(message.uid) > throughUid) continue;
|
||||
const flags = normalizedMessageFlags(message.flags);
|
||||
if (!flags.includes("\\Seen")) flags.push("\\Seen");
|
||||
applyLocalMessageFlags(name, message.uid, flags);
|
||||
}
|
||||
}
|
||||
const folder = state.folders.find((item) => item.name === name);
|
||||
setFolderCounts(
|
||||
name,
|
||||
Number.isFinite(Number(result?.total_count)) ? Number(result.total_count) : Number(folder?.total_count || 0),
|
||||
Number.isFinite(Number(result?.unread_count)) ? Number(result.unread_count) : 0,
|
||||
);
|
||||
toast("All messages marked read");
|
||||
} catch (error) { toast(error.message, "error"); }
|
||||
}
|
||||
|
||||
@@ -160,17 +160,48 @@ function playNotificationSound() {
|
||||
} catch (_) { }
|
||||
}
|
||||
|
||||
function setIndexingIndicator(active, pending = 0) {
|
||||
state.indexingActive = Boolean(active);
|
||||
function renderSyncActivityIndicator() {
|
||||
const indicator = $("#indexing-indicator");
|
||||
if (!indicator) return;
|
||||
indicator.classList.toggle("hidden", !state.indexingActive);
|
||||
const count = Math.max(0, Number(pending) || 0);
|
||||
const label = count > 0 ? `Indexing messages (${count} pending)` : "Indexing messages";
|
||||
const busy = Boolean(state.indexingActive || state.imapSyncActive);
|
||||
indicator.classList.toggle("hidden", !busy);
|
||||
indicator.classList.toggle("active", busy);
|
||||
const parts = [];
|
||||
if (state.imapSyncActive || state.imapSyncPending > 0) {
|
||||
const folder = state.imapSyncFolder ? ` · ${state.imapSyncFolder}` : "";
|
||||
const pending = state.imapSyncPending > 0 ? ` · ${state.imapSyncPending} headers pending in task` : "";
|
||||
parts.push(`IMAP synchronization${folder}${pending}`);
|
||||
}
|
||||
if (state.imapSyncInventoryPending > 0) {
|
||||
parts.push(`IMAP cache incomplete · ${state.imapSyncInventoryPending} message headers not cached yet`);
|
||||
}
|
||||
if (state.indexingActive || state.indexingPending > 0) {
|
||||
const pending = state.indexingPending > 0 ? ` · ${state.indexingPending} messages pending` : "";
|
||||
parts.push(`Search indexing${pending}`);
|
||||
}
|
||||
const label = parts.join(" | ") || "Mail synchronization";
|
||||
indicator.title = label;
|
||||
indicator.setAttribute("aria-label", label);
|
||||
}
|
||||
|
||||
function setIndexingIndicator(active, pending = 0) {
|
||||
state.indexingActive = Boolean(active);
|
||||
state.indexingPending = Math.max(0, Number(pending) || 0);
|
||||
renderSyncActivityIndicator();
|
||||
}
|
||||
|
||||
function setImapSyncIndicator(active, folder = null, pending = 0) {
|
||||
state.imapSyncActive = Boolean(active);
|
||||
state.imapSyncFolder = folder || null;
|
||||
state.imapSyncPending = Math.max(0, Number(pending) || 0);
|
||||
renderSyncActivityIndicator();
|
||||
}
|
||||
|
||||
function setImapSyncInventoryPending(pending = 0) {
|
||||
state.imapSyncInventoryPending = Math.max(0, Number(pending) || 0);
|
||||
renderSyncActivityIndicator();
|
||||
}
|
||||
|
||||
function syncRealtimeWatchedFolder(force = false) {
|
||||
const socket = state.ws;
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN || !state.accountId || !state.folder) return;
|
||||
@@ -296,23 +327,62 @@ function realtimeEventKey(payload) {
|
||||
function isDuplicateRealtimeEvent(payload) {
|
||||
const now = Date.now();
|
||||
for (const [key, seenAt] of state.realtimeSeenEvents) if (now - seenAt > 10000) state.realtimeSeenEvents.delete(key);
|
||||
|
||||
// Server websocket envelopes carry a unique eventId. When present it is the
|
||||
// only safe deduplication key: state can legitimately cycle A -> B -> A in
|
||||
// under a second (for example unread 1 -> 0 -> 1). Fingerprint-based
|
||||
// deduplication would incorrectly drop the second A and leave stale UI.
|
||||
if (payload.eventId) {
|
||||
const idKey = `id:${payload.eventId}`;
|
||||
const duplicate = state.realtimeSeenEvents.has(idKey);
|
||||
state.realtimeSeenEvents.set(idKey, now);
|
||||
return duplicate;
|
||||
}
|
||||
|
||||
// Fallback for legacy/nonstandard envelopes without eventId.
|
||||
const event = payload.event || {};
|
||||
const fingerprint = `${payload.accountId || ""}|${event.type || ""}|${JSON.stringify(event.data || {})}`;
|
||||
const idKey = payload.eventId ? `id:${payload.eventId}` : null;
|
||||
const fingerprintKey = `event:${fingerprint}`;
|
||||
const duplicate = Boolean((idKey && state.realtimeSeenEvents.has(idKey)) || (state.realtimeSeenEvents.get(fingerprintKey) && now - state.realtimeSeenEvents.get(fingerprintKey) < 1500));
|
||||
if (idKey) state.realtimeSeenEvents.set(idKey, now);
|
||||
const seenAt = state.realtimeSeenEvents.get(fingerprintKey);
|
||||
const duplicate = Boolean(seenAt && now - seenAt < 1500);
|
||||
state.realtimeSeenEvents.set(fingerprintKey, now);
|
||||
return duplicate;
|
||||
}
|
||||
|
||||
function applyRealtimeFolderStatus(data) {
|
||||
if (!data?.folder) return;
|
||||
setFolderCounts(data.folder, Number(data.total_count), Number(data.unread_count));
|
||||
}
|
||||
|
||||
function applyRealtimeFlagChanges(data) {
|
||||
if (!data?.folder) return false;
|
||||
const changes = Array.isArray(data.changes) ? data.changes : [];
|
||||
for (const change of changes) {
|
||||
if (!Number.isFinite(Number(change?.uid))) continue;
|
||||
applyLocalMessageFlags(data.folder, Number(change.uid), change.flags || []);
|
||||
}
|
||||
return changes.length > 0;
|
||||
}
|
||||
|
||||
function applyRealtimeFolderAllRead(data) {
|
||||
const folder = data?.folder;
|
||||
if (!folder || folder !== state.folder) return;
|
||||
const throughUid = Number(data?.through_uid);
|
||||
for (const message of state.messages) {
|
||||
if (Number.isFinite(throughUid) && Number(message.uid) > throughUid) continue;
|
||||
const flags = normalizedMessageFlags(message.flags);
|
||||
if (!flags.includes("\\Seen")) flags.push("\\Seen");
|
||||
applyLocalMessageFlags(folder, message.uid, flags);
|
||||
}
|
||||
}
|
||||
|
||||
async function resyncRealtimeState() {
|
||||
if (!state.accountId) return;
|
||||
if (state.realtimeSyncPromise) return state.realtimeSyncPromise;
|
||||
state.realtimeSyncPromise = (async () => {
|
||||
await refreshFolderListOnly();
|
||||
await refreshFolderListOnly({ forceServer: true });
|
||||
if (state.searchContext) await runSearch(new URLSearchParams(state.searchContext.params), state.searchContext.label, { restoreScrollTop: state.searchContext.scrollTop });
|
||||
else await loadMessages(true);
|
||||
else await loadMessages(true, { background: true });
|
||||
})().catch(() => { }).finally(() => { state.realtimeSyncPromise = null; });
|
||||
return state.realtimeSyncPromise;
|
||||
}
|
||||
@@ -323,13 +393,16 @@ function scheduleRealtimeRefresh(event, accountId) {
|
||||
clearTimeout(state.realtimeRefreshTimer);
|
||||
state.realtimeRefreshTimer = setTimeout(async () => {
|
||||
const events = state.realtimeRefreshEvents.splice(0);
|
||||
const refreshCurrent = events.some((item) => item.type === "FolderUpdated" || (["NewMessages", "FlagsChanged"].includes(item.type) && item.data?.folder === state.folder));
|
||||
const tasks = [refreshFolderListOnly()];
|
||||
if (refreshCurrent) {
|
||||
const folderTreeChanged = events.some((item) => item.type === "FolderUpdated");
|
||||
const newInCurrent = events.some((item) => item.type === "NewMessages" && item.data?.folder === state.folder);
|
||||
const structuralFlagsInCurrent = events.some((item) => item.type === "FlagsChanged" && item.data?.folder === state.folder && !(item.data?.changes || []).length);
|
||||
const tasks = [];
|
||||
if (folderTreeChanged) tasks.push(refreshFolderListOnly());
|
||||
if (folderTreeChanged || newInCurrent || structuralFlagsInCurrent) {
|
||||
if (state.searchContext) tasks.push(runSearch(new URLSearchParams(state.searchContext.params), state.searchContext.label, { restoreScrollTop: state.searchContext.scrollTop }));
|
||||
else tasks.push(loadMessages(true));
|
||||
else tasks.push(loadMessages(true, { background: true }));
|
||||
}
|
||||
await Promise.all(tasks);
|
||||
if (tasks.length) await Promise.all(tasks);
|
||||
}, 80);
|
||||
}
|
||||
|
||||
@@ -402,10 +475,29 @@ function connectRealtime() {
|
||||
notifyNewMail(data, payload.accountId);
|
||||
}
|
||||
}
|
||||
if (event.type === "IndexingStatus" && payload.accountId === state.accountId) {
|
||||
setIndexingIndicator(Boolean(event.data?.active), event.data?.pending);
|
||||
if (payload.accountId === state.accountId) {
|
||||
if (event.type === "FolderStatus") applyRealtimeFolderStatus(event.data || {});
|
||||
if (event.type === "FlagsChanged") {
|
||||
const patched = applyRealtimeFlagChanges(event.data || {});
|
||||
if (!patched) scheduleRealtimeRefresh(event, payload.accountId);
|
||||
}
|
||||
if (event.type === "FolderAllRead") applyRealtimeFolderAllRead(event.data || {});
|
||||
if (event.type === "IndexingStatus") setIndexingIndicator(Boolean(event.data?.active), event.data?.pending);
|
||||
if (event.type === "SyncStatus") {
|
||||
const wasActive = state.imapSyncActive;
|
||||
const syncFolder = event.data?.folder || null;
|
||||
const active = Boolean(event.data?.active);
|
||||
setImapSyncIndicator(active, syncFolder, event.data?.pending);
|
||||
const folderState = state.folders.find((folder) => folder.name === syncFolder);
|
||||
const serverTotal = Number(folderState?.total_count);
|
||||
const syncCompletedForCurrentFolder = wasActive && !active && syncFolder === state.folder;
|
||||
if (syncCompletedForCurrentFolder && !state.searchContext && Number.isFinite(serverTotal) && state.total < serverTotal) {
|
||||
void loadMessages(true, { background: true });
|
||||
}
|
||||
}
|
||||
if (event.type === "SyncInventory") setImapSyncInventoryPending(event.data?.pending);
|
||||
}
|
||||
if (["NewMessages", "FlagsChanged", "FolderUpdated"].includes(event.type)) scheduleRealtimeRefresh(event, payload.accountId);
|
||||
if (["NewMessages", "FolderUpdated"].includes(event.type)) scheduleRealtimeRefresh(event, payload.accountId);
|
||||
} catch (error) { console.warn("Invalid realtime event", error); }
|
||||
};
|
||||
socket.onclose = () => {
|
||||
@@ -413,6 +505,7 @@ function connectRealtime() {
|
||||
state.ws = null;
|
||||
state.realtimeWatchedFolder = null;
|
||||
setIndexingIndicator(false);
|
||||
setImapSyncIndicator(false, null, 0);
|
||||
clearInterval(state.wsWatchdogTimer);
|
||||
state.wsWatchdogTimer = null;
|
||||
scheduleRealtimeReconnect();
|
||||
@@ -435,6 +528,7 @@ function disconnectRealtime() {
|
||||
state.wsLastHeartbeatAt = 0;
|
||||
state.realtimeWatchedFolder = null;
|
||||
setIndexingIndicator(false);
|
||||
setImapSyncIndicator(false, null, 0);
|
||||
const socket = state.ws;
|
||||
state.ws = null;
|
||||
if (socket && socket.readyState < WebSocket.CLOSING) socket.close(1000, "Client disconnect");
|
||||
@@ -516,7 +610,8 @@ async function loadDiagnostics() {
|
||||
try {
|
||||
const info = await request("/diagnostics/search", { method: "GET" });
|
||||
searchStatus.textContent = "Ready";
|
||||
$("#diagnostic-search-summary").textContent = `${info.indexedDocuments} indexed · ${info.cachedMessages} cached messages · ${info.cachedBodies} cached bodies${info.excludedMessages ? ` · ${info.excludedMessages} excluded` : ""}`;
|
||||
const bodyCacheState = info.deepIndexEnabled ? "body cache enabled" : "body cache disabled";
|
||||
$("#diagnostic-search-summary").textContent = `${info.indexedDocuments} indexed · ${info.cachedMessages} cached messages · ${info.cachedBodies} cached bodies · ${bodyCacheState}${info.excludedMessages ? ` · ${info.excludedMessages} excluded` : ""}`;
|
||||
} catch (error) {
|
||||
searchStatus.textContent = "Unavailable";
|
||||
$("#diagnostic-search-summary").textContent = error.message;
|
||||
@@ -542,6 +637,7 @@ async function rebuildSearchIndex() {
|
||||
const button = $("#diagnostic-search-rebuild");
|
||||
if (!await appConfirm("Rebuild the search index from the local message cache?", { title: "Rebuild search index", confirmLabel: "Rebuild" })) return;
|
||||
button.disabled = true;
|
||||
setIndexingIndicator(true, 0);
|
||||
try {
|
||||
const result = await request("/diagnostics/search/rebuild", { method: "POST", body: "{}" });
|
||||
toast(`Search index rebuilt: ${result.indexedDocuments} messages`);
|
||||
@@ -549,6 +645,7 @@ async function rebuildSearchIndex() {
|
||||
} catch (error) {
|
||||
toast(error.message, "error");
|
||||
} finally {
|
||||
setIndexingIndicator(false, 0);
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
@@ -237,7 +237,7 @@ function bindEvents() {
|
||||
const button = $("#refresh-btn");
|
||||
setBusy(button, true);
|
||||
try {
|
||||
const folderRefresh = refreshFolderListOnly();
|
||||
const folderRefresh = refreshFolderListOnly({ forceServer: true });
|
||||
if (state.searchContext) {
|
||||
await Promise.all([folderRefresh, runSearch(new URLSearchParams(state.searchContext.params), state.searchContext.label, { restoreScrollTop: state.searchContext.scrollTop })]);
|
||||
} else {
|
||||
@@ -266,7 +266,7 @@ function bindEvents() {
|
||||
disconnectRealtime();
|
||||
connectRealtime();
|
||||
if (state.accounts.length) {
|
||||
await refreshFolderListOnly();
|
||||
await refreshFolderListOnly({ forceServer: true });
|
||||
if (state.searchContext) await runSearch(new URLSearchParams(state.searchContext.params), state.searchContext.label, { restoreScrollTop: state.searchContext.scrollTop });
|
||||
else await loadMessages(true);
|
||||
}
|
||||
@@ -559,7 +559,7 @@ async function boot() {
|
||||
await loadAuthPolicy();
|
||||
const authenticated = await loadAccounts();
|
||||
if (authenticated) {
|
||||
await loadPreferences(); await loadMailPreferences(); if (state.authPolicy.pgpEnabled !== false) await loadOpenPgp(); else setOpenPgpAvailability(false); await loadIdentities(); await loadResponseTemplates(); await loadFolders(true); await loadSpecialFolders(); await loadQuota(); await loadTags(); await loadNotificationPreferences(); await loadTrustedSenders(); startCalendarReminderWatcher(); connectRealtime();
|
||||
await loadSessionPolicy(); await loadPreferences(); await loadMailPreferences(); if (state.authPolicy.pgpEnabled !== false) await loadOpenPgp(); else setOpenPgpAvailability(false); await loadIdentities(); await loadResponseTemplates(); await loadFolders(true); await loadSpecialFolders(); await loadQuota(); await loadTags(); await loadNotificationPreferences(); await loadTrustedSenders(); startCalendarReminderWatcher(); connectRealtime();
|
||||
const params = new URLSearchParams(location.search);
|
||||
const folder = params.get("mail_folder");
|
||||
const uid = Number(params.get("mail_uid"));
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
(() => {
|
||||
const root = document.documentElement;
|
||||
const configured = root.dataset.defaultTheme || "system";
|
||||
root.dataset.theme = configured === "system"
|
||||
? (matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark")
|
||||
: configured;
|
||||
root.dataset.interfaceSkin = root.dataset.defaultInterfaceSkin || "rustbox";
|
||||
})();
|
||||
Reference in new issue
Block a user