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

This commit is contained in:
Mateusz Gruszczyński committed 2026-10-30 17:22:17 +01:00
1 parent 0421994fe6
commit 425ea7449b
138 files changed
+5705 -1012

No files matched your search

+71 -34
View File
@@ -10,7 +10,8 @@ use super::session::SessionStore;
pub const BROWSER_COOKIE: &str = "rustbox_browser";
pub const ACTIVE_ACCOUNT_HEADER: &str = "X-Active-Account";
const UNAUTHORIZED_BODY: &str = r#"{"error":{"code":"UNAUTHORIZED","message":"Invalid or expired session","status":401}}"#;
const UNAUTHORIZED_BODY: &str =
r#"{"error":{"code":"UNAUTHORIZED","message":"Invalid or expired session","status":401}}"#;
fn unauthorized() -> Response {
(
@@ -117,17 +118,21 @@ pub async fn auth_guard(mut req: Request, next: Next) -> Response {
None => return unauthorized(),
};
let account_id = match extract_active_account_header(&req).or_else(|| extract_active_account_query(&req)) {
Some(a) => a,
None => return unauthorized(),
};
let account_id =
match extract_active_account_header(&req).or_else(|| extract_active_account_query(&req)) {
Some(a) => a,
None => return unauthorized(),
};
let token = match extract_session_cookie(&req, &account_id) {
Some(t) => t,
None => return account_expired(&account_id),
};
let session = match store.get_account_session(&browser_id, &account_id, &token) {
let session = match store
.get_account_session_shared(&browser_id, &account_id, &token)
.await
{
Some(s) => s,
None => return account_expired(&account_id),
};
@@ -157,7 +162,9 @@ mod tests {
fn guarded_router(store: Arc<SessionStore>) -> Router {
let handler = |Extension(session): Extension<AccountSession>| async move {
serde_json::json!({ "email": session.email }).to_string().into_response()
serde_json::json!({ "email": session.email })
.to_string()
.into_response()
};
Router::new()
@@ -166,10 +173,7 @@ mod tests {
.layer(Extension(store))
}
async fn send(
router: Router,
req: Request<Body>,
) -> (StatusCode, serde_json::Value) {
async fn send(router: Router, req: Request<Body>) -> (StatusCode, serde_json::Value) {
let resp = router.oneshot(req).await.expect("request should succeed");
let status = resp.status();
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
@@ -184,7 +188,12 @@ mod tests {
token: String,
}
fn create_test_session(store: &SessionStore, email: &str, password: &str, user_hash: &str) -> TestSession {
fn create_test_session(
store: &SessionStore,
email: &str,
password: &str,
user_hash: &str,
) -> TestSession {
let browser_id = store.create_browser();
let (token, account_id) = store.add_account_to_browser(
&browser_id,
@@ -198,7 +207,11 @@ mod tests {
587,
true,
);
TestSession { browser_id, account_id, token }
TestSession {
browser_id,
account_id,
token,
}
}
fn build_auth_headers(session: &TestSession) -> String {
@@ -247,7 +260,10 @@ mod tests {
let req = Request::builder()
.uri("/protected")
.header(ACTIVE_ACCOUNT_HEADER, &session.account_id)
.header("cookie", format!("rustbox_session_{}={}", session.account_id, session.token))
.header(
"cookie",
format!("rustbox_session_{}={}", session.account_id, session.token),
)
.body(Body::empty())
.unwrap();
@@ -304,10 +320,13 @@ mod tests {
let req = Request::builder()
.uri("/protected")
.header(ACTIVE_ACCOUNT_HEADER, &session.account_id)
.header("cookie", format!(
"rustbox_browser={}; rustbox_session_{}=invalid-token",
session.browser_id, session.account_id
))
.header(
"cookie",
format!(
"rustbox_browser={}; rustbox_session_{}=invalid-token",
session.browser_id, session.account_id
),
)
.body(Body::empty())
.unwrap();
@@ -347,10 +366,13 @@ mod tests {
let req = Request::builder()
.uri("/protected")
.header(ACTIVE_ACCOUNT_HEADER, &session.account_id)
.header("cookie", format!(
"rustbox_browser={}; rustbox_session_{}={}",
wrong_browser_id, session.account_id, session.token
))
.header(
"cookie",
format!(
"rustbox_browser={}; rustbox_session_{}={}",
wrong_browser_id, session.account_id, session.token
),
)
.body(Body::empty())
.unwrap();
@@ -364,7 +386,7 @@ mod tests {
#[tokio::test]
async fn expired_session_returns_account_expired() {
let store = Arc::new(SessionStore::new(Duration::from_millis(50)));
let session = create_test_session(&store.as_ref(), "bob@example.com", "pass", "hash");
let session = create_test_session(store.as_ref(), "bob@example.com", "pass", "hash");
thread::sleep(Duration::from_millis(100));
@@ -418,10 +440,13 @@ mod tests {
let req = Request::builder()
.uri("/protected")
.header(ACTIVE_ACCOUNT_HEADER, &session.account_id)
.header("cookie", format!(
"other_browser={}; rustbox_session_{}={}",
session.browser_id, session.account_id, session.token
))
.header(
"cookie",
format!(
"other_browser={}; rustbox_session_{}={}",
session.browser_id, session.account_id, session.token
),
)
.body(Body::empty())
.unwrap();
@@ -472,7 +497,10 @@ mod tests {
.body(Body::empty())
.unwrap();
assert_eq!(extract_active_account_header(&req), Some("account-123".to_string()));
assert_eq!(
extract_active_account_header(&req),
Some("account-123".to_string())
);
}
#[tokio::test]
@@ -492,7 +520,10 @@ mod tests {
.body(Body::empty())
.unwrap();
assert_eq!(extract_active_account_query(&req), Some("account-123".to_string()));
assert_eq!(
extract_active_account_query(&req),
Some("account-123".to_string())
);
}
#[tokio::test]
@@ -502,7 +533,10 @@ mod tests {
.body(Body::empty())
.unwrap();
assert_eq!(extract_active_account_query(&req), Some("abc@example.com".to_string()));
assert_eq!(
extract_active_account_query(&req),
Some("abc@example.com".to_string())
);
}
#[tokio::test]
@@ -543,10 +577,13 @@ mod tests {
let req = Request::builder()
.uri(format!("/protected?account_id={}", session2.account_id))
.header(ACTIVE_ACCOUNT_HEADER, &session1.account_id)
.header("cookie", format!(
"rustbox_browser={}; rustbox_session_{}={}",
session1.browser_id, session1.account_id, session1.token
))
.header(
"cookie",
format!(
"rustbox_browser={}; rustbox_session_{}={}",
session1.browser_id, session1.account_id, session1.token
),
)
.body(Body::empty())
.unwrap();
+371 -38
View File
@@ -6,12 +6,29 @@ use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use dashmap::DashMap;
use rand::RngCore;
use crate::session_store::crypto::SessionCrypto;
use crate::session_store::{PersistedSession, SessionPersistence};
pub type SessionId = String;
pub type BrowserId = String;
pub type AccountId = String;
pub type SessionState = AccountSession;
#[derive(Debug, Clone)]
pub struct NewAccountSession {
pub email: String,
pub password: String,
pub user_hash: String,
pub imap_host: String,
pub imap_port: u16,
pub imap_tls: bool,
pub smtp_host: String,
pub smtp_port: u16,
pub smtp_tls: bool,
pub timeout_override: Option<Duration>,
}
#[derive(Debug, Clone)]
pub struct AccountSession {
pub account_id: AccountId,
@@ -37,7 +54,7 @@ pub struct AccountSession {
pub user_agent: String,
}
#[derive(Debug, Clone)]
#[derive(Clone)]
pub struct SessionStore {
sessions: Arc<DashMap<SessionId, AccountSession>>,
browsers: Arc<DashMap<BrowserId, Vec<AccountId>>>,
@@ -45,6 +62,8 @@ pub struct SessionStore {
account_to_browser: Arc<DashMap<AccountId, BrowserId>>,
timeout: Duration,
tls_verify_override: Option<bool>,
persistence: Option<Arc<dyn SessionPersistence>>,
crypto: Option<SessionCrypto>,
}
impl SessionStore {
@@ -55,10 +74,12 @@ impl SessionStore {
.as_secs()
}
#[cfg(test)]
pub fn new(timeout: Duration) -> Self {
Self::new_with_tls_verify(timeout, None)
}
#[cfg(test)]
pub fn new_with_tls_verify(timeout: Duration, tls_verify_override: Option<bool>) -> Self {
Self {
sessions: Arc::new(DashMap::new()),
@@ -67,6 +88,26 @@ impl SessionStore {
account_to_browser: Arc::new(DashMap::new()),
timeout,
tls_verify_override,
persistence: None,
crypto: None,
}
}
pub fn new_persistent(
timeout: Duration,
tls_verify_override: Option<bool>,
persistence: Arc<dyn SessionPersistence>,
crypto: SessionCrypto,
) -> Self {
Self {
sessions: Arc::new(DashMap::new()),
browsers: Arc::new(DashMap::new()),
account_to_session: Arc::new(DashMap::new()),
account_to_browser: Arc::new(DashMap::new()),
timeout,
tls_verify_override,
persistence: Some(persistence),
crypto: Some(crypto),
}
}
@@ -99,6 +140,7 @@ impl SessionStore {
browser_id
}
#[cfg(test)]
#[allow(clippy::too_many_arguments)]
pub fn add_account_to_browser(
&self,
@@ -270,43 +312,6 @@ impl SessionStore {
result
}
pub fn remove_user_session(&self, user_hash: &str, account_id: &str) -> bool {
let Some(session_id) = self
.account_to_session
.get(account_id)
.map(|entry| entry.clone())
else {
return false;
};
let belongs_to_user = self
.sessions
.get(&session_id)
.is_some_and(|session| session.user_hash == user_hash);
if !belongs_to_user {
return false;
}
self.remove_account(account_id)
}
pub fn remove_other_user_sessions(&self, user_hash: &str, current_account_id: &str) -> usize {
let account_ids: Vec<String> = self
.sessions
.iter()
.filter(|entry| {
entry.value().user_hash == user_hash
&& entry.value().account_id != current_account_id
})
.map(|entry| entry.value().account_id.clone())
.collect();
let mut removed = 0;
for account_id in account_ids {
if self.remove_user_session(user_hash, &account_id) {
removed += 1;
}
}
removed
}
pub fn remove_browser(&self, browser_id: &str) {
if let Some((_, account_ids)) = self.browsers.remove(browser_id) {
for account_id in account_ids {
@@ -474,6 +479,282 @@ impl SessionStore {
pub fn is_empty(&self) -> bool {
self.sessions.is_empty()
}
fn cache_persisted(&self, record: &PersistedSession) -> Result<AccountSession, String> {
let crypto = self
.crypto
.as_ref()
.ok_or("session crypto is not configured")?;
let password = crypto.decrypt(&record.password_ciphertext)?;
let age = Self::unix_now().saturating_sub(record.last_accessed_unix);
let now = Instant::now();
let last_accessed = now.checked_sub(Duration::from_secs(age)).unwrap_or(now);
let timeout_override = if record.timeout_secs == self.timeout.as_secs() {
None
} else {
Some(Duration::from_secs(record.timeout_secs))
};
let session = AccountSession {
account_id: record.account_id.clone(),
email: record.email.clone(),
password,
user_hash: record.user_hash.clone(),
imap_host: record.imap_host.clone(),
imap_port: record.imap_port,
imap_tls: record.imap_tls,
imap_tls_verify: record.imap_tls_verify,
smtp_host: record.smtp_host.clone(),
smtp_port: record.smtp_port,
smtp_tls: record.smtp_tls,
smtp_tls_verify: record.smtp_tls_verify,
last_accessed,
timeout_override,
created_at_unix: record.created_at_unix,
last_accessed_unix: record.last_accessed_unix,
user_agent: record.user_agent.clone(),
};
self.sessions.insert(record.token.clone(), session.clone());
self.account_to_session
.insert(record.account_id.clone(), record.token.clone());
self.account_to_browser
.insert(record.account_id.clone(), record.browser_id.clone());
let mut entry = self.browsers.entry(record.browser_id.clone()).or_default();
if !entry.contains(&record.account_id) {
entry.push(record.account_id.clone());
}
Ok(session)
}
fn persisted_from_memory(
&self,
token: &str,
browser_id: &str,
session: &AccountSession,
) -> Result<PersistedSession, String> {
let crypto = self
.crypto
.as_ref()
.ok_or("session crypto is not configured")?;
Ok(PersistedSession {
token: token.to_string(),
account_id: session.account_id.clone(),
browser_id: browser_id.to_string(),
email: session.email.clone(),
password_ciphertext: crypto.encrypt(&session.password)?,
user_hash: session.user_hash.clone(),
imap_host: session.imap_host.clone(),
imap_port: session.imap_port,
imap_tls: session.imap_tls,
imap_tls_verify: session.imap_tls_verify,
smtp_host: session.smtp_host.clone(),
smtp_port: session.smtp_port,
smtp_tls: session.smtp_tls,
smtp_tls_verify: session.smtp_tls_verify,
timeout_secs: session.timeout_override.unwrap_or(self.timeout).as_secs(),
created_at_unix: session.created_at_unix,
last_accessed_unix: session.last_accessed_unix,
user_agent: session.user_agent.clone(),
})
}
async fn persist_account(&self, browser_id: &str, account_id: &str) {
let (Some(persistence), Some(token)) = (
self.persistence.as_ref(),
self.account_to_session.get(account_id).map(|v| v.clone()),
) else {
return;
};
let Some(session) = self.sessions.get(&token).map(|v| v.clone()) else {
return;
};
match self.persisted_from_memory(&token, browser_id, &session) {
Ok(record) => {
if let Err(error) = persistence.save(&record).await {
tracing::error!(%error, "failed to persist session");
}
}
Err(error) => tracing::error!(%error, "failed to encrypt session"),
}
}
pub async fn add_account_to_browser_shared(
&self,
browser_id: &str,
input: NewAccountSession,
) -> (SessionId, AccountId) {
let result = self.add_account_to_browser_with_timeout(
browser_id,
input.email,
input.password,
input.user_hash,
input.imap_host,
input.imap_port,
input.imap_tls,
input.smtp_host,
input.smtp_port,
input.smtp_tls,
input.timeout_override,
);
self.persist_account(browser_id, &result.1).await;
result
}
pub async fn get_browser_accounts_shared(&self, browser_id: &str) -> Vec<AccountSession> {
let Some(persistence) = self.persistence.as_ref() else {
return self.get_browser_accounts(browser_id);
};
match persistence.load_browser(browser_id).await {
Ok(records) => records
.into_iter()
.filter(|r| !r.expired_at(Self::unix_now()))
.filter_map(|r| {
self.cache_persisted(&r)
.map_err(|e| tracing::error!(error=%e, "failed to restore session"))
.ok()
})
.collect(),
Err(error) => {
tracing::error!(%error, "failed to load browser sessions");
self.get_browser_accounts(browser_id)
}
}
}
pub async fn browser_exists_shared(&self, browser_id: &str) -> bool {
let Some(persistence) = self.persistence.as_ref() else {
return self.browser_exists(browser_id);
};
persistence
.browser_exists(browser_id)
.await
.unwrap_or_else(|error| {
tracing::error!(%error, "failed to query browser session");
false
})
}
pub async fn get_shared(&self, token: &str) -> Option<AccountSession> {
let Some(persistence) = self.persistence.as_ref() else {
return self.get(token);
};
let record = persistence.load_token(token).await.ok().flatten()?;
if record.expired_at(Self::unix_now()) {
let _ = persistence.delete_account(&record.account_id).await;
return None;
}
self.cache_persisted(&record).ok()?;
let refreshed = self.get(token)?;
self.persist_account(&record.browser_id, &record.account_id)
.await;
Some(refreshed)
}
pub async fn get_account_session_shared(
&self,
browser_id: &str,
account_id: &str,
token: &str,
) -> Option<AccountSession> {
let Some(persistence) = self.persistence.as_ref() else {
return self.get_account_session(browser_id, account_id, token);
};
let record = persistence.load_token(token).await.ok().flatten()?;
if record.browser_id != browser_id
|| record.account_id != account_id
|| record.expired_at(Self::unix_now())
{
return None;
}
self.cache_persisted(&record).ok()?;
let session = self.get(token)?;
self.persist_account(browser_id, account_id).await;
Some(session)
}
pub async fn set_user_agent_shared(&self, account_id: &str, user_agent: String) {
self.set_user_agent(account_id, user_agent);
if let Some(browser_id) = self.account_to_browser.get(account_id).map(|v| v.clone()) {
self.persist_account(&browser_id, account_id).await;
}
}
pub async fn remove_account_shared(&self, account_id: &str) -> bool {
let local = self.remove_account(account_id);
let Some(persistence) = self.persistence.as_ref() else {
return local;
};
match persistence.delete_account(account_id).await {
Ok(remote) => local || remote,
Err(error) => {
tracing::error!(%error, "failed to delete persisted session");
local
}
}
}
pub async fn remove_browser_shared(&self, browser_id: &str) {
self.remove_browser(browser_id);
if let Some(persistence) = self.persistence.as_ref()
&& let Err(error) = persistence.delete_browser(browser_id).await
{
tracing::error!(%error, "failed to delete persisted browser sessions");
}
}
pub async fn get_user_sessions_shared(&self, user_hash: &str) -> Vec<AccountSession> {
let Some(persistence) = self.persistence.as_ref() else {
return self.get_user_sessions(user_hash);
};
match persistence.load_user(user_hash).await {
Ok(records) => records
.into_iter()
.filter(|r| !r.expired_at(Self::unix_now()))
.filter_map(|r| self.cache_persisted(&r).ok())
.collect(),
Err(error) => {
tracing::error!(%error, "failed to load user sessions");
self.get_user_sessions(user_hash)
}
}
}
pub async fn remove_user_session_shared(&self, user_hash: &str, account_id: &str) -> bool {
let sessions = self.get_user_sessions_shared(user_hash).await;
if !sessions.iter().any(|s| s.account_id == account_id) {
return false;
}
self.remove_account_shared(account_id).await
}
pub async fn remove_other_user_sessions_shared(
&self,
user_hash: &str,
current_account_id: &str,
) -> usize {
let ids: Vec<String> = self
.get_user_sessions_shared(user_hash)
.await
.into_iter()
.filter(|s| s.account_id != current_account_id)
.map(|s| s.account_id)
.collect();
let mut removed = 0;
for id in ids {
if self.remove_account_shared(&id).await {
removed += 1;
}
}
removed
}
pub async fn purge_expired_shared(&self) {
self.purge_expired();
if let Some(persistence) = self.persistence.as_ref()
&& let Err(error) = persistence.purge_expired(Self::unix_now()).await
{
tracing::error!(%error, "failed to purge persisted sessions");
}
}
}
#[cfg(test)]
@@ -1318,4 +1599,56 @@ mod tests {
"remembered session should outlive default timeout"
);
}
#[tokio::test]
async fn persistent_sqlite_session_survives_store_restart() {
let temp = tempfile::tempdir().expect("tempdir");
let config = crate::config::AppConfig {
data_dir: temp.path().to_string_lossy().into_owned(),
database_backend: "sqlite".to_string(),
session_backend: "database".to_string(),
..Default::default()
};
let persistence = crate::session_store::from_config(&config)
.await
.expect("persistence");
let crypto =
crate::session_store::crypto::SessionCrypto::load_or_create(&config).expect("crypto");
let store =
SessionStore::new_persistent(Duration::from_secs(3600), None, persistence, crypto);
let browser_id = store.create_browser();
let (token, account_id) = store
.add_account_to_browser_shared(
&browser_id,
NewAccountSession {
email: "restart@example.com".to_string(),
password: "secret-password".to_string(),
user_hash: "user-hash".to_string(),
imap_host: "imap.example.com".to_string(),
imap_port: 993,
imap_tls: true,
smtp_host: "smtp.example.com".to_string(),
smtp_port: 587,
smtp_tls: true,
timeout_override: None,
},
)
.await;
drop(store);
let persistence = crate::session_store::from_config(&config)
.await
.expect("persistence after restart");
let crypto = crate::session_store::crypto::SessionCrypto::load_or_create(&config)
.expect("crypto after restart");
let restarted =
SessionStore::new_persistent(Duration::from_secs(3600), None, persistence, crypto);
let restored = restarted
.get_account_session_shared(&browser_id, &account_id, &token)
.await
.expect("session should survive restart");
assert_eq!(restored.email, "restart@example.com");
assert_eq!(restored.password, "secret-password");
}
}
+7 -13
View File
@@ -3,11 +3,6 @@ use std::path::PathBuf;
use sha2::{Digest, Sha256};
mod embedded {
use refinery::embed_migrations;
embed_migrations!("migrations");
}
pub fn hash_email(email: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(email.as_bytes());
@@ -31,13 +26,12 @@ pub fn provision_user_data(data_dir: &str, user_hash: &str) -> Result<PathBuf, S
}
}
let db_path = user_dir.join("db.sqlite");
let mut conn = rusqlite::Connection::open(&db_path)
.map_err(|e| format!("failed to open database: {e}"))?;
embedded::migrations::runner()
.run(&mut conn)
.map_err(|e| format!("failed to run migrations: {e}"))?;
// User data is always SQLite and must only receive the user-schema
// migrations from db::pool. Session migrations are handled separately by
// session_store for the configured session database backend.
let conn = crate::db::pool::open_user_db(data_dir, user_hash)
.map_err(|e| format!("failed to initialize user database: {e}"))?;
drop(conn);
Ok(user_dir)
}
@@ -117,7 +111,7 @@ mod tests {
let value: String = conn
.query_row(
"SELECT value FROM user_meta WHERE key = 'schema_version'",
crate::queries::sqlite::queries::auth_user_data::Q_001,
[],
|row| row.get(0),
)
+122 -1
View File
@@ -46,6 +46,36 @@ pub struct AppConfig {
#[serde(default)]
pub log_dir: Option<String>,
#[serde(default = "default_database_backend")]
pub database_backend: String,
#[serde(default)]
pub database_url: Option<String>,
#[serde(default = "default_session_backend")]
pub session_backend: String,
#[serde(default)]
pub session_encryption_key: Option<String>,
#[serde(default)]
pub redis_url: Option<String>,
#[serde(default)]
pub redis_sentinel_nodes: Vec<String>,
#[serde(default)]
pub redis_sentinel_master: Option<String>,
#[serde(default)]
pub redis_password: Option<String>,
#[serde(default)]
pub redis_sentinel_password: Option<String>,
#[serde(default)]
pub cluster_mode: bool,
#[serde(default = "default_session_timeout_hours")]
pub session_timeout_hours: u64,
@@ -169,6 +199,14 @@ fn default_data_dir() -> String {
"/data".to_string()
}
fn default_database_backend() -> String {
"sqlite".to_string()
}
fn default_session_backend() -> String {
"database".to_string()
}
fn default_session_timeout_hours() -> u64 {
24
}
@@ -232,6 +270,16 @@ impl Default for AppConfig {
max_message_size_mb: default_max_message_size_mb(),
data_dir: default_data_dir(),
log_dir: None,
database_backend: default_database_backend(),
database_url: None,
session_backend: default_session_backend(),
session_encryption_key: None,
redis_url: None,
redis_sentinel_nodes: Vec::new(),
redis_sentinel_master: None,
redis_password: None,
redis_sentinel_password: None,
cluster_mode: false,
session_timeout_hours: default_session_timeout_hours(),
remember_me_enabled: default_remember_me_enabled(),
remember_me_days: default_remember_me_days(),
@@ -407,6 +455,64 @@ impl AppConfig {
if self.max_attachment_size_mb > self.max_message_size_mb {
return Err("MAX_ATTACHMENT_SIZE_MB cannot exceed MAX_MESSAGE_SIZE_MB".to_string());
}
if !matches!(
self.database_backend.as_str(),
"sqlite" | "mysql" | "pgsql" | "postgres" | "postgresql"
) {
return Err("DATABASE_BACKEND must be one of: sqlite, mysql, pgsql".to_string());
}
if self.database_backend != "sqlite"
&& self
.database_url
.as_deref()
.map(str::trim)
.filter(|v| !v.is_empty())
.is_none()
{
return Err("DATABASE_URL is required for mysql/pgsql".to_string());
}
if !matches!(self.session_backend.as_str(), "database" | "redis") {
return Err("SESSION_BACKEND must be one of: database, redis".to_string());
}
if self.session_backend == "redis" {
let direct = self
.redis_url
.as_deref()
.map(str::trim)
.filter(|v| !v.is_empty())
.is_some();
let sentinel = !self.redis_sentinel_nodes.is_empty()
&& self
.redis_sentinel_master
.as_deref()
.map(str::trim)
.filter(|v| !v.is_empty())
.is_some();
if !direct && !sentinel {
return Err("Redis sessions require REDIS_URL or REDIS_SENTINEL_NODES + REDIS_SENTINEL_MASTER".to_string());
}
}
if self.cluster_mode
&& self.session_backend == "database"
&& self.database_backend == "sqlite"
{
return Err("CLUSTER_MODE with database sessions requires mysql/pgsql, or configure SESSION_BACKEND=redis".to_string());
}
if (self.cluster_mode
|| self.session_backend == "redis"
|| self.database_backend != "sqlite")
&& self
.session_encryption_key
.as_deref()
.map(str::trim)
.filter(|v| !v.is_empty())
.is_none()
{
return Err(
"shared session storage requires SESSION_ENCRYPTION_KEY shared by all nodes"
.to_string(),
);
}
if self.session_timeout_hours == 0 || self.session_timeout_hours > 24 * 365 {
return Err("SESSION_TIMEOUT_HOURS must be between 1 and 8760".to_string());
}
@@ -435,8 +541,14 @@ impl AppConfig {
| "yahoo"
| "hey"
| "nord"
| "github"
| "gitlab"
| "discord"
| "slack"
| "reddit"
| "notion"
) {
return Err("DEFAULT_INTERFACE_SKIN must be one of: rustbox, roundcube-classic, roundcube-larry, roundcube-elastic, clean, gmail, proton, zoho, outlook, minimal, graphite, high-contrast, fastmail, apple-mail, yahoo, hey, nord".to_string());
return Err("DEFAULT_INTERFACE_SKIN must be one of: rustbox, roundcube-classic, roundcube-larry, roundcube-elastic, clean, gmail, proton, zoho, outlook, minimal, graphite, high-contrast, fastmail, apple-mail, yahoo, hey, nord, github, gitlab, discord, slack, reddit, notion".to_string());
}
if !matches!(
self.sieve_tls_mode.as_str(),
@@ -489,6 +601,15 @@ mod tests {
assert_eq!(config.max_message_size_mb, 50);
assert_eq!(config.data_dir, "/data");
assert!(config.log_dir.is_none());
assert_eq!(config.database_backend, "sqlite");
assert!(config.database_url.is_none());
assert_eq!(config.session_backend, "database");
assert!(config.session_encryption_key.is_none());
assert!(config.redis_url.is_none());
assert!(config.redis_sentinel_nodes.is_empty());
assert!(config.redis_sentinel_master.is_none());
assert!(config.redis_sentinel_password.is_none());
assert!(!config.cluster_mode);
assert_eq!(config.session_timeout_hours, 24);
assert!(config.remember_me_enabled);
assert_eq!(config.remember_me_days, 30);
+29 -54
View File
@@ -1,3 +1,4 @@
use crate::queries::sqlite::queries::db_calendar::{EVENT_SELECT_COLS, SETTINGS_SELECT_COLS};
use rusqlite::{Connection, params};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
@@ -125,11 +126,6 @@ pub struct MeetingTemplate {
pub created_at: String,
}
const EVENT_SELECT_COLS: &str = "id, title, description, location, start_time, end_time, \
all_day, recurrence_rule, attendees, organizer, status, source, source_uid, \
source_message_uid, source_message_folder, meeting_url, color, reminder_minutes, \
created_at, updated_at";
fn row_to_event(row: &rusqlite::Row<'_>) -> rusqlite::Result<CalendarEvent> {
let all_day_int: i32 = row.get(6)?;
Ok(CalendarEvent {
@@ -191,11 +187,7 @@ pub fn list_events(
start: &str,
end: &str,
) -> Result<Vec<CalendarEvent>, String> {
let sql = format!(
"SELECT {EVENT_SELECT_COLS} FROM calendar_events
WHERE end_time >= ?1 AND start_time <= ?2
ORDER BY start_time ASC"
);
let sql = crate::rustbox_query_db_calendar_001!();
let mut stmt = conn
.prepare(&sql)
.map_err(|e| format!("Failed to prepare list_events: {e}"))?;
@@ -211,9 +203,7 @@ pub fn list_events(
}
pub fn list_all_events(conn: &Connection) -> Result<Vec<CalendarEvent>, String> {
let sql = format!(
"SELECT {EVENT_SELECT_COLS} FROM calendar_events ORDER BY start_time ASC, title ASC"
);
let sql = crate::rustbox_query_db_calendar_002!();
let mut stmt = conn
.prepare(&sql)
.map_err(|e| format!("Failed to prepare list_all_events: {e}"))?;
@@ -229,7 +219,7 @@ pub fn list_all_events(conn: &Connection) -> Result<Vec<CalendarEvent>, String>
}
pub fn get_event(conn: &Connection, id: &str) -> Result<Option<CalendarEvent>, String> {
let sql = format!("SELECT {EVENT_SELECT_COLS} FROM calendar_events WHERE id = ?1");
let sql = crate::rustbox_query_db_calendar_003!();
let result = conn.query_row(&sql, params![id], row_to_event);
match result {
@@ -242,16 +232,15 @@ pub fn get_event(conn: &Connection, id: &str) -> Result<Option<CalendarEvent>, S
pub fn create_event(conn: &Connection, data: &CreateEvent) -> Result<CalendarEvent, String> {
let id = Uuid::new_v4().to_string();
let now: String = conn
.query_row("SELECT datetime('now')", [], |row| row.get(0))
.query_row(
crate::queries::sqlite::queries::db_calendar::Q_001,
[],
|row| row.get(0),
)
.map_err(|e| format!("Failed to get current time: {e}"))?;
conn.execute(
"INSERT INTO calendar_events
(id, title, description, location, start_time, end_time, all_day,
recurrence_rule, attendees, organizer, status, source, source_uid,
source_message_uid, source_message_folder, meeting_url, color,
reminder_minutes, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)",
crate::queries::sqlite::queries::db_calendar::Q_002,
params![
id,
data.title,
@@ -329,10 +318,7 @@ pub fn update_event(
sets.push("updated_at = datetime('now')".to_string());
let sql = format!(
"UPDATE calendar_events SET {} WHERE id = ?{idx}",
sets.join(", ")
);
let sql = crate::rustbox_query_db_calendar_004!(sets.join(", "), idx);
values.push(Box::new(id.to_string()));
let params_refs: Vec<&dyn rusqlite::types::ToSql> = values.iter().map(|b| b.as_ref()).collect();
@@ -349,7 +335,10 @@ pub fn update_event(
pub fn delete_event(conn: &Connection, id: &str) -> Result<bool, String> {
let deleted = conn
.execute("DELETE FROM calendar_events WHERE id = ?1", params![id])
.execute(
crate::queries::sqlite::queries::db_calendar::Q_003,
params![id],
)
.map_err(|e| format!("Failed to delete event: {e}"))?;
Ok(deleted > 0)
}
@@ -358,7 +347,7 @@ pub fn find_event_by_source_uid(
conn: &Connection,
source_uid: &str,
) -> Result<Option<CalendarEvent>, String> {
let sql = format!("SELECT {EVENT_SELECT_COLS} FROM calendar_events WHERE source_uid = ?1");
let sql = crate::rustbox_query_db_calendar_005!();
let result = conn.query_row(&sql, params![source_uid], row_to_event);
match result {
@@ -368,18 +357,11 @@ pub fn find_event_by_source_uid(
}
}
const SETTINGS_SELECT_COLS: &str = "default_view, week_starts_on, working_hours_start, \
working_hours_end, time_format, zoom_link, teams_link, meet_link, caldav_url, \
caldav_username, caldav_enabled, updated_at";
pub fn get_calendar_settings(conn: &Connection) -> Result<CalendarSettings, String> {
conn.execute(
"INSERT OR IGNORE INTO calendar_settings (id) VALUES (1)",
[],
)
.map_err(|e| format!("Failed to ensure calendar_settings row: {e}"))?;
conn.execute(crate::queries::sqlite::queries::db_calendar::Q_004, [])
.map_err(|e| format!("Failed to ensure calendar_settings row: {e}"))?;
let sql = format!("SELECT {SETTINGS_SELECT_COLS} FROM calendar_settings WHERE id = 1");
let sql = crate::rustbox_query_db_calendar_006!();
conn.query_row(&sql, [], row_to_settings)
.map_err(|e| format!("Failed to get calendar settings: {e}"))
}
@@ -388,11 +370,8 @@ pub fn update_calendar_settings(
conn: &Connection,
data: &UpdateCalendarSettings,
) -> Result<CalendarSettings, String> {
conn.execute(
"INSERT OR IGNORE INTO calendar_settings (id) VALUES (1)",
[],
)
.map_err(|e| format!("Failed to ensure calendar_settings row: {e}"))?;
conn.execute(crate::queries::sqlite::queries::db_calendar::Q_005, [])
.map_err(|e| format!("Failed to ensure calendar_settings row: {e}"))?;
let mut sets = Vec::new();
let mut values: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
@@ -436,10 +415,7 @@ pub fn update_calendar_settings(
if !sets.is_empty() {
sets.push("updated_at = datetime('now')".to_string());
let sql = format!(
"UPDATE calendar_settings SET {} WHERE id = 1",
sets.join(", ")
);
let sql = crate::rustbox_query_db_calendar_007!(sets.join(", "));
let params_refs: Vec<&dyn rusqlite::types::ToSql> =
values.iter().map(|b| b.as_ref()).collect();
conn.execute(&sql, params_refs.as_slice())
@@ -451,10 +427,7 @@ pub fn update_calendar_settings(
pub fn list_meeting_templates(conn: &Connection) -> Result<Vec<MeetingTemplate>, String> {
let mut stmt = conn
.prepare(
"SELECT id, name, url_template, icon, is_default, created_at
FROM meeting_templates ORDER BY is_default DESC, name ASC",
)
.prepare(crate::queries::sqlite::queries::db_calendar::Q_006)
.map_err(|e| format!("Failed to prepare list_meeting_templates: {e}"))?;
let rows = stmt
.query_map([], row_to_template)
@@ -474,21 +447,23 @@ pub fn create_meeting_template(
icon: &str,
) -> Result<MeetingTemplate, String> {
conn.execute(
"INSERT INTO meeting_templates (name, url_template, icon) VALUES (?1, ?2, ?3)",
crate::queries::sqlite::queries::db_calendar::Q_007,
params![name, url_template, icon],
)
.map_err(|e| format!("Failed to create meeting template: {e}"))?;
let id = conn.last_insert_rowid();
let sql = "SELECT id, name, url_template, icon, is_default, created_at
FROM meeting_templates WHERE id = ?1";
let sql = crate::queries::sqlite::queries::db_calendar::Q_008;
conn.query_row(sql, params![id], row_to_template)
.map_err(|e| format!("Failed to read created template: {e}"))
}
pub fn delete_meeting_template(conn: &Connection, id: i64) -> Result<bool, String> {
let deleted = conn
.execute("DELETE FROM meeting_templates WHERE id = ?1", params![id])
.execute(
crate::queries::sqlite::queries::db_calendar::Q_009,
params![id],
)
.map_err(|e| format!("Failed to delete meeting template: {e}"))?;
Ok(deleted > 0)
}
+11 -27
View File
@@ -14,7 +14,7 @@ pub struct ContactGroup {
pub fn create_group(conn: &Connection, id: &str, name: &str) -> Result<(), String> {
conn.execute(
"INSERT INTO contact_groups (id, name) VALUES (?1, ?2)",
crate::queries::sqlite::queries::db_contact_groups::Q_001,
params![id, name],
)
.map_err(|e| format!("Failed to create contact group: {e}"))?;
@@ -23,12 +23,7 @@ pub fn create_group(conn: &Connection, id: &str, name: &str) -> Result<(), Strin
pub fn list_groups(conn: &Connection) -> Result<Vec<ContactGroup>, String> {
let mut stmt = conn
.prepare(
"SELECT g.id, g.name, g.created_at, g.updated_at,
(SELECT COUNT(*) FROM contact_group_members m WHERE m.group_id = g.id) AS member_count
FROM contact_groups g
ORDER BY g.name ASC",
)
.prepare(crate::queries::sqlite::queries::db_contact_groups::Q_002)
.map_err(|e| format!("Failed to prepare list_groups: {e}"))?;
let rows = stmt
@@ -53,7 +48,7 @@ pub fn list_groups(conn: &Connection) -> Result<Vec<ContactGroup>, String> {
pub fn update_group(conn: &Connection, id: &str, name: &str) -> Result<bool, String> {
let updated = conn
.execute(
"UPDATE contact_groups SET name = ?1, updated_at = datetime('now') WHERE id = ?2",
crate::queries::sqlite::queries::db_contact_groups::Q_003,
params![name, id],
)
.map_err(|e| format!("Failed to update contact group: {e}"))?;
@@ -62,14 +57,17 @@ pub fn update_group(conn: &Connection, id: &str, name: &str) -> Result<bool, Str
pub fn delete_group(conn: &Connection, id: &str) -> Result<bool, String> {
let deleted = conn
.execute("DELETE FROM contact_groups WHERE id = ?1", params![id])
.execute(
crate::queries::sqlite::queries::db_contact_groups::Q_004,
params![id],
)
.map_err(|e| format!("Failed to delete contact group: {e}"))?;
Ok(deleted > 0)
}
pub fn add_member(conn: &Connection, group_id: &str, contact_id: &str) -> Result<(), String> {
conn.execute(
"INSERT OR IGNORE INTO contact_group_members (group_id, contact_id) VALUES (?1, ?2)",
crate::queries::sqlite::queries::db_contact_groups::Q_005,
params![group_id, contact_id],
)
.map_err(|e| format!("Failed to add group member: {e}"))?;
@@ -79,7 +77,7 @@ pub fn add_member(conn: &Connection, group_id: &str, contact_id: &str) -> Result
pub fn remove_member(conn: &Connection, group_id: &str, contact_id: &str) -> Result<bool, String> {
let deleted = conn
.execute(
"DELETE FROM contact_group_members WHERE group_id = ?1 AND contact_id = ?2",
crate::queries::sqlite::queries::db_contact_groups::Q_006,
params![group_id, contact_id],
)
.map_err(|e| format!("Failed to remove group member: {e}"))?;
@@ -88,14 +86,7 @@ pub fn remove_member(conn: &Connection, group_id: &str, contact_id: &str) -> Res
pub fn list_group_members(conn: &Connection, group_id: &str) -> Result<Vec<Contact>, String> {
let mut stmt = conn
.prepare(
"SELECT c.id, c.email, c.name, c.company, c.notes, c.phone, c.job_title, c.address, c.website, c.birthday, c.is_favorite,
c.last_contacted, c.contact_count, c.source, c.created_at, c.updated_at, c.emails_json, c.phones_json, c.addresses_json, EXISTS(SELECT 1 FROM contact_photos p WHERE p.contact_id = c.id) AS has_photo
FROM contacts c
JOIN contact_group_members m ON m.contact_id = c.id
WHERE m.group_id = ?1
ORDER BY c.name ASC",
)
.prepare(crate::queries::sqlite::queries::db_contact_groups::Q_007)
.map_err(|e| format!("Failed to prepare list_group_members: {e}"))?;
let rows = stmt
@@ -140,14 +131,7 @@ pub fn list_contact_groups(
contact_id: &str,
) -> Result<Vec<ContactGroup>, String> {
let mut stmt = conn
.prepare(
"SELECT g.id, g.name, g.created_at, g.updated_at,
(SELECT COUNT(*) FROM contact_group_members m2 WHERE m2.group_id = g.id) AS member_count
FROM contact_groups g
JOIN contact_group_members m ON m.group_id = g.id
WHERE m.contact_id = ?1
ORDER BY g.name ASC",
)
.prepare(crate::queries::sqlite::queries::db_contact_groups::Q_008)
.map_err(|e| format!("Failed to prepare list_contact_groups: {e}"))?;
let rows = stmt
+21 -63
View File
@@ -1,3 +1,4 @@
use crate::queries::sqlite::queries::db_contacts::CONTACT_SELECT_COLS;
use rusqlite::{Connection, params};
use serde::Serialize;
use uuid::Uuid;
@@ -58,8 +59,6 @@ fn row_to_contact(row: &rusqlite::Row<'_>) -> rusqlite::Result<Contact> {
})
}
const CONTACT_SELECT_COLS: &str = "id, email, name, company, notes, phone, job_title, address, website, birthday, is_favorite, last_contacted, contact_count, source, created_at, updated_at, emails_json, phones_json, addresses_json, EXISTS(SELECT 1 FROM contact_photos p WHERE p.contact_id = contacts.id) AS has_photo";
fn escape_like(term: &str) -> String {
term.replace('\\', "\\\\")
.replace('%', "\\%")
@@ -68,16 +67,7 @@ fn escape_like(term: &str) -> String {
pub fn upsert_contact(conn: &Connection, contact: &Contact) -> Result<(), String> {
conn.execute(
"INSERT INTO contacts
(id, email, name, company, notes, phone, job_title, address, website, birthday, is_favorite, last_contacted,
contact_count, source, created_at, updated_at, emails_json, phones_json, addresses_json)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19)
ON CONFLICT(id) DO UPDATE SET
email=excluded.email, name=excluded.name, company=excluded.company, notes=excluded.notes,
phone=excluded.phone, job_title=excluded.job_title, address=excluded.address, website=excluded.website, birthday=excluded.birthday,
is_favorite=excluded.is_favorite, last_contacted=excluded.last_contacted, contact_count=excluded.contact_count,
source=excluded.source, updated_at=excluded.updated_at, emails_json=excluded.emails_json,
phones_json=excluded.phones_json, addresses_json=excluded.addresses_json",
crate::queries::sqlite::queries::db_contacts::Q_001,
params![
contact.id,
contact.email,
@@ -105,7 +95,7 @@ pub fn upsert_contact(conn: &Connection, contact: &Contact) -> Result<(), String
}
pub fn get_contact(conn: &Connection, id: &str) -> Result<Option<Contact>, String> {
let sql = format!("SELECT {CONTACT_SELECT_COLS} FROM contacts WHERE id = ?1");
let sql = crate::rustbox_query_db_contacts_001!();
let result = conn.query_row(&sql, params![id], row_to_contact);
match result {
@@ -116,11 +106,7 @@ pub fn get_contact(conn: &Connection, id: &str) -> Result<Option<Contact>, Strin
}
pub fn get_contact_by_email(conn: &Connection, email: &str) -> Result<Option<Contact>, String> {
let sql = format!(
"SELECT {CONTACT_SELECT_COLS} FROM contacts \
WHERE lower(email) = lower(?1) \
OR EXISTS (SELECT 1 FROM json_each(contacts.emails_json) WHERE lower(value) = lower(?1))"
);
let sql = crate::rustbox_query_db_contacts_002!();
let result = conn.query_row(&sql, params![email], row_to_contact);
match result {
@@ -140,22 +126,13 @@ pub fn list_contacts(
Some(q) if !q.is_empty() => {
let escaped = escape_like(q);
let pattern = format!("%{escaped}%");
let sql = format!(
"SELECT {CONTACT_SELECT_COLS} FROM contacts
WHERE name LIKE ?1 ESCAPE '\\' OR email LIKE ?1 ESCAPE '\\' OR emails_json LIKE ?1 ESCAPE '\\'
ORDER BY name ASC
LIMIT ?2 OFFSET ?3"
);
let sql = crate::rustbox_query_db_contacts_003!();
drop(pattern);
(sql, Some(escaped))
}
_ => {
let sql = format!(
"SELECT {CONTACT_SELECT_COLS} FROM contacts
ORDER BY name ASC
LIMIT ?1 OFFSET ?2"
);
let sql = crate::rustbox_query_db_contacts_004!();
(sql, None)
}
};
@@ -193,7 +170,10 @@ pub fn list_contacts(
pub fn delete_contact(conn: &Connection, id: &str) -> Result<bool, String> {
let deleted = conn
.execute("DELETE FROM contacts WHERE id = ?1", params![id])
.execute(
crate::queries::sqlite::queries::db_contacts::Q_002,
params![id],
)
.map_err(|e| format!("Failed to delete contact: {e}"))?;
Ok(deleted > 0)
}
@@ -202,12 +182,7 @@ pub fn search_contacts(conn: &Connection, query: &str, limit: u32) -> Result<Vec
let escaped = escape_like(query);
let pattern = format!("%{escaped}%");
let sql = format!(
"SELECT {CONTACT_SELECT_COLS} FROM contacts
WHERE name LIKE ?1 ESCAPE '\\' OR email LIKE ?1 ESCAPE '\\' OR emails_json LIKE ?1 ESCAPE '\\'
ORDER BY name ASC
LIMIT ?2"
);
let sql = crate::rustbox_query_db_contacts_005!();
let mut stmt = conn
.prepare(&sql)
@@ -231,18 +206,7 @@ pub fn search_known_addresses(
let escaped = escape_like(query);
let pattern = format!("%{escaped}%");
let sql = r#"
SELECT email, name
FROM known_addresses
WHERE (email LIKE ?1 ESCAPE '\' OR name LIKE ?1 ESCAPE '\')
AND email NOT IN (SELECT email FROM contacts)
AND NOT EXISTS (
SELECT 1 FROM contacts c, json_each(c.emails_json) alt
WHERE lower(alt.value) = lower(known_addresses.email)
)
ORDER BY email ASC
LIMIT ?2
"#;
let sql = crate::queries::sqlite::queries::db_contacts::Q_003;
let mut stmt = conn
.prepare(sql)
@@ -273,8 +237,7 @@ pub fn populate_known_addresses(
) -> Result<(), String> {
if !from_address.is_empty() {
conn.execute(
"INSERT INTO known_addresses (email, name) VALUES (?1, ?2)
ON CONFLICT(email) DO UPDATE SET name = excluded.name WHERE excluded.name != ''",
crate::queries::sqlite::queries::db_contacts::Q_004,
params![from_address, from_name],
)
.map_err(|e| format!("Failed to insert known address (from): {e}"))?;
@@ -300,8 +263,7 @@ fn insert_addresses_from_json(conn: &Connection, json: &str) -> Result<(), Strin
}
let name = entry.get("name").and_then(|v| v.as_str()).unwrap_or("");
conn.execute(
"INSERT INTO known_addresses (email, name) VALUES (?1, ?2)
ON CONFLICT(email) DO UPDATE SET name = excluded.name WHERE excluded.name != ''",
crate::queries::sqlite::queries::db_contacts::Q_005,
params![email, name],
)
.map_err(|e| format!("Failed to insert known address (json): {e}"))?;
@@ -313,11 +275,7 @@ fn insert_addresses_from_json(conn: &Connection, json: &str) -> Result<(), Strin
#[allow(dead_code)]
pub fn increment_contact_count(conn: &Connection, email: &str) -> Result<(), String> {
conn.execute(
"UPDATE contacts
SET contact_count = contact_count + 1,
last_contacted = datetime('now'),
updated_at = datetime('now')
WHERE email = ?1",
crate::queries::sqlite::queries::db_contacts::Q_006,
params![email],
)
.map_err(|e| format!("Failed to increment contact count: {e}"))?;
@@ -328,8 +286,7 @@ pub fn increment_contact_count(conn: &Connection, email: &str) -> Result<(), Str
pub fn auto_add_contact(conn: &Connection, email: &str, name: &str) -> Result<(), String> {
let id = Uuid::new_v4().to_string();
conn.execute(
"INSERT OR IGNORE INTO contacts (id, email, name, source)
VALUES (?1, ?2, ?3, 'auto')",
crate::queries::sqlite::queries::db_contacts::Q_007,
params![id, email, name],
)
.map_err(|e| format!("Failed to auto-add contact: {e}"))?;
@@ -343,9 +300,10 @@ pub fn set_contact_photo(
data: &[u8],
) -> Result<(), String> {
conn.execute(
"INSERT INTO contact_photos (contact_id, content_type, data, updated_at) VALUES (?1, ?2, ?3, datetime('now')) ON CONFLICT(contact_id) DO UPDATE SET content_type=excluded.content_type, data=excluded.data, updated_at=datetime('now')",
crate::queries::sqlite::queries::db_contacts::Q_008,
params![contact_id, content_type, data],
).map_err(|e| format!("Failed to save contact photo: {e}"))?;
)
.map_err(|e| format!("Failed to save contact photo: {e}"))?;
Ok(())
}
@@ -354,7 +312,7 @@ pub fn get_contact_photo(
contact_id: &str,
) -> Result<Option<(String, Vec<u8>)>, String> {
match conn.query_row(
"SELECT content_type, data FROM contact_photos WHERE contact_id = ?1",
crate::queries::sqlite::queries::db_contacts::Q_009,
params![contact_id],
|row| Ok((row.get(0)?, row.get(1)?)),
) {
@@ -366,7 +324,7 @@ pub fn get_contact_photo(
pub fn delete_contact_photo(conn: &Connection, contact_id: &str) -> Result<bool, String> {
conn.execute(
"DELETE FROM contact_photos WHERE contact_id = ?1",
crate::queries::sqlite::queries::db_contacts::Q_010,
params![contact_id],
)
.map(|n| n > 0)
+103 -52
View File
@@ -185,21 +185,65 @@ fn defaults() -> DisplayPreferences {
pub fn get_preferences(conn: &Connection) -> Result<DisplayPreferences, String> {
let result = conn.query_row(
"SELECT density, theme, language, compose_format, deep_index, animation_mode, preview_pane, ui_scale, color_preset, accent_color, background_color, panel_color, text_color, font_family, font_size, row_height, folder_pane_width, message_pane_width, border_radius, contrast, show_avatars, show_attachment_icons, show_flags, reduce_motion, date_format, time_format, compose_autosave_ms, default_priority, request_mdn_default, attachment_reminder, icon_size, toolbar_density, sidebar_item_height, reader_max_width, composer_width, selection_color, unread_font_weight, unread_badge_style, compact_headers, show_borders, scrollbar_density, monochrome, folder_background, message_background, reader_background, compose_font_family, reply_position, quote_style, forward_mode, spellcheck_enabled, interface_skin, preset_scale_unlocked, updated_at FROM display_preferences WHERE id = 1",
crate::queries::sqlite::queries::db_display_preferences::Q_001,
[],
|row| Ok(DisplayPreferences {
density: row.get(0)?, theme: row.get(1)?, language: row.get(2)?, compose_format: row.get(3)?,
deep_index: row.get::<_, i32>(4)? != 0, animation_mode: row.get(5)?, preview_pane: row.get(6)?,
ui_scale: row.get(7)?, color_preset: row.get(8)?, accent_color: row.get(9)?, background_color: row.get(10)?, panel_color: row.get(11)?, text_color: row.get(12)?, font_family: row.get(13)?,
font_size: row.get(14)?, row_height: row.get(15)?, folder_pane_width: row.get(16)?, message_pane_width: row.get(17)?,
border_radius: row.get(18)?, contrast: row.get(19)?, show_avatars: row.get::<_, i32>(20)? != 0,
show_attachment_icons: row.get::<_, i32>(21)? != 0, show_flags: row.get::<_, i32>(22)? != 0,
reduce_motion: row.get::<_, i32>(23)? != 0, date_format: row.get(24)?, time_format: row.get(25)?,
compose_autosave_ms: row.get(26)?, default_priority: row.get(27)?, request_mdn_default: row.get::<_, i32>(28)? != 0, attachment_reminder: row.get::<_, i32>(29)? != 0,
icon_size: row.get(30)?, toolbar_density: row.get(31)?, sidebar_item_height: row.get(32)?, reader_max_width: row.get(33)?, composer_width: row.get(34)?,
selection_color: row.get(35)?, unread_font_weight: row.get(36)?, unread_badge_style: row.get(37)?, compact_headers: row.get::<_, i32>(38)? != 0, show_borders: row.get::<_, i32>(39)? != 0,
scrollbar_density: row.get(40)?, monochrome: row.get::<_, i32>(41)? != 0, folder_background: row.get(42)?, message_background: row.get(43)?, reader_background: row.get(44)?, compose_font_family: row.get(45)?, reply_position: row.get(46)?, quote_style: row.get(47)?, forward_mode: row.get(48)?, spellcheck_enabled: row.get::<_, i32>(49)? != 0, interface_skin: row.get(50)?, preset_scale_unlocked: row.get::<_, i32>(51)? != 0, updated_at: row.get(52)?,
}),
|row| {
Ok(DisplayPreferences {
density: row.get(0)?,
theme: row.get(1)?,
language: row.get(2)?,
compose_format: row.get(3)?,
deep_index: row.get::<_, i32>(4)? != 0,
animation_mode: row.get(5)?,
preview_pane: row.get(6)?,
ui_scale: row.get(7)?,
color_preset: row.get(8)?,
accent_color: row.get(9)?,
background_color: row.get(10)?,
panel_color: row.get(11)?,
text_color: row.get(12)?,
font_family: row.get(13)?,
font_size: row.get(14)?,
row_height: row.get(15)?,
folder_pane_width: row.get(16)?,
message_pane_width: row.get(17)?,
border_radius: row.get(18)?,
contrast: row.get(19)?,
show_avatars: row.get::<_, i32>(20)? != 0,
show_attachment_icons: row.get::<_, i32>(21)? != 0,
show_flags: row.get::<_, i32>(22)? != 0,
reduce_motion: row.get::<_, i32>(23)? != 0,
date_format: row.get(24)?,
time_format: row.get(25)?,
compose_autosave_ms: row.get(26)?,
default_priority: row.get(27)?,
request_mdn_default: row.get::<_, i32>(28)? != 0,
attachment_reminder: row.get::<_, i32>(29)? != 0,
icon_size: row.get(30)?,
toolbar_density: row.get(31)?,
sidebar_item_height: row.get(32)?,
reader_max_width: row.get(33)?,
composer_width: row.get(34)?,
selection_color: row.get(35)?,
unread_font_weight: row.get(36)?,
unread_badge_style: row.get(37)?,
compact_headers: row.get::<_, i32>(38)? != 0,
show_borders: row.get::<_, i32>(39)? != 0,
scrollbar_density: row.get(40)?,
monochrome: row.get::<_, i32>(41)? != 0,
folder_background: row.get(42)?,
message_background: row.get(43)?,
reader_background: row.get(44)?,
compose_font_family: row.get(45)?,
reply_position: row.get(46)?,
quote_style: row.get(47)?,
forward_mode: row.get(48)?,
spellcheck_enabled: row.get::<_, i32>(49)? != 0,
interface_skin: row.get(50)?,
preset_scale_unlocked: row.get::<_, i32>(51)? != 0,
updated_at: row.get(52)?,
})
},
);
match result {
Ok(value) => Ok(value),
@@ -231,7 +275,7 @@ pub fn update_preferences(
data: &UpdateDisplayPreferences,
) -> Result<DisplayPreferences, String> {
conn.execute(
"INSERT OR IGNORE INTO display_preferences (id) VALUES (1)",
crate::queries::sqlite::queries::db_display_preferences::Q_002,
[],
)
.map_err(|e| format!("Failed to ensure preferences row: {e}"))?;
@@ -362,6 +406,12 @@ pub fn update_preferences(
"yahoo",
"hey",
"nord",
"github",
"gitlab",
"discord",
"slack",
"reddit",
"notion",
],
)?;
}
@@ -386,10 +436,10 @@ pub fn update_preferences(
if let Some(v) = data.sidebar_item_height {
range("sidebar_item_height", v, 24, 48)?;
}
if let Some(v) = data.reader_max_width {
if v != 0 {
range("reader_max_width", v, 560, 1400)?;
}
if let Some(v) = data.reader_max_width
&& v != 0
{
range("reader_max_width", v, 560, 1400)?;
}
if let Some(v) = data.composer_width {
range("composer_width", v, 640, 1400)?;
@@ -397,46 +447,45 @@ pub fn update_preferences(
if let Some(v) = data.unread_font_weight {
range("unread_font_weight", v, 500, 800)?;
}
if let Some(ref v) = data.accent_color {
if !valid_hex(v) {
return Err(format!("Invalid accent_color: {v}"));
}
if let Some(ref v) = data.accent_color
&& !valid_hex(v)
{
return Err(format!("Invalid accent_color: {v}"));
}
if let Some(ref v) = data.background_color {
if !valid_hex(v) {
return Err(format!("Invalid background_color: {v}"));
}
if let Some(ref v) = data.background_color
&& !valid_hex(v)
{
return Err(format!("Invalid background_color: {v}"));
}
if let Some(ref v) = data.panel_color {
if !valid_hex(v) {
return Err(format!("Invalid panel_color: {v}"));
}
if let Some(ref v) = data.panel_color
&& !valid_hex(v)
{
return Err(format!("Invalid panel_color: {v}"));
}
if let Some(ref v) = data.text_color {
if !valid_hex(v) {
return Err(format!("Invalid text_color: {v}"));
}
if let Some(ref v) = data.text_color
&& !valid_hex(v)
{
return Err(format!("Invalid text_color: {v}"));
}
if let Some(ref v) = data.selection_color {
if !valid_hex(v) {
return Err(format!("Invalid selection_color: {v}"));
}
if let Some(ref v) = data.selection_color
&& !valid_hex(v)
{
return Err(format!("Invalid selection_color: {v}"));
}
for (name, value) in [
("folder_background", data.folder_background.as_deref()),
("message_background", data.message_background.as_deref()),
("reader_background", data.reader_background.as_deref()),
] {
if let Some(v) = value {
if !v.is_empty() && !valid_hex(v) {
return Err(format!("Invalid {name}: {v}"));
}
if let Some(v) = value
&& !v.is_empty()
&& !valid_hex(v)
{
return Err(format!("Invalid {name}: {v}"));
}
}
if let Some(ref mode) = data.animation_mode {
if let Some(v) = mode {
enum_value("animation_mode", v, &["rich", "medium", "subtle", "off"])?;
}
if let Some(Some(v)) = data.animation_mode.as_ref() {
enum_value("animation_mode", v, &["rich", "medium", "subtle", "off"])?;
}
set_text!(density, "density");
@@ -500,11 +549,7 @@ pub fn update_preferences(
return get_preferences(conn);
}
sets.push("updated_at = datetime('now')".to_string());
let sql = format!(
"UPDATE display_preferences SET {} WHERE id = ?{}",
sets.join(", "),
idx
);
let sql = crate::rustbox_query_db_display_preferences_001!(sets.join(", "), idx);
values.push(Box::new(1_i32));
let refs: Vec<&dyn rusqlite::types::ToSql> = values.iter().map(|v| v.as_ref()).collect();
conn.execute(&sql, refs.as_slice())
@@ -558,6 +603,12 @@ mod tests {
"yahoo",
"hey",
"nord",
"github",
"gitlab",
"discord",
"slack",
"reddit",
"notion",
] {
let conn = open_test_db();
let update: UpdateDisplayPreferences =
+14 -34
View File
@@ -72,25 +72,7 @@ pub fn upsert_draft(
) -> Result<(), String> {
validate_draft_id(id)?;
conn.execute(
"INSERT INTO drafts (id, to_addresses, cc_addresses, bcc_addresses, subject, text_body, html_body, in_reply_to, references_header, request_mdn, priority, compose_format, pgp_mode, pgp_inline, pgp_attach_public_key, pgp_attach_recipient_keys, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, datetime('now'))
ON CONFLICT(id) DO UPDATE SET
to_addresses = excluded.to_addresses,
cc_addresses = excluded.cc_addresses,
bcc_addresses = excluded.bcc_addresses,
subject = excluded.subject,
text_body = excluded.text_body,
html_body = excluded.html_body,
in_reply_to = excluded.in_reply_to,
references_header = excluded.references_header,
request_mdn = excluded.request_mdn,
priority = excluded.priority,
compose_format = excluded.compose_format,
pgp_mode = excluded.pgp_mode,
pgp_inline = excluded.pgp_inline,
pgp_attach_public_key = excluded.pgp_attach_public_key,
pgp_attach_recipient_keys = excluded.pgp_attach_recipient_keys,
updated_at = datetime('now')",
crate::queries::sqlite::queries::db_drafts::Q_001,
params![
id,
to_addresses,
@@ -116,8 +98,7 @@ pub fn upsert_draft(
pub fn get_draft(conn: &Connection, id: &str) -> Result<Option<Draft>, String> {
let result = conn.query_row(
"SELECT id, to_addresses, cc_addresses, bcc_addresses, subject, text_body, html_body, in_reply_to, references_header, request_mdn, priority, compose_format, pgp_mode, pgp_inline, pgp_attach_public_key, pgp_attach_recipient_keys, remote_folder, remote_uid, remote_uid_validity, created_at, updated_at
FROM drafts WHERE id = ?1",
crate::queries::sqlite::queries::db_drafts::Q_002,
params![id],
|row| {
Ok(Draft {
@@ -155,10 +136,7 @@ pub fn get_draft(conn: &Connection, id: &str) -> Result<Option<Draft>, String> {
pub fn list_drafts(conn: &Connection) -> Result<Vec<Draft>, String> {
let mut stmt = conn
.prepare(
"SELECT id, to_addresses, cc_addresses, bcc_addresses, subject, text_body, html_body, in_reply_to, references_header, request_mdn, priority, compose_format, pgp_mode, pgp_inline, pgp_attach_public_key, pgp_attach_recipient_keys, remote_folder, remote_uid, remote_uid_validity, created_at, updated_at
FROM drafts ORDER BY updated_at DESC",
)
.prepare(crate::queries::sqlite::queries::db_drafts::Q_003)
.map_err(|e| format!("Failed to prepare list_drafts: {e}"))?;
let rows = stmt
@@ -205,7 +183,7 @@ pub fn set_remote_location(
) -> Result<(), String> {
let updated = conn
.execute(
"UPDATE drafts SET remote_folder = ?2, remote_uid = ?3, remote_uid_validity = ?4 WHERE id = ?1",
crate::queries::sqlite::queries::db_drafts::Q_004,
params![id, folder, uid, uid_validity],
)
.map_err(|e| format!("Failed to update remote draft location: {e}"))?;
@@ -217,7 +195,10 @@ pub fn set_remote_location(
pub fn delete_draft(conn: &Connection, id: &str) -> Result<bool, String> {
let deleted = conn
.execute("DELETE FROM drafts WHERE id = ?1", params![id])
.execute(
crate::queries::sqlite::queries::db_drafts::Q_005,
params![id],
)
.map_err(|e| format!("Failed to delete draft: {e}"))?;
Ok(deleted > 0)
}
@@ -232,8 +213,7 @@ pub fn add_draft_attachment(
file_path: &str,
) -> Result<(), String> {
conn.execute(
"INSERT INTO draft_attachments (id, draft_id, filename, content_type, size, file_path)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
crate::queries::sqlite::queries::db_drafts::Q_006,
params![id, draft_id, filename, content_type, size, file_path],
)
.map_err(|e| format!("Failed to add draft attachment: {e}"))?;
@@ -245,10 +225,7 @@ pub fn get_draft_attachments(
draft_id: &str,
) -> Result<Vec<DraftAttachment>, String> {
let mut stmt = conn
.prepare(
"SELECT id, draft_id, filename, content_type, size, file_path, created_at
FROM draft_attachments WHERE draft_id = ?1 ORDER BY created_at ASC",
)
.prepare(crate::queries::sqlite::queries::db_drafts::Q_007)
.map_err(|e| format!("Failed to prepare get_draft_attachments: {e}"))?;
let rows = stmt
@@ -274,7 +251,10 @@ pub fn get_draft_attachments(
pub fn delete_draft_attachment(conn: &Connection, id: &str) -> Result<bool, String> {
let deleted = conn
.execute("DELETE FROM draft_attachments WHERE id = ?1", params![id])
.execute(
crate::queries::sqlite::queries::db_drafts::Q_008,
params![id],
)
.map_err(|e| format!("Failed to delete draft attachment: {e}"))?;
Ok(deleted > 0)
}
+28 -13
View File
@@ -14,7 +14,7 @@ pub struct UpdateFolderPreferences {
fn get_override(conn: &Connection, folder: &str) -> Result<Option<bool>, String> {
conn.query_row(
"SELECT flagged_on_top FROM folder_preferences WHERE folder_name = ?1",
crate::queries::sqlite::queries::db_folder_preferences::Q_001,
[folder],
|row| row.get::<_, Option<i32>>(0),
)
@@ -29,7 +29,7 @@ pub fn effective_flagged_on_top(conn: &Connection, folder: &str) -> Result<bool,
}
let global = conn
.query_row(
"SELECT flagged_on_top FROM mail_preferences WHERE id = 1",
crate::queries::sqlite::queries::db_folder_preferences::Q_002,
[],
|row| row.get::<_, i32>(0),
)
@@ -40,7 +40,7 @@ pub fn effective_flagged_on_top(conn: &Connection, folder: &str) -> Result<bool,
pub fn get_preferences(conn: &Connection, folder: &str) -> Result<FolderPreferences, String> {
let exists: bool = conn
.query_row(
"SELECT EXISTS(SELECT 1 FROM folders WHERE name = ?1)",
crate::queries::sqlite::queries::db_folder_preferences::Q_003,
[folder],
|row| row.get(0),
)
@@ -51,7 +51,7 @@ pub fn get_preferences(conn: &Connection, folder: &str) -> Result<FolderPreferen
let flagged_on_top = get_override(conn, folder)?;
let effective_flagged_on_top = flagged_on_top.unwrap_or(
conn.query_row(
"SELECT flagged_on_top FROM mail_preferences WHERE id = 1",
crate::queries::sqlite::queries::db_folder_preferences::Q_004,
[],
|row| row.get::<_, i32>(0),
)
@@ -71,7 +71,7 @@ pub fn update_preferences(
) -> Result<FolderPreferences, String> {
let exists: bool = conn
.query_row(
"SELECT EXISTS(SELECT 1 FROM folders WHERE name = ?1)",
crate::queries::sqlite::queries::db_folder_preferences::Q_005,
[folder],
|row| row.get(0),
)
@@ -83,14 +83,17 @@ pub fn update_preferences(
match data.flagged_on_top {
Some(value) => {
conn.execute(
"INSERT INTO folder_preferences(folder_name, flagged_on_top, updated_at) VALUES(?1, ?2, datetime('now'))\n ON CONFLICT(folder_name) DO UPDATE SET flagged_on_top = excluded.flagged_on_top, updated_at = datetime('now')",
crate::queries::sqlite::queries::db_folder_preferences::Q_006,
params![folder, value as i32],
)
.map_err(|error| format!("Failed to update folder preferences: {error}"))?;
}
None => {
conn.execute("DELETE FROM folder_preferences WHERE folder_name = ?1", [folder])
.map_err(|error| format!("Failed to clear folder preferences: {error}"))?;
conn.execute(
crate::queries::sqlite::queries::db_folder_preferences::Q_007,
[folder],
)
.map_err(|error| format!("Failed to clear folder preferences: {error}"))?;
}
}
@@ -100,14 +103,18 @@ pub fn update_preferences(
#[cfg(test)]
mod tests {
use super::*;
use crate::db::{folders, mail_preferences};
use crate::db::pool::open_test_db;
use crate::db::{folders, mail_preferences};
#[test]
fn folder_override_inherits_and_overrides_global_value() {
let conn = open_test_db();
folders::insert_folder_if_new(&conn, "INBOX", Some("/"), "").unwrap();
assert!(!get_preferences(&conn, "INBOX").unwrap().effective_flagged_on_top);
assert!(
!get_preferences(&conn, "INBOX")
.unwrap()
.effective_flagged_on_top
);
mail_preferences::update_preferences(
&conn,
@@ -125,12 +132,18 @@ mod tests {
},
)
.unwrap();
assert!(get_preferences(&conn, "INBOX").unwrap().effective_flagged_on_top);
assert!(
get_preferences(&conn, "INBOX")
.unwrap()
.effective_flagged_on_top
);
let overridden = update_preferences(
&conn,
"INBOX",
&UpdateFolderPreferences { flagged_on_top: Some(false) },
&UpdateFolderPreferences {
flagged_on_top: Some(false),
},
)
.unwrap();
assert_eq!(overridden.flagged_on_top, Some(false));
@@ -139,7 +152,9 @@ mod tests {
let inherited = update_preferences(
&conn,
"INBOX",
&UpdateFolderPreferences { flagged_on_top: None },
&UpdateFolderPreferences {
flagged_on_top: None,
},
)
.unwrap();
assert_eq!(inherited.flagged_on_top, None);
+42 -63
View File
@@ -28,10 +28,7 @@ pub fn upsert_folder(
highest_modseq: u64,
) -> Result<(), String> {
conn.execute(
"INSERT OR REPLACE 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'))",
crate::queries::sqlite::queries::db_folders::Q_001,
params![
folder_name,
delimiter,
@@ -55,9 +52,7 @@ pub fn insert_folder_if_new(
flags_csv: &str,
) -> Result<(), String> {
conn.execute(
"INSERT OR IGNORE INTO folders
(name, delimiter, flags, is_subscribed, updated_at)
VALUES (?1, ?2, ?3, 1, datetime('now'))",
crate::queries::sqlite::queries::db_folders::Q_002,
params![folder_name, delimiter, flags_csv],
)
.map_err(|e| format!("Failed to insert folder: {e}"))?;
@@ -66,12 +61,7 @@ pub fn insert_folder_if_new(
pub fn get_all_folders(conn: &Connection) -> Result<Vec<CachedFolder>, String> {
let mut stmt = conn
.prepare(
"SELECT name, delimiter, parent, flags, is_subscribed,
total_count, unread_count, uid_validity, highest_modseq
FROM folders
ORDER BY name",
)
.prepare(crate::queries::sqlite::queries::db_folders::Q_003)
.map_err(|e| format!("Failed to prepare get_all_folders: {e}"))?;
let rows = stmt
@@ -102,16 +92,13 @@ pub fn get_all_folders(conn: &Connection) -> Result<Vec<CachedFolder>, String> {
pub fn remove_stale_folders(conn: &Connection, current_names: &[String]) -> Result<usize, String> {
if current_names.is_empty() {
let deleted = conn
.execute("DELETE FROM folders", [])
.execute(crate::queries::sqlite::queries::db_folders::Q_004, [])
.map_err(|e| format!("Failed to delete all folders: {e}"))?;
return Ok(deleted);
}
let placeholders: Vec<String> = (1..=current_names.len()).map(|i| format!("?{i}")).collect();
let sql = format!(
"DELETE FROM folders WHERE name NOT IN ({})",
placeholders.join(", ")
);
let sql = crate::rustbox_query_db_folders_001!(placeholders.join(", "));
let params: Vec<&dyn rusqlite::types::ToSql> = current_names
.iter()
@@ -126,7 +113,7 @@ pub fn remove_stale_folders(conn: &Connection, current_names: &[String]) -> Resu
}
pub fn touch_all_folders(conn: &Connection) -> Result<(), String> {
conn.execute("UPDATE folders SET updated_at = datetime('now')", [])
conn.execute(crate::queries::sqlite::queries::db_folders::Q_005, [])
.map_err(|e| format!("Failed to touch folders: {e}"))?;
Ok(())
}
@@ -138,8 +125,7 @@ pub fn update_folder_status(
total_count: u32,
) -> Result<(), String> {
conn.execute(
"UPDATE folders SET uid_validity = ?1, total_count = ?2, messages_updated_at = datetime('now')
WHERE name = ?3",
crate::queries::sqlite::queries::db_folders::Q_006,
params![uid_validity, total_count, name],
)
.map_err(|e| format!("Failed to update folder status: {e}"))?;
@@ -154,8 +140,7 @@ pub fn update_folder_sync_status(
highest_modseq: u64,
) -> Result<(), String> {
conn.execute(
"UPDATE folders SET uid_validity = ?1, total_count = ?2, highest_modseq = ?3
WHERE name = ?4",
crate::queries::sqlite::queries::db_folders::Q_007,
params![uid_validity, total_count, highest_modseq as i64, name],
)
.map_err(|e| format!("Failed to update folder sync status: {e}"))?;
@@ -165,14 +150,14 @@ pub fn update_folder_sync_status(
pub fn refresh_unread_count(conn: &Connection, folder_name: &str) -> Result<(), String> {
let unread: u32 = conn
.query_row(
"SELECT COUNT(*) FROM messages WHERE folder = ?1 AND flags NOT LIKE '%\\Seen%'",
crate::queries::sqlite::queries::db_folders::Q_008,
params![folder_name],
|row| row.get(0),
)
.map_err(|e| format!("Failed to count unread messages: {e}"))?;
conn.execute(
"UPDATE folders SET unread_count = ?1 WHERE name = ?2",
crate::queries::sqlite::queries::db_folders::Q_009,
params![unread, folder_name],
)
.map_err(|e| format!("Failed to update unread_count: {e}"))?;
@@ -182,7 +167,7 @@ pub fn refresh_unread_count(conn: &Connection, folder_name: &str) -> Result<(),
pub fn adjust_unread_count(conn: &Connection, folder_name: &str, delta: i32) -> Result<(), String> {
conn.execute(
"UPDATE folders SET unread_count = MAX(0, CAST(unread_count AS INTEGER) + ?1) WHERE name = ?2",
crate::queries::sqlite::queries::db_folders::Q_010,
params![delta, folder_name],
)
.map_err(|e| format!("Failed to adjust unread_count: {e}"))?;
@@ -192,10 +177,7 @@ pub fn adjust_unread_count(conn: &Connection, folder_name: &str, delta: i32) ->
pub fn is_folder_cache_fresh(conn: &Connection, max_age_secs: u32) -> Result<bool, String> {
let fresh: bool = conn
.query_row(
"SELECT EXISTS(
SELECT 1 FROM folders
WHERE updated_at > datetime('now', ?1)
)",
crate::queries::sqlite::queries::db_folders::Q_011,
params![format!("-{max_age_secs} seconds")],
|row| row.get(0),
)
@@ -210,10 +192,7 @@ pub fn is_folder_fresh(
) -> Result<bool, String> {
let fresh: bool = conn
.query_row(
"SELECT EXISTS(
SELECT 1 FROM folders
WHERE name = ?1 AND messages_updated_at > datetime('now', ?2)
)",
crate::queries::sqlite::queries::db_folders::Q_012,
params![folder_name, format!("-{max_age_secs} seconds")],
|row| row.get(0),
)
@@ -227,7 +206,7 @@ pub fn is_folder_messages_invalidated(
) -> Result<bool, String> {
let invalidated: bool = conn
.query_row(
"SELECT messages_updated_at IS NULL FROM folders WHERE name = ?1",
crate::queries::sqlite::queries::db_folders::Q_013,
params![folder_name],
|row| row.get(0),
)
@@ -237,7 +216,7 @@ pub fn is_folder_messages_invalidated(
pub fn invalidate_folder_freshness(conn: &Connection, folder_name: &str) -> Result<(), String> {
conn.execute(
"UPDATE folders SET messages_updated_at = NULL WHERE name = ?1",
crate::queries::sqlite::queries::db_folders::Q_014,
params![folder_name],
)
.map_err(|e| format!("Failed to invalidate folder freshness: {e}"))?;
@@ -246,10 +225,7 @@ pub fn invalidate_folder_freshness(conn: &Connection, folder_name: &str) -> Resu
pub fn get_folder(conn: &Connection, name: &str) -> Result<Option<CachedFolder>, String> {
let result = conn.query_row(
"SELECT name, delimiter, parent, flags, is_subscribed,
total_count, unread_count, uid_validity, highest_modseq
FROM folders
WHERE name = ?1",
crate::queries::sqlite::queries::db_folders::Q_015,
params![name],
|row| {
let is_subscribed_int: i32 = row.get(4)?;
@@ -277,12 +253,15 @@ pub fn get_folder(conn: &Connection, name: &str) -> Result<Option<CachedFolder>,
pub fn delete_folder_and_messages(conn: &Connection, folder_name: &str) -> Result<(), String> {
conn.execute(
"DELETE FROM messages WHERE folder = ?1",
crate::queries::sqlite::queries::db_folders::Q_016,
params![folder_name],
)
.map_err(|e| format!("Failed to delete folder messages: {e}"))?;
conn.execute("DELETE FROM folders WHERE name = ?1", params![folder_name])
.map_err(|e| format!("Failed to delete folder: {e}"))?;
conn.execute(
crate::queries::sqlite::queries::db_folders::Q_017,
params![folder_name],
)
.map_err(|e| format!("Failed to delete folder: {e}"))?;
Ok(())
}
@@ -294,41 +273,44 @@ pub fn rename_folder_in_cache(
let tx = conn
.unchecked_transaction()
.map_err(|e| format!("Failed to start folder rename transaction: {e}"))?;
tx.execute_batch("PRAGMA defer_foreign_keys = ON;")
tx.execute_batch(crate::queries::sqlite::queries::db_folders::Q_018)
.map_err(|e| format!("Failed to defer folder rename foreign keys: {e}"))?;
let inserted = tx.execute(
"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
FROM folders WHERE name = ?2",
params![new_name, old_name],
).map_err(|e| format!("Failed to create renamed folder cache entry: {e}"))?;
let inserted = tx
.execute(
crate::queries::sqlite::queries::db_folders::Q_019,
params![new_name, old_name],
)
.map_err(|e| format!("Failed to create renamed folder cache entry: {e}"))?;
if inserted == 0 {
return Err(format!("Folder not found in cache: {old_name}"));
}
tx.execute(
"UPDATE messages SET folder = ?1 WHERE folder = ?2",
crate::queries::sqlite::queries::db_folders::Q_020,
params![new_name, old_name],
)
.map_err(|e| format!("Failed to rename messages folder: {e}"))?;
tx.execute(
"UPDATE message_tags SET message_folder = ?1 WHERE message_folder = ?2",
crate::queries::sqlite::queries::db_folders::Q_021,
params![new_name, old_name],
)
.map_err(|e| format!("Failed to rename message tag folder references: {e}"))?;
tx.execute(
"UPDATE calendar_events SET source_message_folder = ?1 WHERE source_message_folder = ?2",
crate::queries::sqlite::queries::db_folders::Q_022,
params![new_name, old_name],
)
.ok();
tx.execute(
"UPDATE folders SET parent = ?1 WHERE parent = ?2",
crate::queries::sqlite::queries::db_folders::Q_023,
params![new_name, old_name],
)
.map_err(|e| format!("Failed to update child folder parent: {e}"))?;
tx.execute("DELETE FROM folders WHERE name = ?1", params![old_name])
.map_err(|e| format!("Failed to remove old folder cache entry: {e}"))?;
tx.execute(
crate::queries::sqlite::queries::db_folders::Q_024,
params![old_name],
)
.map_err(|e| format!("Failed to remove old folder cache entry: {e}"))?;
tx.commit()
.map_err(|e| format!("Failed to commit folder rename: {e}"))?;
Ok(())
@@ -339,7 +321,7 @@ pub fn find_folder_by_attribute(
attribute: &str,
) -> Result<Option<String>, String> {
conn.query_row(
"SELECT name FROM folders WHERE flags LIKE '%' || ?1 || '%' LIMIT 1",
crate::queries::sqlite::queries::db_folders::Q_025,
[attribute],
|row| row.get(0),
)
@@ -460,17 +442,14 @@ mod tests {
fn sync_metadata_preserves_message_sync_timestamp() {
let conn = open_test_db();
insert_folder_if_new(&conn, "Sent", None, "\\Sent").unwrap();
conn.execute(
"UPDATE folders SET messages_updated_at = '2000-01-01 00:00:00' WHERE name = 'Sent'",
[],
)
.unwrap();
conn.execute(crate::queries::sqlite::queries::db_folders::Q_026, [])
.unwrap();
update_folder_sync_status(&conn, "Sent", 1, 5, 42).unwrap();
let timestamp: String = conn
.query_row(
"SELECT messages_updated_at FROM folders WHERE name = 'Sent'",
crate::queries::sqlite::queries::db_folders::Q_027,
[],
|row| row.get(0),
)
+3 -7
View File
@@ -9,7 +9,7 @@ impl HeaderSync {
pub(crate) fn capture(conn: &Connection) -> Result<Self, String> {
let generation = conn
.query_row(
"SELECT value FROM user_meta WHERE key = 'header_sync_generation'",
crate::queries::sqlite::queries::db_header_sync::Q_001,
[],
|row| row.get::<_, String>(0),
)
@@ -32,11 +32,7 @@ impl HeaderSync {
}
pub(crate) fn invalidate(conn: &Transaction<'_>) -> Result<(), String> {
conn.execute(
"INSERT INTO user_meta (key, value) VALUES ('header_sync_generation', '1')
ON CONFLICT(key) DO UPDATE SET value = CAST(value AS INTEGER) + 1",
[],
)
.map_err(|e| e.to_string())?;
conn.execute(crate::queries::sqlite::queries::db_header_sync::Q_002, [])
.map_err(|e| e.to_string())?;
Ok(())
}
+193 -61
View File
@@ -1,4 +1,5 @@
use rusqlite::{params, Connection};
use crate::queries::sqlite::queries::db_identities::IDENTITY_SELECT_COLS;
use rusqlite::{Connection, params};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize)]
@@ -26,22 +27,37 @@ pub struct Identity {
#[derive(Debug, Deserialize)]
pub struct CreateIdentity {
#[serde(default)] pub display_name: String,
#[serde(default)]
pub display_name: String,
pub email: String,
#[serde(default)] pub signature_html: String,
#[serde(default)] pub is_default: bool,
#[serde(default)] pub reply_to: String,
#[serde(default)] pub bcc: String,
#[serde(default)] pub organization: String,
#[serde(default)] pub signature_text: String,
#[serde(default = "default_true")] pub signature_new: bool,
#[serde(default = "default_true")] pub signature_reply: bool,
#[serde(default = "default_true")] pub signature_forward: bool,
#[serde(default = "default_signature_position")] pub signature_position: String,
#[serde(default)] pub pgp_fingerprint: Option<String>,
#[serde(default)] pub pgp_sign_default: bool,
#[serde(default)] pub pgp_encrypt_default: bool,
#[serde(default)] pub pgp_inline_default: bool,
#[serde(default)]
pub signature_html: String,
#[serde(default)]
pub is_default: bool,
#[serde(default)]
pub reply_to: String,
#[serde(default)]
pub bcc: String,
#[serde(default)]
pub organization: String,
#[serde(default)]
pub signature_text: String,
#[serde(default = "default_true")]
pub signature_new: bool,
#[serde(default = "default_true")]
pub signature_reply: bool,
#[serde(default = "default_true")]
pub signature_forward: bool,
#[serde(default = "default_signature_position")]
pub signature_position: String,
#[serde(default)]
pub pgp_fingerprint: Option<String>,
#[serde(default)]
pub pgp_sign_default: bool,
#[serde(default)]
pub pgp_encrypt_default: bool,
#[serde(default)]
pub pgp_inline_default: bool,
}
#[derive(Debug, Deserialize)]
@@ -64,11 +80,19 @@ pub struct UpdateIdentity {
pub pgp_inline_default: Option<bool>,
}
fn default_true() -> bool { true }
fn default_signature_position() -> String { "above".to_string() }
fn default_true() -> bool {
true
}
fn default_signature_position() -> String {
"above".to_string()
}
fn validate_signature_position(value: &str) -> Result<(), String> {
if matches!(value, "above" | "below") { Ok(()) } else { Err(format!("Invalid signature_position: {value}")) }
if matches!(value, "above" | "below") {
Ok(())
} else {
Err(format!("Invalid signature_position: {value}"))
}
}
fn row_to_identity(row: &rusqlite::Row<'_>) -> rusqlite::Result<Identity> {
@@ -95,17 +119,20 @@ fn row_to_identity(row: &rusqlite::Row<'_>) -> rusqlite::Result<Identity> {
})
}
const IDENTITY_SELECT_COLS: &str = "id, display_name, email, signature_html, is_default, created_at, updated_at, reply_to, bcc, organization, signature_text, signature_new, signature_reply, signature_forward, signature_position, pgp_fingerprint, pgp_sign_default, pgp_encrypt_default, pgp_inline_default";
pub fn list_identities(conn: &Connection) -> Result<Vec<Identity>, String> {
let sql = format!("SELECT {IDENTITY_SELECT_COLS} FROM identities ORDER BY is_default DESC, email ASC");
let mut stmt = conn.prepare(&sql).map_err(|e| format!("Failed to prepare list_identities: {e}"))?;
let rows = stmt.query_map([], row_to_identity).map_err(|e| format!("Failed to query identities: {e}"))?;
rows.map(|row| row.map_err(|e| format!("Failed to read identity row: {e}"))).collect()
let sql = crate::rustbox_query_db_identities_001!();
let mut stmt = conn
.prepare(&sql)
.map_err(|e| format!("Failed to prepare list_identities: {e}"))?;
let rows = stmt
.query_map([], row_to_identity)
.map_err(|e| format!("Failed to query identities: {e}"))?;
rows.map(|row| row.map_err(|e| format!("Failed to read identity row: {e}")))
.collect()
}
pub fn get_identity(conn: &Connection, id: i64) -> Result<Option<Identity>, String> {
let sql = format!("SELECT {IDENTITY_SELECT_COLS} FROM identities WHERE id = ?1");
let sql = crate::rustbox_query_db_identities_002!();
match conn.query_row(&sql, params![id], row_to_identity) {
Ok(identity) => Ok(Some(identity)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
@@ -115,7 +142,7 @@ pub fn get_identity(conn: &Connection, id: i64) -> Result<Option<Identity>, Stri
#[allow(dead_code)]
pub fn get_default_identity(conn: &Connection) -> Result<Option<Identity>, String> {
let sql = format!("SELECT {IDENTITY_SELECT_COLS} FROM identities WHERE is_default = 1 LIMIT 1");
let sql = crate::rustbox_query_db_identities_003!();
match conn.query_row(&sql, [], row_to_identity) {
Ok(identity) => Ok(Some(identity)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
@@ -125,49 +152,127 @@ pub fn get_default_identity(conn: &Connection) -> Result<Option<Identity>, Strin
pub fn create_identity(conn: &Connection, data: &CreateIdentity) -> Result<Identity, String> {
validate_signature_position(&data.signature_position)?;
if data.is_default { clear_default(conn)?; }
if data.is_default {
clear_default(conn)?;
}
conn.execute(
"INSERT INTO identities (display_name,email,signature_html,is_default,reply_to,bcc,organization,signature_text,signature_new,signature_reply,signature_forward,signature_position,pgp_fingerprint,pgp_sign_default,pgp_encrypt_default,pgp_inline_default) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16)",
params![data.display_name, data.email, data.signature_html, data.is_default as i32, data.reply_to, data.bcc, data.organization, data.signature_text, data.signature_new as i32, data.signature_reply as i32, data.signature_forward as i32, data.signature_position, data.pgp_fingerprint, data.pgp_sign_default as i32, data.pgp_encrypt_default as i32, data.pgp_inline_default as i32],
).map_err(|e| format!("Failed to create identity: {e}"))?;
crate::queries::sqlite::queries::db_identities::Q_001,
params![
data.display_name,
data.email,
data.signature_html,
data.is_default as i32,
data.reply_to,
data.bcc,
data.organization,
data.signature_text,
data.signature_new as i32,
data.signature_reply as i32,
data.signature_forward as i32,
data.signature_position,
data.pgp_fingerprint,
data.pgp_sign_default as i32,
data.pgp_encrypt_default as i32,
data.pgp_inline_default as i32
],
)
.map_err(|e| format!("Failed to create identity: {e}"))?;
let id = conn.last_insert_rowid();
get_identity(conn, id)?.ok_or_else(|| "Failed to read back created identity".to_string())
}
pub fn update_identity(conn: &Connection, id: i64, data: &UpdateIdentity) -> Result<Option<Identity>, String> {
if get_identity(conn, id)?.is_none() { return Ok(None); }
if data.is_default == Some(true) { clear_default(conn)?; }
if let Some(value) = data.signature_position.as_deref() { validate_signature_position(value)?; }
pub fn update_identity(
conn: &Connection,
id: i64,
data: &UpdateIdentity,
) -> Result<Option<Identity>, String> {
if get_identity(conn, id)?.is_none() {
return Ok(None);
}
if data.is_default == Some(true) {
clear_default(conn)?;
}
if let Some(value) = data.signature_position.as_deref() {
validate_signature_position(value)?;
}
let mut sets = Vec::new();
let mut values: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
let mut idx = 1usize;
macro_rules! text { ($field:ident,$column:literal) => { if let Some(ref value)=data.$field { sets.push(format!(concat!($column," = ?{}"),idx)); values.push(Box::new(value.clone())); idx += 1; } }; }
macro_rules! boolean { ($field:ident,$column:literal) => { if let Some(value)=data.$field { sets.push(format!(concat!($column," = ?{}"),idx)); values.push(Box::new(value as i32)); idx += 1; } }; }
text!(display_name,"display_name"); text!(email,"email"); text!(signature_html,"signature_html"); boolean!(is_default,"is_default");
text!(reply_to,"reply_to"); text!(bcc,"bcc"); text!(organization,"organization"); text!(signature_text,"signature_text");
boolean!(signature_new,"signature_new"); boolean!(signature_reply,"signature_reply"); boolean!(signature_forward,"signature_forward"); text!(signature_position,"signature_position");
if let Some(ref value) = data.pgp_fingerprint { sets.push(format!("pgp_fingerprint = ?{}", idx)); values.push(Box::new(value.clone())); idx += 1; }
boolean!(pgp_sign_default,"pgp_sign_default"); boolean!(pgp_encrypt_default,"pgp_encrypt_default"); boolean!(pgp_inline_default,"pgp_inline_default");
if sets.is_empty() { return get_identity(conn,id); }
macro_rules! text {
($field:ident,$column:literal) => {
if let Some(ref value) = data.$field {
sets.push(format!(concat!($column, " = ?{}"), idx));
values.push(Box::new(value.clone()));
idx += 1;
}
};
}
macro_rules! boolean {
($field:ident,$column:literal) => {
if let Some(value) = data.$field {
sets.push(format!(concat!($column, " = ?{}"), idx));
values.push(Box::new(value as i32));
idx += 1;
}
};
}
text!(display_name, "display_name");
text!(email, "email");
text!(signature_html, "signature_html");
boolean!(is_default, "is_default");
text!(reply_to, "reply_to");
text!(bcc, "bcc");
text!(organization, "organization");
text!(signature_text, "signature_text");
boolean!(signature_new, "signature_new");
boolean!(signature_reply, "signature_reply");
boolean!(signature_forward, "signature_forward");
text!(signature_position, "signature_position");
if let Some(ref value) = data.pgp_fingerprint {
sets.push(format!("pgp_fingerprint = ?{}", idx));
values.push(Box::new(value.clone()));
idx += 1;
}
boolean!(pgp_sign_default, "pgp_sign_default");
boolean!(pgp_encrypt_default, "pgp_encrypt_default");
boolean!(pgp_inline_default, "pgp_inline_default");
if sets.is_empty() {
return get_identity(conn, id);
}
sets.push("updated_at = datetime('now')".to_string());
let sql = format!("UPDATE identities SET {} WHERE id = ?{}", sets.join(", "), idx);
let sql = crate::rustbox_query_db_identities_004!(sets.join(", "), idx);
values.push(Box::new(id));
let refs: Vec<&dyn rusqlite::types::ToSql> = values.iter().map(|value| value.as_ref()).collect();
conn.execute(&sql, refs.as_slice()).map_err(|e| format!("Failed to update identity: {e}"))?;
get_identity(conn,id)
let refs: Vec<&dyn rusqlite::types::ToSql> =
values.iter().map(|value| value.as_ref()).collect();
conn.execute(&sql, refs.as_slice())
.map_err(|e| format!("Failed to update identity: {e}"))?;
get_identity(conn, id)
}
pub fn delete_identity(conn: &Connection, id: i64) -> Result<bool, String> {
Ok(conn.execute("DELETE FROM identities WHERE id = ?1", params![id]).map_err(|e| format!("Failed to delete identity: {e}"))? > 0)
Ok(conn
.execute(
crate::queries::sqlite::queries::db_identities::Q_002,
params![id],
)
.map_err(|e| format!("Failed to delete identity: {e}"))?
> 0)
}
pub fn has_identities(conn: &Connection) -> Result<bool, String> {
let count: i64 = conn.query_row("SELECT COUNT(*) FROM identities", [], |row| row.get(0)).map_err(|e| format!("Failed to count identities: {e}"))?;
let count: i64 = conn
.query_row(
crate::queries::sqlite::queries::db_identities::Q_003,
[],
|row| row.get(0),
)
.map_err(|e| format!("Failed to count identities: {e}"))?;
Ok(count > 0)
}
fn clear_default(conn: &Connection) -> Result<(), String> {
conn.execute("UPDATE identities SET is_default = 0, updated_at = datetime('now') WHERE is_default = 1", []).map_err(|e| format!("Failed to clear default identity: {e}"))?;
conn.execute(crate::queries::sqlite::queries::db_identities::Q_004, [])
.map_err(|e| format!("Failed to clear default identity: {e}"))?;
Ok(())
}
@@ -177,28 +282,55 @@ mod tests {
use crate::db::pool::open_test_db;
fn sample(email: &str, is_default: bool) -> CreateIdentity {
CreateIdentity { display_name:"Alice".into(), email:email.into(), signature_html:"<p>Best</p>".into(), is_default, reply_to:String::new(), bcc:String::new(), organization:String::new(), signature_text:"Best".into(), signature_new:true, signature_reply:true, signature_forward:true, signature_position:"above".into(), pgp_fingerprint:None, pgp_sign_default:false, pgp_encrypt_default:false, pgp_inline_default:false }
CreateIdentity {
display_name: "Alice".into(),
email: email.into(),
signature_html: "<p>Best</p>".into(),
is_default,
reply_to: String::new(),
bcc: String::new(),
organization: String::new(),
signature_text: "Best".into(),
signature_new: true,
signature_reply: true,
signature_forward: true,
signature_position: "above".into(),
pgp_fingerprint: None,
pgp_sign_default: false,
pgp_encrypt_default: false,
pgp_inline_default: false,
}
}
#[test]
fn create_and_extended_options_roundtrip() {
let conn=open_test_db();
let mut value=sample("alice@example.com", true);
value.reply_to="reply@example.com".into(); value.bcc="archive@example.com".into(); value.organization="Example".into(); value.signature_position="below".into();
let identity=create_identity(&conn,&value).unwrap();
assert_eq!(identity.reply_to,"reply@example.com"); assert_eq!(identity.bcc,"archive@example.com"); assert_eq!(identity.organization,"Example"); assert_eq!(identity.signature_position,"below");
let conn = open_test_db();
let mut value = sample("alice@example.com", true);
value.reply_to = "reply@example.com".into();
value.bcc = "archive@example.com".into();
value.organization = "Example".into();
value.signature_position = "below".into();
let identity = create_identity(&conn, &value).unwrap();
assert_eq!(identity.reply_to, "reply@example.com");
assert_eq!(identity.bcc, "archive@example.com");
assert_eq!(identity.organization, "Example");
assert_eq!(identity.signature_position, "below");
}
#[test]
fn only_one_default() {
let conn=open_test_db();
let first=create_identity(&conn,&sample("a@example.com",true)).unwrap();
let second=create_identity(&conn,&sample("b@example.com",true)).unwrap();
assert!(second.is_default); assert!(!get_identity(&conn,first.id).unwrap().unwrap().is_default);
let conn = open_test_db();
let first = create_identity(&conn, &sample("a@example.com", true)).unwrap();
let second = create_identity(&conn, &sample("b@example.com", true)).unwrap();
assert!(second.is_default);
assert!(!get_identity(&conn, first.id).unwrap().unwrap().is_default);
}
#[test]
fn rejects_invalid_signature_position() {
let conn=open_test_db(); let mut value=sample("a@example.com",false); value.signature_position="side".into(); assert!(create_identity(&conn,&value).is_err());
let conn = open_test_db();
let mut value = sample("a@example.com", false);
value.signature_position = "side".into();
assert!(create_identity(&conn, &value).is_err());
}
}
+70 -36
View File
@@ -32,7 +32,7 @@ pub struct UpdateMailPreferences {
pub fn get_preferences(conn: &Connection) -> Result<MailPreferences, String> {
conn.query_row(
"SELECT messages_per_page, mark_read_delay_ms, remote_images, preferred_body, thread_mode, collapse_quotes, show_snippets, default_sort, flagged_on_top, sync_imap_keywords, updated_at FROM mail_preferences WHERE id = 1",
crate::queries::sqlite::queries::db_mail_preferences::Q_001,
[],
|row| {
Ok(MailPreferences {
@@ -57,58 +57,92 @@ pub fn update_preferences(
conn: &Connection,
data: &UpdateMailPreferences,
) -> Result<MailPreferences, String> {
conn.execute("INSERT OR IGNORE INTO mail_preferences (id) VALUES (1)", [])
.map_err(|error| format!("Failed to initialize mail preferences: {error}"))?;
conn.execute(
crate::queries::sqlite::queries::db_mail_preferences::Q_002,
[],
)
.map_err(|error| format!("Failed to initialize mail preferences: {error}"))?;
if let Some(value) = data.messages_per_page {
if !(20..=200).contains(&value) {
return Err("messages_per_page must be between 20 and 200".to_string());
}
if let Some(value) = data.messages_per_page
&& !(20..=200).contains(&value)
{
return Err("messages_per_page must be between 20 and 200".to_string());
}
if let Some(value) = data.mark_read_delay_ms {
if value > 30_000 {
return Err("mark_read_delay_ms must be between 0 and 30000".to_string());
}
if let Some(value) = data.mark_read_delay_ms
&& value > 30_000
{
return Err("mark_read_delay_ms must be between 0 and 30000".to_string());
}
if let Some(ref value) = data.remote_images {
if !matches!(value.as_str(), "never" | "contacts" | "ask" | "always") {
return Err("remote_images must be never, contacts, ask or always".to_string());
}
if let Some(ref value) = data.remote_images
&& !matches!(value.as_str(), "never" | "contacts" | "ask" | "always")
{
return Err("remote_images must be never, contacts, ask or always".to_string());
}
if let Some(ref value) = data.preferred_body {
if !matches!(value.as_str(), "auto" | "html" | "text") {
return Err("preferred_body must be auto, html or text".to_string());
}
if let Some(ref value) = data.preferred_body
&& !matches!(value.as_str(), "auto" | "html" | "text")
{
return Err("preferred_body must be auto, html or text".to_string());
}
if let Some(ref value) = data.thread_mode {
if !matches!(value.as_str(), "threaded" | "flat") {
return Err("thread_mode must be threaded or flat".to_string());
}
if let Some(ref value) = data.thread_mode
&& !matches!(value.as_str(), "threaded" | "flat")
{
return Err("thread_mode must be threaded or flat".to_string());
}
if let Some(ref value) = data.default_sort {
if !matches!(
if let Some(ref value) = data.default_sort
&& !matches!(
value.as_str(),
"date_desc" | "date_asc" | "sender_asc" | "sender_desc" | "subject_asc" | "subject_desc" | "size_asc" | "size_desc"
) {
return Err("unsupported default_sort".to_string());
}
"date_desc"
| "date_asc"
| "sender_asc"
| "sender_desc"
| "subject_asc"
| "subject_desc"
| "size_asc"
| "size_desc"
)
{
return Err("unsupported default_sort".to_string());
}
let current = get_preferences(conn)?;
let messages_per_page = data.messages_per_page.unwrap_or(current.messages_per_page);
let mark_read_delay_ms = data.mark_read_delay_ms.unwrap_or(current.mark_read_delay_ms);
let remote_images = data.remote_images.as_deref().unwrap_or(&current.remote_images);
let preferred_body = data.preferred_body.as_deref().unwrap_or(&current.preferred_body);
let mark_read_delay_ms = data
.mark_read_delay_ms
.unwrap_or(current.mark_read_delay_ms);
let remote_images = data
.remote_images
.as_deref()
.unwrap_or(&current.remote_images);
let preferred_body = data
.preferred_body
.as_deref()
.unwrap_or(&current.preferred_body);
let thread_mode = data.thread_mode.as_deref().unwrap_or(&current.thread_mode);
let collapse_quotes = data.collapse_quotes.unwrap_or(current.collapse_quotes);
let show_snippets = data.show_snippets.unwrap_or(current.show_snippets);
let default_sort = data.default_sort.as_deref().unwrap_or(&current.default_sort);
let default_sort = data
.default_sort
.as_deref()
.unwrap_or(&current.default_sort);
let flagged_on_top = data.flagged_on_top.unwrap_or(current.flagged_on_top);
let sync_imap_keywords = data.sync_imap_keywords.unwrap_or(current.sync_imap_keywords);
let sync_imap_keywords = data
.sync_imap_keywords
.unwrap_or(current.sync_imap_keywords);
conn.execute(
"UPDATE mail_preferences SET messages_per_page = ?1, mark_read_delay_ms = ?2, remote_images = ?3, preferred_body = ?4, thread_mode = ?5, collapse_quotes = ?6, show_snippets = ?7, default_sort = ?8, flagged_on_top = ?9, sync_imap_keywords = ?10, updated_at = datetime('now') WHERE id = 1",
rusqlite::params![messages_per_page, mark_read_delay_ms, remote_images, preferred_body, thread_mode, collapse_quotes as i32, show_snippets as i32, default_sort, flagged_on_top as i32, sync_imap_keywords as i32],
crate::queries::sqlite::queries::db_mail_preferences::Q_003,
rusqlite::params![
messages_per_page,
mark_read_delay_ms,
remote_images,
preferred_body,
thread_mode,
collapse_quotes as i32,
show_snippets as i32,
default_sort,
flagged_on_top as i32,
sync_imap_keywords as i32
],
)
.map_err(|error| format!("Failed to update mail preferences: {error}"))?;
+38 -78
View File
@@ -1,3 +1,4 @@
use crate::queries::sqlite::queries::db_messages::MSG_SELECT_COLS;
use rusqlite::{Connection, params};
use serde::Serialize;
@@ -35,7 +36,7 @@ pub struct CachedMessage {
}
fn row_to_cached_message(row: &rusqlite::Row<'_>) -> rusqlite::Result<CachedMessage> {
let has_attachments_int: i32 = row.get(12)?;
let has_attachments_int: i32 = row.get(13)?;
Ok(CachedMessage {
uid: row.get(0)?,
folder: row.get(1)?,
@@ -49,17 +50,13 @@ fn row_to_cached_message(row: &rusqlite::Row<'_>) -> rusqlite::Result<CachedMess
cc_addresses: row.get(9)?,
date: row.get(10)?,
flags: row.get(11)?,
size: row.get(13)?,
size: row.get(12)?,
has_attachments: has_attachments_int != 0,
snippet: row.get(14)?,
reaction: row.get(15)?,
})
}
const MSG_SELECT_COLS: &str = "uid, folder, message_id, in_reply_to, references_header,
subject, from_address, from_name, to_addresses, cc_addresses,
date, flags, has_attachments, size, snippet, reaction";
#[allow(clippy::too_many_arguments)]
pub fn upsert_message(
conn: &Connection,
@@ -82,11 +79,7 @@ pub fn upsert_message(
) -> Result<(), String> {
let date_epoch = parse_date_to_epoch(date);
conn.execute(
"INSERT OR REPLACE INTO messages
(uid, folder, message_id, in_reply_to, references_header,
subject, from_address, from_name, to_addresses, cc_addresses,
date, flags, size, has_attachments, snippet, date_epoch, reaction)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17)",
crate::queries::sqlite::queries::db_messages::Q_001,
params![
uid,
folder,
@@ -123,14 +116,17 @@ pub fn remove_after_mutation(conn: &Connection, folder: &str, uid: u32) -> Resul
pub fn delete_folder_messages(conn: &Connection, folder: &str) -> Result<usize, String> {
let deleted = conn
.execute("DELETE FROM messages WHERE folder = ?1", params![folder])
.execute(
crate::queries::sqlite::queries::db_messages::Q_002,
params![folder],
)
.map_err(|e| format!("Failed to delete folder messages: {e}"))?;
Ok(deleted)
}
pub fn max_uid(conn: &Connection, folder: &str) -> Result<u32, String> {
conn.query_row(
"SELECT COALESCE(MAX(uid), 0) FROM messages WHERE folder = ?1",
crate::queries::sqlite::queries::db_messages::Q_003,
params![folder],
|row| row.get(0),
)
@@ -139,11 +135,7 @@ pub fn max_uid(conn: &Connection, folder: &str) -> Result<u32, String> {
pub fn list_index_source_messages(conn: &Connection) -> Result<Vec<IndexSourceMessage>, String> {
let mut stmt = conn
.prepare(
"SELECT uid, folder, subject, from_address, from_name, to_addresses, \
COALESCE(NULLIF(body_text, ''), snippet, ''), date_epoch, has_attachments \
FROM messages ORDER BY folder, uid",
)
.prepare(crate::queries::sqlite::queries::db_messages::Q_004)
.map_err(|e| format!("Failed to prepare index rebuild query: {e}"))?;
let rows = stmt
@@ -261,13 +253,7 @@ pub fn get_messages(
per_page: u32,
) -> Result<Vec<CachedMessage>, String> {
let offset = page * per_page;
let sql = format!(
"SELECT {MSG_SELECT_COLS}
FROM messages
WHERE folder = ?1
ORDER BY date_epoch DESC
LIMIT ?2 OFFSET ?3"
);
let sql = crate::rustbox_query_db_messages_001!();
let mut stmt = conn
.prepare(&sql)
@@ -318,13 +304,7 @@ pub fn get_messages_sorted(
};
let offset = page * per_page;
let sql = format!(
"SELECT {MSG_SELECT_COLS}
FROM messages
WHERE folder = ?1
ORDER BY {order_by}
LIMIT ?2 OFFSET ?3"
);
let sql = crate::rustbox_query_db_messages_002!(&order_by);
let mut stmt = conn
.prepare(&sql)
.map_err(|e| format!("Failed to prepare sorted get_messages: {e}"))?;
@@ -337,7 +317,7 @@ pub fn get_messages_sorted(
pub fn count_messages(conn: &Connection, folder: &str) -> Result<u32, String> {
conn.query_row(
"SELECT COUNT(*) FROM messages WHERE folder = ?1",
crate::queries::sqlite::queries::db_messages::Q_005,
params![folder],
|row| row.get(0),
)
@@ -346,7 +326,11 @@ pub fn count_messages(conn: &Connection, folder: &str) -> Result<u32, String> {
pub fn count_all_messages(conn: &Connection) -> Result<usize, String> {
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM messages", [], |row| row.get(0))
.query_row(
crate::queries::sqlite::queries::db_messages::Q_006,
[],
|row| row.get(0),
)
.map_err(|e| format!("Failed to count cached messages: {e}"))?;
usize::try_from(count).map_err(|_| "Cached message count is out of range".to_string())
}
@@ -358,7 +342,7 @@ pub fn update_message_flags(
flags_csv: &str,
) -> Result<(), String> {
conn.execute(
"UPDATE messages SET flags = ?1 WHERE folder = ?2 AND uid = ?3",
crate::queries::sqlite::queries::db_messages::Q_007,
params![flags_csv, folder, uid],
)
.map_err(|e| format!("Failed to update message flags: {e}"))?;
@@ -377,10 +361,7 @@ pub fn cache_message_body(
email_theme: Option<i32>,
) -> Result<(), String> {
conn.execute(
"UPDATE messages
SET body_html = ?1, body_text = ?2, body_cached = 1,
attachments_json = ?3, raw_headers = ?4, email_theme = ?5
WHERE folder = ?6 AND uid = ?7",
crate::queries::sqlite::queries::db_messages::Q_008,
params![
html,
text,
@@ -402,7 +383,7 @@ pub fn update_email_theme(
email_theme: i32,
) -> Result<(), String> {
conn.execute(
"UPDATE messages SET email_theme = ?1 WHERE folder = ?2 AND uid = ?3",
crate::queries::sqlite::queries::db_messages::Q_009,
params![email_theme, folder, uid],
)
.map_err(|e| format!("Failed to update email theme: {e}"))?;
@@ -423,9 +404,7 @@ pub fn get_cached_body(
uid: u32,
) -> Result<Option<CachedBody>, String> {
let result = conn.query_row(
"SELECT body_cached, body_html, body_text, attachments_json, raw_headers, email_theme
FROM messages
WHERE folder = ?1 AND uid = ?2",
crate::queries::sqlite::queries::db_messages::Q_010,
params![folder, uid],
|row| {
let cached: i32 = row.get(0)?;
@@ -455,13 +434,9 @@ pub fn get_single_message(
folder: &str,
uid: u32,
) -> Result<Option<CachedMessage>, String> {
let sql = format!(
"SELECT {MSG_SELECT_COLS}
FROM messages
WHERE folder = ?1 AND uid = ?2"
);
let sql = crate::rustbox_query_db_messages_003!();
let result = conn.query_row(&sql, params![folder, uid], |row| row_to_cached_message(row));
let result = conn.query_row(&sql, params![folder, uid], row_to_cached_message);
match result {
Ok(msg) => Ok(Some(msg)),
@@ -472,7 +447,7 @@ pub fn get_single_message(
pub fn delete_message(conn: &Connection, folder: &str, uid: u32) -> Result<(), String> {
conn.execute(
"DELETE FROM messages WHERE folder = ?1 AND uid = ?2",
crate::queries::sqlite::queries::db_messages::Q_011,
params![folder, uid],
)
.map_err(|e| format!("Failed to delete message: {e}"))?;
@@ -481,12 +456,7 @@ pub fn delete_message(conn: &Connection, folder: &str, uid: u32) -> Result<(), S
pub fn get_unindexed_messages(conn: &Connection, limit: u32) -> Result<Vec<(String, u32)>, String> {
let mut stmt = conn
.prepare(
"SELECT folder, uid FROM messages
WHERE body_cached = 0
ORDER BY date_epoch DESC
LIMIT ?1",
)
.prepare(crate::queries::sqlite::queries::db_messages::Q_012)
.map_err(|e| format!("Failed to prepare get_unindexed_messages: {e}"))?;
let rows = stmt
@@ -507,7 +477,7 @@ pub fn get_all_uids_and_flags(
folder: &str,
) -> Result<Vec<(u32, String)>, String> {
let mut stmt = conn
.prepare("SELECT uid, flags FROM messages WHERE folder = ?1")
.prepare(crate::queries::sqlite::queries::db_messages::Q_013)
.map_err(|e| format!("Failed to prepare get_all_uids_and_flags: {e}"))?;
let rows = stmt
.query_map(params![folder], |row| {
@@ -528,14 +498,7 @@ pub fn get_thread_messages(
) -> Result<Vec<CachedMessage>, String> {
let escaped_target = target_message_id.replace("%", "\\%").replace("_", "\\_");
let like_pattern = format!("%{}%", escaped_target);
let sql = format!(
"SELECT {MSG_SELECT_COLS}
FROM messages
WHERE message_id = ?1
OR in_reply_to = ?1
OR references_header LIKE ?2 ESCAPE '\\'
ORDER BY date_epoch ASC"
);
let sql = crate::rustbox_query_db_messages_004!();
let mut stmt = conn
.prepare(&sql)
@@ -732,14 +695,7 @@ pub fn search_messages_sqlite(
format!("WHERE {}", conditions.join(" AND "))
};
let sql = format!(
"SELECT {MSG_SELECT_COLS}
FROM messages
{where_clause}
ORDER BY date_epoch DESC
LIMIT ?{}",
param_values.len() + 1
);
let sql = crate::rustbox_query_db_messages_005!(&where_clause, param_values.len() + 1);
param_values.push(Box::new(limit as i64));
@@ -763,6 +719,15 @@ pub fn search_messages_sqlite(
Ok(results)
}
pub fn cached_storage_bytes(conn: &Connection) -> Result<Option<u64>, String> {
conn.query_row(
crate::queries::sqlite::queries::db_messages::Q_014,
[],
|row| row.get(0),
)
.map_err(|e| e.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1314,8 +1279,3 @@ mod tests {
assert_eq!(thread[0].uid, 1);
}
}
pub fn cached_storage_bytes(conn: &Connection) -> Result<Option<u64>, String> {
conn.query_row("SELECT SUM(size) FROM messages", [], |row| row.get(0))
.map_err(|e| e.to_string())
}
+2 -2
View File
@@ -3,16 +3,16 @@ pub mod contact_groups;
pub mod contacts;
pub mod display_preferences;
pub mod drafts;
pub mod folders;
pub mod folder_preferences;
pub mod folders;
pub(crate) mod header_sync;
pub mod identities;
pub mod mail_preferences;
pub mod messages;
pub mod notification_preferences;
pub mod pgp;
pub mod response_templates;
pub mod pool;
pub mod response_templates;
pub mod special_folders;
pub mod tags;
pub mod trusted_senders;
+12 -4
View File
@@ -24,7 +24,7 @@ pub struct UpdateNotificationPreferences {
pub fn get_preferences(conn: &Connection) -> Result<NotificationPreferences, String> {
let result = conn.query_row(
"SELECT enabled, sound, folders, dnd_enabled, dnd_start, dnd_end, updated_at FROM notification_preferences WHERE id = 1",
crate::queries::sqlite::queries::db_notification_preferences::Q_001,
[],
|row| {
let enabled_int: i32 = row.get(0)?;
@@ -34,7 +34,15 @@ pub fn get_preferences(conn: &Connection) -> Result<NotificationPreferences, Str
let dnd_start: String = row.get(4)?;
let dnd_end: String = row.get(5)?;
let updated_at: String = row.get(6)?;
Ok((enabled_int, sound_int, folders_json, dnd_enabled_int, dnd_start, dnd_end, updated_at))
Ok((
enabled_int,
sound_int,
folders_json,
dnd_enabled_int,
dnd_start,
dnd_end,
updated_at,
))
},
);
@@ -78,7 +86,7 @@ pub fn update_preferences(
data: &UpdateNotificationPreferences,
) -> Result<NotificationPreferences, String> {
conn.execute(
"INSERT OR IGNORE INTO notification_preferences (id) VALUES (1)",
crate::queries::sqlite::queries::db_notification_preferences::Q_002,
[],
)
.map_err(|e| format!("Failed to ensure preferences row: {e}"))?;
@@ -132,7 +140,7 @@ pub fn update_preferences(
sets.push("updated_at = datetime('now')".to_string());
let set_clause = sets.join(", ");
let sql = format!("UPDATE notification_preferences SET {set_clause} WHERE id = ?{idx}");
let sql = crate::rustbox_query_db_notification_preferences_001!(&set_clause, idx);
values.push(Box::new(1_i32));
let params_refs: Vec<&dyn rusqlite::types::ToSql> = values.iter().map(|v| v.as_ref()).collect();
+48 -32
View File
@@ -1,4 +1,5 @@
use rusqlite::{params, Connection};
use crate::queries::sqlite::queries::db_pgp::KEY_COLS;
use rusqlite::{Connection, params};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize)]
@@ -48,22 +49,23 @@ fn row_to_key(row: &rusqlite::Row<'_>) -> rusqlite::Result<PgpKeyInternal> {
})
}
const KEY_COLS: &str = "fingerprint,key_id,email,user_id,public_key_armor,secret_key_path,has_secret,trust,source,created_at,updated_at";
pub fn list_keys(conn: &Connection) -> Result<Vec<PgpKeyRecord>, String> {
let mut stmt = conn
.prepare(&format!("SELECT {KEY_COLS} FROM pgp_keys ORDER BY has_secret DESC, lower(email), fingerprint"))
.prepare(&crate::rustbox_query_db_pgp_001!())
.map_err(|e| format!("Failed to prepare PGP key list: {e}"))?;
let rows = stmt
.query_map([], row_to_key)
.map_err(|e| format!("Failed to list PGP keys: {e}"))?;
rows.map(|row| row.map(|v| v.record).map_err(|e| format!("Failed to read PGP key: {e}")))
.collect()
rows.map(|row| {
row.map(|v| v.record)
.map_err(|e| format!("Failed to read PGP key: {e}"))
})
.collect()
}
pub fn get_key(conn: &Connection, fingerprint: &str) -> Result<Option<PgpKeyInternal>, String> {
let normalized = normalize_fingerprint(fingerprint);
let sql = format!("SELECT {KEY_COLS} FROM pgp_keys WHERE replace(upper(fingerprint),' ','')=?1");
let sql = crate::rustbox_query_db_pgp_002!();
match conn.query_row(&sql, params![normalized], row_to_key) {
Ok(v) => Ok(Some(v)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
@@ -73,13 +75,16 @@ pub fn get_key(conn: &Connection, fingerprint: &str) -> Result<Option<PgpKeyInte
pub fn find_keys_by_email(conn: &Connection, email: &str) -> Result<Vec<PgpKeyRecord>, String> {
let mut stmt = conn
.prepare(&format!("SELECT {KEY_COLS} FROM pgp_keys WHERE lower(email)=lower(?1) ORDER BY CASE trust WHEN 'verified' THEN 0 WHEN 'trusted' THEN 1 WHEN 'unknown' THEN 2 ELSE 3 END, has_secret DESC"))
.prepare(&crate::rustbox_query_db_pgp_003!())
.map_err(|e| format!("Failed to prepare PGP email lookup: {e}"))?;
let rows = stmt
.query_map(params![email.trim()], row_to_key)
.map_err(|e| format!("Failed to query PGP keys by email: {e}"))?;
rows.map(|row| row.map(|v| v.record).map_err(|e| format!("Failed to read PGP key: {e}")))
.collect()
rows.map(|row| {
row.map(|v| v.record)
.map_err(|e| format!("Failed to read PGP key: {e}"))
})
.collect()
}
pub fn upsert_key(
@@ -88,7 +93,7 @@ pub fn upsert_key(
secret_key_path: Option<&str>,
) -> Result<(), String> {
conn.execute(
"INSERT INTO pgp_keys (fingerprint,key_id,email,user_id,public_key_armor,secret_key_path,has_secret,trust,source) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9)\n ON CONFLICT(fingerprint) DO UPDATE SET key_id=excluded.key_id,email=CASE WHEN excluded.email='' THEN pgp_keys.email ELSE excluded.email END,user_id=CASE WHEN excluded.user_id='' THEN pgp_keys.user_id ELSE excluded.user_id END,public_key_armor=excluded.public_key_armor,secret_key_path=COALESCE(excluded.secret_key_path,pgp_keys.secret_key_path),has_secret=MAX(pgp_keys.has_secret,excluded.has_secret),trust=excluded.trust,source=excluded.source,updated_at=datetime('now')",
crate::queries::sqlite::queries::db_pgp::Q_001,
params![
record.fingerprint,
record.key_id,
@@ -108,7 +113,7 @@ pub fn upsert_key(
pub fn delete_key(conn: &Connection, fingerprint: &str) -> Result<bool, String> {
Ok(conn
.execute(
"DELETE FROM pgp_keys WHERE replace(upper(fingerprint),' ','')=?1",
crate::queries::sqlite::queries::db_pgp::Q_002,
params![normalize_fingerprint(fingerprint)],
)
.map_err(|e| format!("Failed to delete PGP key: {e}"))?
@@ -121,7 +126,7 @@ pub fn set_trust(conn: &Connection, fingerprint: &str, trust: &str) -> Result<bo
}
Ok(conn
.execute(
"UPDATE pgp_keys SET trust=?2,updated_at=datetime('now') WHERE replace(upper(fingerprint),' ','')=?1",
crate::queries::sqlite::queries::db_pgp::Q_003,
params![normalize_fingerprint(fingerprint), trust],
)
.map_err(|e| format!("Failed to update PGP trust: {e}"))?
@@ -129,35 +134,45 @@ pub fn set_trust(conn: &Connection, fingerprint: &str, trust: &str) -> Result<bo
}
pub fn get_preferences(conn: &Connection) -> Result<PgpPreferences, String> {
conn.query_row(
"SELECT wkd_enabled,hkp_enabled,require_trusted_encryption FROM pgp_preferences WHERE id=1",
[],
|row| {
Ok(PgpPreferences {
wkd_enabled: row.get::<_, i32>(0)? != 0,
hkp_enabled: row.get::<_, i32>(1)? != 0,
require_trusted_encryption: row.get::<_, i32>(2)? != 0,
})
},
)
conn.query_row(crate::queries::sqlite::queries::db_pgp::Q_004, [], |row| {
Ok(PgpPreferences {
wkd_enabled: row.get::<_, i32>(0)? != 0,
hkp_enabled: row.get::<_, i32>(1)? != 0,
require_trusted_encryption: row.get::<_, i32>(2)? != 0,
})
})
.map_err(|e| format!("Failed to read PGP preferences: {e}"))
}
pub fn update_preferences(conn: &Connection, prefs: &PgpPreferences) -> Result<PgpPreferences, String> {
pub fn update_preferences(
conn: &Connection,
prefs: &PgpPreferences,
) -> Result<PgpPreferences, String> {
conn.execute(
"UPDATE pgp_preferences SET wkd_enabled=?1,hkp_enabled=?2,require_trusted_encryption=?3,updated_at=datetime('now') WHERE id=1",
params![prefs.wkd_enabled as i32, prefs.hkp_enabled as i32, prefs.require_trusted_encryption as i32],
crate::queries::sqlite::queries::db_pgp::Q_005,
params![
prefs.wkd_enabled as i32,
prefs.hkp_enabled as i32,
prefs.require_trusted_encryption as i32
],
)
.map_err(|e| format!("Failed to update PGP preferences: {e}"))?;
get_preferences(conn)
}
pub fn normalize_fingerprint(value: &str) -> String {
value.chars().filter(|c| !c.is_ascii_whitespace()).flat_map(char::to_uppercase).collect()
value
.chars()
.filter(|c| !c.is_ascii_whitespace())
.flat_map(char::to_uppercase)
.collect()
}
pub fn find_key_internal_by_email(conn: &Connection, email: &str) -> Result<Option<PgpKeyInternal>, String> {
let sql = format!("SELECT {KEY_COLS} FROM pgp_keys WHERE lower(email)=lower(?1) ORDER BY CASE trust WHEN 'verified' THEN 0 WHEN 'trusted' THEN 1 WHEN 'unknown' THEN 2 ELSE 3 END, has_secret DESC LIMIT 1");
pub fn find_key_internal_by_email(
conn: &Connection,
email: &str,
) -> Result<Option<PgpKeyInternal>, String> {
let sql = crate::rustbox_query_db_pgp_004!();
match conn.query_row(&sql, params![email.trim()], row_to_key) {
Ok(value) => Ok(Some(value)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
@@ -167,10 +182,11 @@ pub fn find_key_internal_by_email(conn: &Connection, email: &str) -> Result<Opti
pub fn list_secret_keys(conn: &Connection) -> Result<Vec<PgpKeyInternal>, String> {
let mut stmt = conn
.prepare(&format!("SELECT {KEY_COLS} FROM pgp_keys WHERE has_secret=1 AND secret_key_path IS NOT NULL ORDER BY lower(email), fingerprint"))
.prepare(&crate::rustbox_query_db_pgp_005!())
.map_err(|e| format!("Failed to prepare PGP secret key list: {e}"))?;
let rows = stmt
.query_map([], row_to_key)
.map_err(|e| format!("Failed to list PGP secret keys: {e}"))?;
rows.map(|row| row.map_err(|e| format!("Failed to read PGP secret key: {e}"))).collect()
rows.map(|row| row.map_err(|e| format!("Failed to read PGP secret key: {e}")))
.collect()
}
+79 -71
View File
@@ -32,198 +32,204 @@ impl UserDb {
}
const MIGRATIONS: &[(u32, &str)] = &[
(1, include_str!("../../migrations/V001__initial_schema.sql")),
(
1,
include_str!("../../migrations/sqlite/V001__initial_schema.sql"),
),
(
2,
include_str!("../../migrations/V002__folders_and_messages.sql"),
include_str!("../../migrations/sqlite/V002__folders_and_messages.sql"),
),
(
3,
include_str!("../../migrations/sqlite/V003__add_date_epoch.sql"),
),
(3, include_str!("../../migrations/V003__add_date_epoch.sql")),
(
4,
include_str!("../../migrations/V004__folder_messages_updated_at.sql"),
include_str!("../../migrations/sqlite/V004__folder_messages_updated_at.sql"),
),
(
5,
include_str!("../../migrations/V005__drafts_and_attachments.sql"),
include_str!("../../migrations/sqlite/V005__drafts_and_attachments.sql"),
),
(
6,
include_str!("../../migrations/V006__cache_attachments_and_headers.sql"),
include_str!("../../migrations/sqlite/V006__cache_attachments_and_headers.sql"),
),
(
7,
include_str!("../../migrations/sqlite/V007__contacts.sql"),
),
(
8,
include_str!("../../migrations/sqlite/V008__identities.sql"),
),
(7, include_str!("../../migrations/V007__contacts.sql")),
(8, include_str!("../../migrations/V008__identities.sql")),
(
9,
include_str!("../../migrations/V009__notification_preferences.sql"),
include_str!("../../migrations/sqlite/V009__notification_preferences.sql"),
),
(
10,
include_str!("../../migrations/V010__contact_groups.sql"),
include_str!("../../migrations/sqlite/V010__contact_groups.sql"),
),
(
11,
include_str!("../../migrations/V011__message_reaction.sql"),
include_str!("../../migrations/sqlite/V011__message_reaction.sql"),
),
(12, include_str!("../../migrations/V012__tags.sql")),
(12, include_str!("../../migrations/sqlite/V012__tags.sql")),
(
13,
include_str!("../../migrations/V013__display_preferences.sql"),
include_str!("../../migrations/sqlite/V013__display_preferences.sql"),
),
(
14,
include_str!("../../migrations/V014__compose_format.sql"),
include_str!("../../migrations/sqlite/V014__compose_format.sql"),
),
(
15,
include_str!("../../migrations/sqlite/V015__deep_index.sql"),
),
(
16,
include_str!("../../migrations/sqlite/V016__calendar.sql"),
),
(15, include_str!("../../migrations/V015__deep_index.sql")),
(16, include_str!("../../migrations/V016__calendar.sql")),
(
17,
include_str!("../../migrations/V017__add_email_theme.sql"),
include_str!("../../migrations/sqlite/V017__add_email_theme.sql"),
),
(
18,
include_str!("../../migrations/V018__animation_mode.sql"),
include_str!("../../migrations/sqlite/V018__animation_mode.sql"),
),
(
19,
include_str!("../../migrations/V019__known_addresses.sql"),
include_str!("../../migrations/sqlite/V019__known_addresses.sql"),
),
(
20,
include_str!("../../migrations/V020__appearance_personalization.sql"),
include_str!("../../migrations/sqlite/V020__appearance_personalization.sql"),
),
(
21,
include_str!("../../migrations/V021__special_folders.sql"),
include_str!("../../migrations/sqlite/V021__special_folders.sql"),
),
(
22,
include_str!("../../migrations/V022__identity_options.sql"),
include_str!("../../migrations/sqlite/V022__identity_options.sql"),
),
(
23,
include_str!("../../migrations/V023__saved_searches.sql"),
include_str!("../../migrations/sqlite/V023__saved_searches.sql"),
),
(
24,
include_str!("../../migrations/V024__notification_dnd.sql"),
include_str!("../../migrations/sqlite/V024__notification_dnd.sql"),
),
(
25,
include_str!("../../migrations/V025__custom_theme_colors.sql"),
include_str!("../../migrations/sqlite/V025__custom_theme_colors.sql"),
),
(
26,
include_str!("../../migrations/V026__mail_preferences.sql"),
include_str!("../../migrations/sqlite/V026__mail_preferences.sql"),
),
(
27,
include_str!("../../migrations/V027__contact_profile.sql"),
include_str!("../../migrations/sqlite/V027__contact_profile.sql"),
),
(
28,
include_str!("../../migrations/V028__contact_multi_values.sql"),
include_str!("../../migrations/sqlite/V028__contact_multi_values.sql"),
),
(
29,
include_str!("../../migrations/V029__draft_send_options.sql"),
include_str!("../../migrations/sqlite/V029__draft_send_options.sql"),
),
(
30,
include_str!("../../migrations/V030__folder_list_preferences.sql"),
include_str!("../../migrations/sqlite/V030__folder_list_preferences.sql"),
),
(
31,
include_str!("../../migrations/V031__compose_preferences.sql"),
include_str!("../../migrations/sqlite/V031__compose_preferences.sql"),
),
(
32,
include_str!("../../migrations/V032__advanced_appearance.sql"),
include_str!("../../migrations/sqlite/V032__advanced_appearance.sql"),
),
(
33,
include_str!("../../migrations/V033__draft_compose_format.sql"),
include_str!("../../migrations/sqlite/V033__draft_compose_format.sql"),
),
(
34,
include_str!("../../migrations/V034__imap_keyword_sync.sql"),
include_str!("../../migrations/sqlite/V034__imap_keyword_sync.sql"),
),
(
35,
include_str!("../../migrations/V035__compose_behavior_preferences.sql"),
include_str!("../../migrations/sqlite/V035__compose_behavior_preferences.sql"),
),
(
36,
include_str!("../../migrations/V036__remote_draft_tracking.sql"),
include_str!("../../migrations/sqlite/V036__remote_draft_tracking.sql"),
),
(
37,
include_str!("../../migrations/V037__openpgp_keyring.sql"),
include_str!("../../migrations/sqlite/V037__openpgp_keyring.sql"),
),
(
38,
include_str!("../../migrations/V038__identity_openpgp.sql"),
include_str!("../../migrations/sqlite/V038__identity_openpgp.sql"),
),
(
39,
include_str!("../../migrations/V039__draft_openpgp_options.sql"),
include_str!("../../migrations/sqlite/V039__draft_openpgp_options.sql"),
),
(
40,
include_str!("../../migrations/V040__draft_openpgp_attach_key.sql"),
include_str!("../../migrations/sqlite/V040__draft_openpgp_attach_key.sql"),
),
(
41,
include_str!("../../migrations/V041__draft_openpgp_recipient_keys.sql"),
include_str!("../../migrations/sqlite/V041__draft_openpgp_recipient_keys.sql"),
),
(
42,
include_str!("../../migrations/V042__trusted_senders.sql"),
include_str!("../../migrations/sqlite/V042__trusted_senders.sql"),
),
(
43,
include_str!("../../migrations/V043__response_templates.sql"),
include_str!("../../migrations/sqlite/V043__response_templates.sql"),
),
(
44,
include_str!("../../migrations/V044__interface_skin.sql"),
include_str!("../../migrations/sqlite/V044__interface_skin.sql"),
),
(
45,
include_str!("../../migrations/V045__response_template_format.sql"),
include_str!("../../migrations/sqlite/V045__response_template_format.sql"),
),
(
46,
include_str!("../../migrations/V046__preset_scale_unlock.sql"),
include_str!("../../migrations/sqlite/V046__preset_scale_unlock.sql"),
),
];
fn run_migrations(conn: &Connection) -> Result<(), String> {
let mut current: u32 = conn
.query_row("PRAGMA user_version", [], |row| row.get(0))
let current: u32 = conn
.query_row(crate::queries::sqlite::queries::db_pool::Q_001, [], |row| {
row.get(0)
})
.map_err(|e| format!("Failed to read user_version: {e}"))?;
let refinery_max: Option<u32> = conn
.query_row(
"SELECT MAX(version) FROM refinery_schema_history",
[],
|row| row.get(0),
)
.unwrap_or(None);
if let Some(v) = refinery_max
&& v > current
{
current = v;
conn.execute_batch(&format!("PRAGMA user_version = {current};"))
.map_err(|e| format!("Failed to sync user_version from refinery: {e}"))?;
}
for &(version, sql) in MIGRATIONS {
if version > current {
conn.execute_batch(sql)
.map_err(|e| format!("Migration V{version:03} failed: {e}"))?;
conn.execute_batch(&format!("PRAGMA user_version = {version};"))
.map_err(|e| format!("Failed to set user_version to {version}: {e}"))?;
conn.execute_batch(&crate::queries::sqlite::queries::db_pool::set_user_version(
version,
))
.map_err(|e| format!("Failed to set user_version to {version}: {e}"))?;
}
}
@@ -237,10 +243,10 @@ pub fn open_user_db(data_dir: &str, user_hash: &str) -> Result<Connection, Strin
let db_path = dir.join("db.sqlite");
let conn = Connection::open(&db_path).map_err(|e| format!("Failed to open SQLite: {e}"))?;
conn.execute_batch("PRAGMA journal_mode=WAL;")
conn.execute_batch(crate::queries::sqlite::queries::db_pool::Q_002)
.map_err(|e| format!("Failed to set WAL mode: {e}"))?;
conn.execute_batch("PRAGMA foreign_keys=ON;")
conn.execute_batch(crate::queries::sqlite::queries::db_pool::Q_003)
.map_err(|e| format!("Failed to enable foreign keys: {e}"))?;
run_migrations(&conn)?;
@@ -252,7 +258,7 @@ pub fn open_user_db(data_dir: &str, user_hash: &str) -> Result<Connection, Strin
pub fn open_test_db() -> Connection {
let conn = Connection::open_in_memory().expect("Failed to open in-memory SQLite");
conn.execute_batch("PRAGMA foreign_keys=ON;")
conn.execute_batch(crate::queries::sqlite::queries::db_pool::Q_004)
.expect("Failed to enable foreign keys");
run_migrations(&conn).expect("Test migrations failed");
@@ -269,7 +275,7 @@ mod tests {
let conn = open_test_db();
let tables: Vec<String> = conn
.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
.prepare(crate::queries::sqlite::queries::db_pool::Q_005)
.unwrap()
.query_map([], |row| row.get(0))
.unwrap()
@@ -294,7 +300,9 @@ mod tests {
assert!(db_file.exists());
let fk: i32 = conn
.query_row("PRAGMA foreign_keys", [], |row| row.get(0))
.query_row(crate::queries::sqlite::queries::db_pool::Q_006, [], |row| {
row.get(0)
})
.unwrap();
assert_eq!(fk, 1);
+19 -10
View File
@@ -24,7 +24,7 @@ fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<ResponseTemplate> {
pub fn list(conn: &Connection) -> Result<Vec<ResponseTemplate>, String> {
let mut stmt = conn
.prepare("SELECT id,name,body,format,created_at,updated_at FROM response_templates ORDER BY name COLLATE NOCASE")
.prepare(crate::queries::sqlite::queries::db_response_templates::Q_001)
.map_err(|e| format!("Failed to prepare response templates: {e}"))?;
let rows = stmt
.query_map([], from_row)
@@ -35,7 +35,7 @@ pub fn list(conn: &Connection) -> Result<Vec<ResponseTemplate>, String> {
pub fn get(conn: &Connection, id: i64) -> Result<Option<ResponseTemplate>, String> {
match conn.query_row(
"SELECT id,name,body,format,created_at,updated_at FROM response_templates WHERE id=?1",
crate::queries::sqlite::queries::db_response_templates::Q_002,
params![id],
from_row,
) {
@@ -67,7 +67,7 @@ pub fn create(
_ => return Err("Invalid response template format".to_string()),
};
conn.execute(
"INSERT INTO response_templates(name,body,format) VALUES (?1,?2,?3)",
crate::queries::sqlite::queries::db_response_templates::Q_003,
params![name, body, format],
)
.map_err(|e| {
@@ -103,12 +103,18 @@ pub fn update(
"text" | "" => "text",
_ => return Err("Invalid response template format".to_string()),
};
let changed = conn.execute(
"UPDATE response_templates SET name=?1,body=?2,format=?3,updated_at=datetime('now') WHERE id=?4",
params![name, body, format, id],
).map_err(|e| if e.to_string().contains("UNIQUE constraint failed") {
"A response template with this name already exists".to_string()
} else { format!("Failed to update response template: {e}") })?;
let changed = conn
.execute(
crate::queries::sqlite::queries::db_response_templates::Q_004,
params![name, body, format, id],
)
.map_err(|e| {
if e.to_string().contains("UNIQUE constraint failed") {
"A response template with this name already exists".to_string()
} else {
format!("Failed to update response template: {e}")
}
})?;
if changed == 0 {
return Ok(None);
}
@@ -117,7 +123,10 @@ pub fn update(
pub fn delete(conn: &Connection, id: i64) -> Result<bool, String> {
Ok(conn
.execute("DELETE FROM response_templates WHERE id=?1", params![id])
.execute(
crate::queries::sqlite::queries::db_response_templates::Q_005,
params![id],
)
.map_err(|e| format!("Failed to delete response template: {e}"))?
> 0)
}
+26 -12
View File
@@ -1,4 +1,4 @@
use rusqlite::{params, Connection};
use rusqlite::{Connection, params};
use serde::Serialize;
#[derive(Debug, Clone, Serialize)]
@@ -22,7 +22,7 @@ fn row_to_saved(row: &rusqlite::Row<'_>) -> rusqlite::Result<SavedSearch> {
pub fn list(conn: &Connection) -> Result<Vec<SavedSearch>, String> {
let mut stmt = conn
.prepare("SELECT id,name,query_string,created_at,updated_at FROM saved_searches ORDER BY name COLLATE NOCASE")
.prepare(crate::queries::sqlite::queries::db_saved_searches::Q_001)
.map_err(|e| format!("Failed to prepare saved searches: {e}"))?;
let rows = stmt
.query_map([], row_to_saved)
@@ -38,7 +38,7 @@ pub fn create(conn: &Connection, name: &str, query_string: &str) -> Result<Saved
return Err("Saved search name and query are required".to_string());
}
conn.execute(
"INSERT INTO saved_searches(name,query_string) VALUES (?1,?2)",
crate::queries::sqlite::queries::db_saved_searches::Q_002,
params![name, query_string],
)
.map_err(|e| format!("Failed to save search: {e}"))?;
@@ -46,23 +46,32 @@ pub fn create(conn: &Connection, name: &str, query_string: &str) -> Result<Saved
get(conn, id)?.ok_or_else(|| "Failed to read saved search".to_string())
}
pub fn update(conn: &Connection, id: i64, name: &str, query_string: &str) -> Result<Option<SavedSearch>, String> {
pub fn update(
conn: &Connection,
id: i64,
name: &str,
query_string: &str,
) -> Result<Option<SavedSearch>, String> {
let name = name.trim();
let query_string = query_string.trim();
if name.is_empty() || query_string.is_empty() {
return Err("Saved search name and query are required".to_string());
}
let changed = conn.execute(
"UPDATE saved_searches SET name=?1,query_string=?2,updated_at=datetime('now') WHERE id=?3",
params![name, query_string, id],
).map_err(|e| format!("Failed to update saved search: {e}"))?;
if changed == 0 { return Ok(None); }
let changed = conn
.execute(
crate::queries::sqlite::queries::db_saved_searches::Q_003,
params![name, query_string, id],
)
.map_err(|e| format!("Failed to update saved search: {e}"))?;
if changed == 0 {
return Ok(None);
}
get(conn, id)
}
pub fn get(conn: &Connection, id: i64) -> Result<Option<SavedSearch>, String> {
match conn.query_row(
"SELECT id,name,query_string,created_at,updated_at FROM saved_searches WHERE id=?1",
crate::queries::sqlite::queries::db_saved_searches::Q_004,
params![id],
row_to_saved,
) {
@@ -73,6 +82,11 @@ pub fn get(conn: &Connection, id: i64) -> Result<Option<SavedSearch>, String> {
}
pub fn delete(conn: &Connection, id: i64) -> Result<bool, String> {
Ok(conn.execute("DELETE FROM saved_searches WHERE id=?1", params![id])
.map_err(|e| format!("Failed to delete saved search: {e}"))? > 0)
Ok(conn
.execute(
crate::queries::sqlite::queries::db_saved_searches::Q_005,
params![id],
)
.map_err(|e| format!("Failed to delete saved search: {e}"))?
> 0)
}
+10 -7
View File
@@ -21,7 +21,7 @@ pub fn get_role(conn: &Connection, role: &str) -> Result<Option<String>, String>
return Err(format!("Invalid special folder role: {role}"));
}
conn.query_row(
"SELECT folder_name FROM special_folders WHERE role = ?1",
crate::queries::sqlite::queries::db_special_folders::Q_001,
[role],
|row| row.get(0),
)
@@ -47,7 +47,7 @@ pub fn set_role(conn: &Connection, role: &str, folder_name: Option<&str>) -> Res
Some(folder) => {
let exists: bool = conn
.query_row(
"SELECT EXISTS(SELECT 1 FROM folders WHERE name = ?1)",
crate::queries::sqlite::queries::db_special_folders::Q_002,
[folder],
|row| row.get(0),
)
@@ -56,14 +56,17 @@ pub fn set_role(conn: &Connection, role: &str, folder_name: Option<&str>) -> Res
return Err(format!("Folder does not exist: {folder}"));
}
conn.execute(
"INSERT INTO special_folders(role, folder_name, updated_at) VALUES(?1, ?2, datetime('now'))\n ON CONFLICT(role) DO UPDATE SET folder_name = excluded.folder_name, updated_at = datetime('now')",
crate::queries::sqlite::queries::db_special_folders::Q_003,
params![role, folder],
)
.map_err(|e| format!("Failed to set special folder {role}: {e}"))?;
}
None => {
conn.execute("DELETE FROM special_folders WHERE role = ?1", [role])
.map_err(|e| format!("Failed to clear special folder {role}: {e}"))?;
conn.execute(
crate::queries::sqlite::queries::db_special_folders::Q_004,
[role],
)
.map_err(|e| format!("Failed to clear special folder {role}: {e}"))?;
}
}
Ok(())
@@ -84,7 +87,7 @@ pub fn rename_folder_reference(
new_name: &str,
) -> Result<(), String> {
conn.execute(
"UPDATE special_folders SET folder_name = ?1, updated_at = datetime('now') WHERE folder_name = ?2",
crate::queries::sqlite::queries::db_special_folders::Q_005,
params![new_name, old_name],
)
.map_err(|e| format!("Failed to update special folder reference: {e}"))?;
@@ -93,7 +96,7 @@ pub fn rename_folder_reference(
pub fn clear_folder_reference(conn: &Connection, folder_name: &str) -> Result<(), String> {
conn.execute(
"DELETE FROM special_folders WHERE folder_name = ?1",
crate::queries::sqlite::queries::db_special_folders::Q_006,
[folder_name],
)
.map_err(|e| format!("Failed to clear special folder reference: {e}"))?;
+16 -46
View File
@@ -33,7 +33,7 @@ pub fn imap_keyword_for_tag_id(id: &str) -> String {
pub fn create_tag(conn: &Connection, id: &str, name: &str, color: &str) -> Result<(), String> {
let imap_keyword = imap_keyword_for_tag_id(id);
conn.execute(
"INSERT INTO tags (id, name, color, imap_keyword) VALUES (?1, ?2, ?3, ?4)",
crate::queries::sqlite::queries::db_tags::Q_001,
params![id, name, color, imap_keyword],
)
.map_err(|e| format!("Failed to create tag: {e}"))?;
@@ -42,12 +42,7 @@ pub fn create_tag(conn: &Connection, id: &str, name: &str, color: &str) -> Resul
pub fn list_tags(conn: &Connection) -> Result<Vec<Tag>, String> {
let mut stmt = conn
.prepare(
"SELECT t.id, t.name, t.color, t.imap_keyword, t.created_at, t.updated_at,
(SELECT COUNT(*) FROM message_tags mt WHERE mt.tag_id = t.id) AS message_count
FROM tags t
ORDER BY t.name ASC",
)
.prepare(crate::queries::sqlite::queries::db_tags::Q_002)
.map_err(|e| format!("Failed to prepare list_tags: {e}"))?;
let rows = stmt
@@ -81,7 +76,7 @@ pub fn get_message_refs_by_tag(
tag_id: &str,
) -> Result<Vec<(u32, String)>, String> {
let mut stmt = conn
.prepare("SELECT message_uid, message_folder FROM message_tags WHERE tag_id = ?1")
.prepare(crate::queries::sqlite::queries::db_tags::Q_003)
.map_err(|e| format!("Failed to prepare tag message refs: {e}"))?;
let rows = stmt
.query_map(params![tag_id], |row| Ok((row.get(0)?, row.get(1)?)))
@@ -140,10 +135,8 @@ pub fn reconcile_imap_keywords(
add_tag_to_message(conn, &tag.id, uid, folder)?;
added += 1;
}
(false, true) => {
if remove_tag_from_message(conn, &tag.id, uid, folder)? {
removed += 1;
}
(false, true) if remove_tag_from_message(conn, &tag.id, uid, folder)? => {
removed += 1;
}
_ => {}
}
@@ -154,7 +147,7 @@ pub fn reconcile_imap_keywords(
pub fn update_tag(conn: &Connection, id: &str, name: &str, color: &str) -> Result<bool, String> {
let updated = conn
.execute(
"UPDATE tags SET name = ?1, color = ?2, updated_at = datetime('now') WHERE id = ?3",
crate::queries::sqlite::queries::db_tags::Q_004,
params![name, color, id],
)
.map_err(|e| format!("Failed to update tag: {e}"))?;
@@ -163,7 +156,7 @@ pub fn update_tag(conn: &Connection, id: &str, name: &str, color: &str) -> Resul
pub fn delete_tag(conn: &Connection, id: &str) -> Result<bool, String> {
let deleted = conn
.execute("DELETE FROM tags WHERE id = ?1", params![id])
.execute(crate::queries::sqlite::queries::db_tags::Q_005, params![id])
.map_err(|e| format!("Failed to delete tag: {e}"))?;
Ok(deleted > 0)
}
@@ -175,7 +168,7 @@ pub fn add_tag_to_message(
folder: &str,
) -> Result<(), String> {
conn.execute(
"INSERT OR IGNORE INTO message_tags (tag_id, message_uid, message_folder) VALUES (?1, ?2, ?3)",
crate::queries::sqlite::queries::db_tags::Q_006,
params![tag_id, uid, folder],
)
.map_err(|e| format!("Failed to add tag to message: {e}"))?;
@@ -190,7 +183,7 @@ pub fn remove_tag_from_message(
) -> Result<bool, String> {
let deleted = conn
.execute(
"DELETE FROM message_tags WHERE tag_id = ?1 AND message_uid = ?2 AND message_folder = ?3",
crate::queries::sqlite::queries::db_tags::Q_007,
params![tag_id, uid, folder],
)
.map_err(|e| format!("Failed to remove tag from message: {e}"))?;
@@ -203,13 +196,7 @@ pub fn get_message_tags(
folder: &str,
) -> Result<Vec<MessageTag>, String> {
let mut stmt = conn
.prepare(
"SELECT t.id, t.name, t.color
FROM tags t
JOIN message_tags mt ON mt.tag_id = t.id
WHERE mt.message_uid = ?1 AND mt.message_folder = ?2
ORDER BY t.name ASC",
)
.prepare(crate::queries::sqlite::queries::db_tags::Q_008)
.map_err(|e| format!("Failed to prepare get_message_tags: {e}"))?;
let rows = stmt
@@ -249,14 +236,7 @@ pub fn get_tags_for_messages(
})
.collect();
let sql = format!(
"SELECT mt.message_uid, mt.message_folder, t.id, t.name, t.color
FROM message_tags mt
JOIN tags t ON t.id = mt.tag_id
WHERE {}
ORDER BY t.name ASC",
conditions.join(" OR ")
);
let sql = crate::rustbox_query_db_tags_001!(conditions.join(" OR "));
let mut stmt = conn
.prepare(&sql)
@@ -299,16 +279,7 @@ pub fn get_messages_by_tag(
) -> Result<Vec<CachedMessage>, String> {
let offset = page * per_page;
let mut stmt = conn
.prepare(
"SELECT m.uid, m.folder, m.message_id, m.in_reply_to, m.references_header,
m.subject, m.from_address, m.from_name, m.to_addresses, m.cc_addresses,
m.date, m.flags, m.size, m.has_attachments, m.snippet, m.reaction
FROM messages m
JOIN message_tags mt ON mt.message_uid = m.uid AND mt.message_folder = m.folder
WHERE mt.tag_id = ?1
ORDER BY m.date_epoch DESC
LIMIT ?2 OFFSET ?3",
)
.prepare(crate::queries::sqlite::queries::db_tags::Q_009)
.map_err(|e| format!("Failed to prepare get_messages_by_tag: {e}"))?;
let rows = stmt
@@ -344,7 +315,7 @@ pub fn get_messages_by_tag(
pub fn count_messages_by_tag(conn: &Connection, tag_id: &str) -> Result<u32, String> {
conn.query_row(
"SELECT COUNT(*) FROM message_tags WHERE tag_id = ?1",
crate::queries::sqlite::queries::db_tags::Q_010,
params![tag_id],
|row| row.get(0),
)
@@ -358,13 +329,12 @@ mod tests {
fn insert_test_message(conn: &Connection, folder: &str, uid: u32) {
conn.execute(
"INSERT OR IGNORE INTO folders (name) VALUES (?1)",
crate::queries::sqlite::queries::db_tags::Q_011,
params![folder],
)
.unwrap();
conn.execute(
"INSERT INTO messages (uid, folder, subject, from_address, from_name, to_addresses, date, flags, size, has_attachments, snippet)
VALUES (?1, ?2, 'Test', 'test@ex.com', 'Test', '[]', '2024-01-01', '', 100, 0, '')",
crate::queries::sqlite::queries::db_tags::Q_012,
params![uid, folder],
)
.unwrap();
@@ -462,7 +432,7 @@ mod tests {
let map = get_tags_for_messages(&conn, &refs).unwrap();
assert_eq!(map.get(&(1, "INBOX".to_string())).unwrap().len(), 1);
assert!(map.get(&(2, "INBOX".to_string())).is_none());
assert!(!map.contains_key(&(2, "INBOX".to_string())));
}
#[test]
+3 -3
View File
@@ -2,7 +2,7 @@ use rusqlite::Connection;
pub fn list(conn: &Connection) -> Result<Vec<String>, String> {
let mut stmt = conn
.prepare("SELECT email FROM trusted_senders ORDER BY lower(email)")
.prepare(crate::queries::sqlite::queries::db_trusted_senders::Q_001)
.map_err(|e| format!("Failed to prepare trusted sender list: {e}"))?;
let rows = stmt
.query_map([], |row| row.get::<_, String>(0))
@@ -13,7 +13,7 @@ pub fn list(conn: &Connection) -> Result<Vec<String>, String> {
pub fn add(conn: &Connection, email: &str) -> Result<(), String> {
conn.execute(
"INSERT OR IGNORE INTO trusted_senders(email) VALUES(?1)",
crate::queries::sqlite::queries::db_trusted_senders::Q_002,
[email],
)
.map_err(|e| format!("Failed to trust sender: {e}"))?;
@@ -22,7 +22,7 @@ pub fn add(conn: &Connection, email: &str) -> Result<(), String> {
pub fn remove(conn: &Connection, email: &str) -> Result<(), String> {
conn.execute(
"DELETE FROM trusted_senders WHERE email = ?1 COLLATE NOCASE",
crate::queries::sqlite::queries::db_trusted_senders::Q_003,
[email],
)
.map_err(|e| format!("Failed to remove trusted sender: {e}"))?;
+11 -2
View File
@@ -6,9 +6,11 @@ mod email_theme;
mod error;
mod imap;
mod pgp;
mod queries;
mod realtime;
mod routes;
mod search;
mod session_store;
mod sieve;
mod smtp;
mod ui;
@@ -54,9 +56,16 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
})?;
let config = Arc::new(loaded_config);
let store = Arc::new(SessionStore::new_with_tls_verify(
let persistence = session_store::from_config(&config)
.await
.map_err(std::io::Error::other)?;
let session_crypto = session_store::crypto::SessionCrypto::load_or_create(&config)
.map_err(std::io::Error::other)?;
let store = Arc::new(SessionStore::new_persistent(
Duration::from_secs(config.session_timeout_hours * 3600),
config.tls_verify,
persistence,
session_crypto,
));
{
@@ -65,7 +74,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut interval = tokio::time::interval(Duration::from_secs(300));
loop {
interval.tick().await;
store.purge_expired();
store.purge_expired_shared().await;
tracing::debug!("Purged expired sessions");
}
});
+1 -1
View File
@@ -126,7 +126,7 @@ async fn https_get_address(
format!("{host}:{port}")
};
let request = format!(
"GET {target} HTTP/1.1\r\nHost: {host_header}\r\nUser-Agent: RustBox/0.0.6\r\nAccept: application/octet-stream, application/pgp-keys, text/plain\r\nConnection: close\r\n\r\n"
"GET {target} HTTP/1.1\r\nHost: {host_header}\r\nUser-Agent: RustBox/0.1.0\r\nAccept: application/octet-stream, application/pgp-keys, text/plain\r\nConnection: close\r\n\r\n"
);
timeout(duration, stream.write_all(request.as_bytes()))
.await
+9 -9
View File
@@ -286,15 +286,15 @@ pub fn decrypt_inline(
let mut signer_fingerprint = None;
if signed {
for armor in public_armors {
if let Ok((public, _)) = SignedPublicKey::from_string(armor) {
if decrypted.verify(&public).is_ok() {
verified = true;
signer_fingerprint = Some(normalize_fingerprint(&format!(
"{}",
public.primary_key.fingerprint()
)));
break;
}
if let Ok((public, _)) = SignedPublicKey::from_string(armor)
&& decrypted.verify(&public).is_ok()
{
verified = true;
signer_fingerprint = Some(normalize_fingerprint(&format!(
"{}",
public.primary_key.fingerprint()
)));
break;
}
}
}
+9
View File
@@ -0,0 +1,9 @@
// Query catalogs intentionally expose parallel SQL dialects. Not every catalog
// is used in a SQLite-only process, so dead-code suppression is scoped to the
// dialect modules instead of the rest of the crate.
#[allow(dead_code)]
pub mod mysql;
#[allow(dead_code)]
pub mod pgsql;
#[allow(dead_code)]
pub mod sqlite;
+1
View File
@@ -0,0 +1 @@
pub mod queries;
+487
View File
@@ -0,0 +1,487 @@
pub mod auth_user_data {
pub const Q_001: &str = "SELECT value FROM user_meta WHERE key = 'schema_version'";
}
pub mod db_calendar {
pub const EVENT_SELECT_COLS: &str = "id, title, description, location, start_time, end_time, \
all_day, recurrence_rule, attendees, organizer, status, source, source_uid, \
source_message_uid, source_message_folder, meeting_url, color, reminder_minutes, \
created_at, updated_at";
pub const SETTINGS_SELECT_COLS: &str = "default_view, week_starts_on, working_hours_start, \
working_hours_end, time_format, zoom_link, teams_link, meet_link, caldav_url, \
caldav_username, caldav_enabled, updated_at";
pub const Q_001: &str = "SELECT CURRENT_TIMESTAMP";
pub const Q_002: &str = "INSERT INTO calendar_events
(id, title, description, location, start_time, end_time, all_day,
recurrence_rule, attendees, organizer, status, source, source_uid,
source_message_uid, source_message_folder, meeting_url, color,
reminder_minutes, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
pub const Q_003: &str = "DELETE FROM calendar_events WHERE id = ?";
pub const Q_004: &str = "INSERT IGNORE INTO calendar_settings (id) VALUES (1)";
pub const Q_005: &str = "INSERT IGNORE INTO calendar_settings (id) VALUES (1)";
pub const Q_006: &str = "SELECT id, name, url_template, icon, is_default, created_at
FROM meeting_templates ORDER BY is_default DESC, name ASC";
pub const Q_007: &str =
"INSERT INTO meeting_templates (name, url_template, icon) VALUES (?, ?, ?)";
pub const Q_008: &str = "SELECT id, name, url_template, icon, is_default, created_at
FROM meeting_templates WHERE id = ?";
pub const Q_009: &str = "DELETE FROM meeting_templates WHERE id = ?";
pub const FORMAT_001: &str = "SELECT {EVENT_SELECT_COLS} FROM calendar_events
WHERE end_time >= ? AND start_time <= ?
ORDER BY start_time ASC";
pub const FORMAT_002: &str =
"SELECT {EVENT_SELECT_COLS} FROM calendar_events ORDER BY start_time ASC, title ASC";
pub const FORMAT_003: &str = "SELECT {EVENT_SELECT_COLS} FROM calendar_events WHERE id = ?";
pub const FORMAT_004: &str = "UPDATE calendar_events SET {} WHERE id = ?";
pub const FORMAT_005: &str =
"SELECT {EVENT_SELECT_COLS} FROM calendar_events WHERE source_uid = ?";
pub const FORMAT_006: &str =
"SELECT {SETTINGS_SELECT_COLS} FROM calendar_settings WHERE id = 1";
pub const FORMAT_007: &str = "UPDATE calendar_settings SET {} WHERE id = 1";
}
pub mod db_contact_groups {
pub const Q_001: &str = "INSERT INTO contact_groups (id, name) VALUES (?, ?)";
pub const Q_002: &str = "SELECT g.id, g.name, g.created_at, g.updated_at,
(SELECT COUNT(*) FROM contact_group_members m WHERE m.group_id = g.id) AS member_count
FROM contact_groups g
ORDER BY g.name ASC";
pub const Q_003: &str =
"UPDATE contact_groups SET name = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?";
pub const Q_004: &str = "DELETE FROM contact_groups WHERE id = ?";
pub const Q_005: &str =
"INSERT IGNORE INTO contact_group_members (group_id, contact_id) VALUES (?, ?)";
pub const Q_006: &str =
"DELETE FROM contact_group_members WHERE group_id = ? AND contact_id = ?";
pub const Q_007: &str = "SELECT c.id, c.email, c.name, c.company, c.notes, c.phone, c.job_title, c.address, c.website, c.birthday, c.is_favorite,
c.last_contacted, c.contact_count, c.source, c.created_at, c.updated_at, c.emails_json, c.phones_json, c.addresses_json, EXISTS(SELECT 1 FROM contact_photos p WHERE p.contact_id = c.id) AS has_photo
FROM contacts c
JOIN contact_group_members m ON m.contact_id = c.id
WHERE m.group_id = ?
ORDER BY c.name ASC";
pub const Q_008: &str = "SELECT g.id, g.name, g.created_at, g.updated_at,
(SELECT COUNT(*) FROM contact_group_members m2 WHERE m2.group_id = g.id) AS member_count
FROM contact_groups g
JOIN contact_group_members m ON m.group_id = g.id
WHERE m.contact_id = ?
ORDER BY g.name ASC";
}
pub mod db_contacts {
pub const CONTACT_SELECT_COLS: &str = "id, email, name, company, notes, phone, job_title, address, website, birthday, is_favorite, last_contacted, contact_count, source, created_at, updated_at, emails_json, phones_json, addresses_json, EXISTS(SELECT 1 FROM contact_photos p WHERE p.contact_id = contacts.id) AS has_photo";
pub const Q_001: &str = "INSERT INTO contacts
(id, email, name, company, notes, phone, job_title, address, website, birthday, is_favorite, last_contacted,
contact_count, source, created_at, updated_at, emails_json, phones_json, addresses_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
email=excluded.email, name=excluded.name, company=excluded.company, notes=excluded.notes,
phone=excluded.phone, job_title=excluded.job_title, address=excluded.address, website=excluded.website, birthday=excluded.birthday,
is_favorite=excluded.is_favorite, last_contacted=excluded.last_contacted, contact_count=excluded.contact_count,
source=excluded.source, updated_at=excluded.updated_at, emails_json=excluded.emails_json,
phones_json=excluded.phones_json, addresses_json=excluded.addresses_json";
pub const Q_002: &str = "DELETE FROM contacts WHERE id = ?";
pub const Q_003: &str = r#"
SELECT email, name
FROM known_addresses
WHERE (email LIKE ? ESCAPE '\' OR name LIKE ? ESCAPE '\')
AND email NOT IN (SELECT email FROM contacts)
AND NOT EXISTS (
SELECT 1 FROM contacts c, json_each(c.emails_json) alt
WHERE lower(alt.value) = lower(known_addresses.email)
)
ORDER BY email ASC
LIMIT ?
"#;
pub const Q_004: &str = "INSERT INTO known_addresses (email, name) VALUES (?, ?)
ON CONFLICT(email) DO UPDATE SET name = excluded.name WHERE excluded.name != ''";
pub const Q_005: &str = "INSERT INTO known_addresses (email, name) VALUES (?, ?)
ON CONFLICT(email) DO UPDATE SET name = excluded.name WHERE excluded.name != ''";
pub const Q_006: &str = "UPDATE contacts
SET contact_count = contact_count + 1,
last_contacted = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
WHERE email = ?";
pub const Q_007: &str = "INSERT IGNORE INTO contacts (id, email, name, source)
VALUES (?, ?, ?, 'auto')";
pub const Q_008: &str = "INSERT INTO contact_photos (contact_id, content_type, data, updated_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP) ON CONFLICT(contact_id) DO UPDATE SET content_type=excluded.content_type, data=excluded.data, updated_at=CURRENT_TIMESTAMP";
pub const Q_009: &str = "SELECT content_type, data FROM contact_photos WHERE contact_id = ?";
pub const Q_010: &str = "DELETE FROM contact_photos WHERE contact_id = ?";
pub const FORMAT_001: &str = "SELECT {CONTACT_SELECT_COLS} FROM contacts WHERE id = ?";
pub const FORMAT_002: &str = "SELECT {CONTACT_SELECT_COLS} FROM contacts \
WHERE lower(email) = lower(?) \
OR EXISTS (SELECT 1 FROM json_each(contacts.emails_json) WHERE lower(value) = lower(?))";
pub const FORMAT_003: &str = "SELECT {CONTACT_SELECT_COLS} FROM contacts
WHERE name LIKE ? ESCAPE '\\' OR email LIKE ? ESCAPE '\\' OR emails_json LIKE ? ESCAPE '\\'
ORDER BY name ASC
LIMIT ? OFFSET ?";
pub const FORMAT_004: &str = "SELECT {CONTACT_SELECT_COLS} FROM contacts
ORDER BY name ASC
LIMIT ? OFFSET ?";
pub const FORMAT_005: &str = "SELECT {CONTACT_SELECT_COLS} FROM contacts
WHERE name LIKE ? ESCAPE '\\' OR email LIKE ? ESCAPE '\\' OR emails_json LIKE ? ESCAPE '\\'
ORDER BY name ASC
LIMIT ?";
}
pub mod db_display_preferences {
pub const Q_001: &str = "SELECT density, theme, language, compose_format, deep_index, animation_mode, preview_pane, ui_scale, color_preset, accent_color, background_color, panel_color, text_color, font_family, font_size, row_height, folder_pane_width, message_pane_width, border_radius, contrast, show_avatars, show_attachment_icons, show_flags, reduce_motion, date_format, time_format, compose_autosave_ms, default_priority, request_mdn_default, attachment_reminder, icon_size, toolbar_density, sidebar_item_height, reader_max_width, composer_width, selection_color, unread_font_weight, unread_badge_style, compact_headers, show_borders, scrollbar_density, monochrome, folder_background, message_background, reader_background, compose_font_family, reply_position, quote_style, forward_mode, spellcheck_enabled, interface_skin, preset_scale_unlocked, updated_at FROM display_preferences WHERE id = 1";
pub const Q_002: &str = "INSERT IGNORE INTO display_preferences (id) VALUES (1)";
pub const FORMAT_001: &str = "UPDATE display_preferences SET {} WHERE id = ?";
}
pub mod db_drafts {
pub const Q_001: &str = "INSERT INTO drafts (id, to_addresses, cc_addresses, bcc_addresses, subject, text_body, html_body, in_reply_to, references_header, request_mdn, priority, compose_format, pgp_mode, pgp_inline, pgp_attach_public_key, pgp_attach_recipient_keys, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(id) DO UPDATE SET
to_addresses = excluded.to_addresses,
cc_addresses = excluded.cc_addresses,
bcc_addresses = excluded.bcc_addresses,
subject = excluded.subject,
text_body = excluded.text_body,
html_body = excluded.html_body,
in_reply_to = excluded.in_reply_to,
references_header = excluded.references_header,
request_mdn = excluded.request_mdn,
priority = excluded.priority,
compose_format = excluded.compose_format,
pgp_mode = excluded.pgp_mode,
pgp_inline = excluded.pgp_inline,
pgp_attach_public_key = excluded.pgp_attach_public_key,
pgp_attach_recipient_keys = excluded.pgp_attach_recipient_keys,
updated_at = CURRENT_TIMESTAMP";
pub const Q_002: &str = "SELECT id, to_addresses, cc_addresses, bcc_addresses, subject, text_body, html_body, in_reply_to, references_header, request_mdn, priority, compose_format, pgp_mode, pgp_inline, pgp_attach_public_key, pgp_attach_recipient_keys, remote_folder, remote_uid, remote_uid_validity, created_at, updated_at
FROM drafts WHERE id = ?";
pub const Q_003: &str = "SELECT id, to_addresses, cc_addresses, bcc_addresses, subject, text_body, html_body, in_reply_to, references_header, request_mdn, priority, compose_format, pgp_mode, pgp_inline, pgp_attach_public_key, pgp_attach_recipient_keys, remote_folder, remote_uid, remote_uid_validity, created_at, updated_at
FROM drafts ORDER BY updated_at DESC";
pub const Q_004: &str =
"UPDATE drafts SET remote_folder = ?, remote_uid = ?, remote_uid_validity = ? WHERE id = ?";
pub const Q_005: &str = "DELETE FROM drafts WHERE id = ?";
pub const Q_006: &str =
"INSERT INTO draft_attachments (id, draft_id, filename, content_type, size, file_path)
VALUES (?, ?, ?, ?, ?, ?)";
pub const Q_007: &str =
"SELECT id, draft_id, filename, content_type, size, file_path, created_at
FROM draft_attachments WHERE draft_id = ? ORDER BY created_at ASC";
pub const Q_008: &str = "DELETE FROM draft_attachments WHERE id = ?";
}
pub mod db_folder_preferences {
pub const Q_001: &str = "SELECT flagged_on_top FROM folder_preferences WHERE folder_name = ?";
pub const Q_002: &str = "SELECT flagged_on_top FROM mail_preferences WHERE id = 1";
pub const Q_003: &str = "SELECT EXISTS(SELECT 1 FROM folders WHERE name = ?)";
pub const Q_004: &str = "SELECT flagged_on_top FROM mail_preferences WHERE id = 1";
pub const Q_005: &str = "SELECT EXISTS(SELECT 1 FROM folders WHERE name = ?)";
pub const Q_006: &str = "INSERT INTO folder_preferences(folder_name, flagged_on_top, updated_at) VALUES(?, ?, CURRENT_TIMESTAMP)\n ON CONFLICT(folder_name) DO UPDATE SET flagged_on_top = excluded.flagged_on_top, updated_at = CURRENT_TIMESTAMP";
pub const Q_007: &str = "DELETE FROM folder_preferences WHERE folder_name = ?";
}
pub mod db_folders {
pub const Q_001: &str = "REPLACE INTO folders
(name, delimiter, parent, flags, is_subscribed,
total_count, unread_count, uid_validity, highest_modseq, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 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
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
WHERE name = ?";
pub const Q_007: &str =
"UPDATE folders SET uid_validity = ?, total_count = ?, highest_modseq = ?
WHERE name = ?";
pub const Q_008: &str =
"SELECT COUNT(*) FROM messages WHERE folder = ? AND flags NOT LIKE '%\\Seen%'";
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_011: &str = "SELECT EXISTS(
SELECT 1 FROM folders
WHERE updated_at > datetime('now', ?)
)";
pub const Q_012: &str = "SELECT EXISTS(
SELECT 1 FROM folders
WHERE name = ? AND messages_updated_at > datetime('now', ?)
)";
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
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 = "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 ?, delimiter, parent, flags, is_subscribed, total_count, unread_count, uid_validity, highest_modseq, 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 = ?";
pub const Q_022: &str =
"UPDATE calendar_events SET source_message_folder = ? WHERE source_message_folder = ?";
pub const Q_023: &str = "UPDATE folders SET parent = ? WHERE parent = ?";
pub const Q_024: &str = "DELETE FROM folders WHERE name = ?";
pub const Q_025: &str = "SELECT name FROM folders WHERE flags LIKE CONCAT('%', ?, '%') LIMIT 1";
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 FORMAT_001: &str = "DELETE FROM folders WHERE name NOT IN ({})";
}
pub mod db_header_sync {
pub const Q_001: &str = "SELECT value FROM user_meta WHERE key = 'header_sync_generation'";
pub const Q_002: &str =
"INSERT INTO user_meta (key, value) VALUES ('header_sync_generation', '1')
ON CONFLICT(key) DO UPDATE SET value = CAST(value AS INTEGER) + 1";
}
pub mod db_identities {
pub const IDENTITY_SELECT_COLS: &str = "id, display_name, email, signature_html, is_default, created_at, updated_at, reply_to, bcc, organization, signature_text, signature_new, signature_reply, signature_forward, signature_position, pgp_fingerprint, pgp_sign_default, pgp_encrypt_default, pgp_inline_default";
pub const Q_001: &str = "INSERT INTO identities (display_name,email,signature_html,is_default,reply_to,bcc,organization,signature_text,signature_new,signature_reply,signature_forward,signature_position,pgp_fingerprint,pgp_sign_default,pgp_encrypt_default,pgp_inline_default) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)";
pub const Q_002: &str = "DELETE FROM identities WHERE id = ?";
pub const Q_003: &str = "SELECT COUNT(*) FROM identities";
pub const Q_004: &str =
"UPDATE identities SET is_default = 0, updated_at = CURRENT_TIMESTAMP WHERE is_default = 1";
pub const FORMAT_001: &str =
"SELECT {IDENTITY_SELECT_COLS} FROM identities ORDER BY is_default DESC, email ASC";
pub const FORMAT_002: &str = "SELECT {IDENTITY_SELECT_COLS} FROM identities WHERE id = ?";
pub const FORMAT_003: &str =
"SELECT {IDENTITY_SELECT_COLS} FROM identities WHERE is_default = 1 LIMIT 1";
pub const FORMAT_004: &str = "UPDATE identities SET {} WHERE id = ?";
}
pub mod db_mail_preferences {
pub const Q_001: &str = "SELECT messages_per_page, mark_read_delay_ms, remote_images, preferred_body, thread_mode, collapse_quotes, show_snippets, default_sort, flagged_on_top, sync_imap_keywords, updated_at FROM mail_preferences WHERE id = 1";
pub const Q_002: &str = "INSERT IGNORE INTO mail_preferences (id) VALUES (1)";
pub const Q_003: &str = "UPDATE mail_preferences SET messages_per_page = ?, mark_read_delay_ms = ?, remote_images = ?, preferred_body = ?, thread_mode = ?, collapse_quotes = ?, show_snippets = ?, default_sort = ?, flagged_on_top = ?, sync_imap_keywords = ?, updated_at = CURRENT_TIMESTAMP WHERE id = 1";
}
pub mod db_messages {
pub const MSG_SELECT_COLS: &str = "uid, folder, message_id, in_reply_to, references_header, subject, from_address, from_name, to_addresses, cc_addresses, date, flags, size, has_attachments, snippet, reaction";
pub const Q_001: &str = "REPLACE INTO messages
(uid, folder, message_id, in_reply_to, references_header,
subject, from_address, from_name, to_addresses, cc_addresses,
date, flags, size, has_attachments, snippet, date_epoch, reaction)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
pub const Q_002: &str = "DELETE FROM messages WHERE folder = ?";
pub const Q_003: &str = "SELECT COALESCE(MAX(uid), 0) FROM messages WHERE folder = ?";
pub const Q_004: &str = "SELECT uid, folder, subject, from_address, from_name, to_addresses, \
COALESCE(NULLIF(body_text, ''), snippet, ''), date_epoch, has_attachments \
FROM messages ORDER BY folder, uid";
pub const Q_005: &str = "SELECT COUNT(*) FROM messages WHERE folder = ?";
pub const Q_006: &str = "SELECT COUNT(*) FROM messages";
pub const Q_007: &str = "UPDATE messages SET flags = ? WHERE folder = ? AND uid = ?";
pub const Q_008: &str = "UPDATE messages
SET body_html = ?, body_text = ?, body_cached = 1,
attachments_json = ?, raw_headers = ?, email_theme = ?
WHERE folder = ? AND uid = ?";
pub const Q_009: &str = "UPDATE messages SET email_theme = ? WHERE folder = ? AND uid = ?";
pub const Q_010: &str =
"SELECT body_cached, body_html, body_text, attachments_json, raw_headers, email_theme
FROM messages
WHERE folder = ? AND uid = ?";
pub const Q_011: &str = "DELETE FROM messages WHERE folder = ? AND uid = ?";
pub const Q_012: &str = "SELECT folder, uid FROM messages
WHERE body_cached = 0
ORDER BY date_epoch DESC
LIMIT ?";
pub const Q_013: &str = "SELECT uid, flags FROM messages WHERE folder = ?";
pub const Q_014: &str = "SELECT SUM(size) FROM messages";
pub const FORMAT_001: &str = "SELECT {MSG_SELECT_COLS}
FROM messages
WHERE folder = ?
ORDER BY date_epoch DESC
LIMIT ? OFFSET ?";
pub const FORMAT_002: &str = "SELECT {MSG_SELECT_COLS}
FROM messages
WHERE folder = ?
ORDER BY {order_by}
LIMIT ? OFFSET ?";
pub const FORMAT_003: &str = "SELECT {MSG_SELECT_COLS}
FROM messages
WHERE folder = ? AND uid = ?";
pub const FORMAT_004: &str = "SELECT {MSG_SELECT_COLS}
FROM messages
WHERE message_id = ?
OR in_reply_to = ?
OR references_header LIKE ? ESCAPE '\\'
ORDER BY date_epoch ASC";
pub const FORMAT_005: &str = "SELECT {MSG_SELECT_COLS}
FROM messages
{where_clause}
ORDER BY date_epoch DESC
LIMIT ?";
}
pub mod db_notification_preferences {
pub const Q_001: &str = "SELECT enabled, sound, folders, dnd_enabled, dnd_start, dnd_end, updated_at FROM notification_preferences WHERE id = 1";
pub const Q_002: &str = "INSERT IGNORE INTO notification_preferences (id) VALUES (1)";
pub const FORMAT_001: &str = "UPDATE notification_preferences SET {set_clause} WHERE id = ?";
}
pub mod db_pgp {
pub const KEY_COLS: &str = "fingerprint,key_id,email,user_id,public_key_armor,secret_key_path,has_secret,trust,source,created_at,updated_at";
pub const Q_001: &str = "INSERT INTO pgp_keys (fingerprint,key_id,email,user_id,public_key_armor,secret_key_path,has_secret,trust,source) VALUES (?,?,?,?,?,?,?,?,?)\n ON CONFLICT(fingerprint) DO UPDATE SET key_id=excluded.key_id,email=CASE WHEN excluded.email='' THEN pgp_keys.email ELSE excluded.email END,user_id=CASE WHEN excluded.user_id='' THEN pgp_keys.user_id ELSE excluded.user_id END,public_key_armor=excluded.public_key_armor,secret_key_path=COALESCE(excluded.secret_key_path,pgp_keys.secret_key_path),has_secret=MAX(pgp_keys.has_secret,excluded.has_secret),trust=excluded.trust,source=excluded.source,updated_at=CURRENT_TIMESTAMP";
pub const Q_002: &str = "DELETE FROM pgp_keys WHERE replace(upper(fingerprint),' ','')=?";
pub const Q_003: &str = "UPDATE pgp_keys SET trust=?,updated_at=CURRENT_TIMESTAMP WHERE replace(upper(fingerprint),' ','')=?";
pub const Q_004: &str =
"SELECT wkd_enabled,hkp_enabled,require_trusted_encryption FROM pgp_preferences WHERE id=1";
pub const Q_005: &str = "UPDATE pgp_preferences SET wkd_enabled=?,hkp_enabled=?,require_trusted_encryption=?,updated_at=CURRENT_TIMESTAMP WHERE id=1";
pub const FORMAT_001: &str =
"SELECT {KEY_COLS} FROM pgp_keys ORDER BY has_secret DESC, lower(email), fingerprint";
pub const FORMAT_002: &str =
"SELECT {KEY_COLS} FROM pgp_keys WHERE replace(upper(fingerprint),' ','')=?";
pub const FORMAT_003: &str = "SELECT {KEY_COLS} FROM pgp_keys WHERE lower(email)=lower(?) ORDER BY CASE trust WHEN 'verified' THEN 0 WHEN 'trusted' THEN 1 WHEN 'unknown' THEN 2 ELSE 3 END, has_secret DESC";
pub const FORMAT_004: &str = "SELECT {KEY_COLS} FROM pgp_keys WHERE lower(email)=lower(?) ORDER BY CASE trust WHEN 'verified' THEN 0 WHEN 'trusted' THEN 1 WHEN 'unknown' THEN 2 ELSE 3 END, has_secret DESC LIMIT 1";
pub const FORMAT_005: &str = "SELECT {KEY_COLS} FROM pgp_keys WHERE has_secret=1 AND secret_key_path IS NOT NULL ORDER BY lower(email), fingerprint";
}
pub mod db_pool {
pub const Q_001: &str = "SELECT 0";
pub const Q_002: &str = "SELECT CURRENT_TIMESTAMP";
pub const Q_003: &str = "SELECT 1";
pub const Q_004: &str = "SELECT 1";
pub const Q_005: &str = "SHOW TABLES";
pub const Q_006: &str = "SELECT 1";
}
pub mod db_response_templates {
pub const Q_001: &str =
"SELECT id,name,body,format,created_at,updated_at FROM response_templates ORDER BY name ";
pub const Q_002: &str =
"SELECT id,name,body,format,created_at,updated_at FROM response_templates WHERE id=?";
pub const Q_003: &str = "INSERT INTO response_templates(name,body,format) VALUES (?,?,?)";
pub const Q_004: &str = "UPDATE response_templates SET name=?,body=?,format=?,updated_at=CURRENT_TIMESTAMP WHERE id=?";
pub const Q_005: &str = "DELETE FROM response_templates WHERE id=?";
}
pub mod db_saved_searches {
pub const Q_001: &str =
"SELECT id,name,query_string,created_at,updated_at FROM saved_searches ORDER BY name ";
pub const Q_002: &str = "INSERT INTO saved_searches(name,query_string) VALUES (?,?)";
pub const Q_003: &str =
"UPDATE saved_searches SET name=?,query_string=?,updated_at=CURRENT_TIMESTAMP WHERE id=?";
pub const Q_004: &str =
"SELECT id,name,query_string,created_at,updated_at FROM saved_searches WHERE id=?";
pub const Q_005: &str = "DELETE FROM saved_searches WHERE id=?";
}
pub mod db_special_folders {
pub const Q_001: &str = "SELECT folder_name FROM special_folders WHERE role = ?";
pub const Q_002: &str = "SELECT EXISTS(SELECT 1 FROM folders WHERE name = ?)";
pub const Q_003: &str = "INSERT INTO special_folders(role, folder_name, updated_at) VALUES(?, ?, CURRENT_TIMESTAMP)\n ON CONFLICT(role) DO UPDATE SET folder_name = excluded.folder_name, updated_at = CURRENT_TIMESTAMP";
pub const Q_004: &str = "DELETE FROM special_folders WHERE role = ?";
pub const Q_005: &str = "UPDATE special_folders SET folder_name = ?, updated_at = CURRENT_TIMESTAMP WHERE folder_name = ?";
pub const Q_006: &str = "DELETE FROM special_folders WHERE folder_name = ?";
}
pub mod db_tags {
pub const Q_001: &str = "INSERT INTO tags (id, name, color, imap_keyword) VALUES (?, ?, ?, ?)";
pub const Q_002: &str =
"SELECT t.id, t.name, t.color, t.imap_keyword, t.created_at, t.updated_at,
(SELECT COUNT(*) FROM message_tags mt WHERE mt.tag_id = t.id) AS message_count
FROM tags t
ORDER BY t.name ASC";
pub const Q_003: &str = "SELECT message_uid, message_folder FROM message_tags WHERE tag_id = ?";
pub const Q_004: &str =
"UPDATE tags SET name = ?, color = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?";
pub const Q_005: &str = "DELETE FROM tags WHERE id = ?";
pub const Q_006: &str =
"INSERT IGNORE INTO message_tags (tag_id, message_uid, message_folder) VALUES (?, ?, ?)";
pub const Q_007: &str =
"DELETE FROM message_tags WHERE tag_id = ? AND message_uid = ? AND message_folder = ?";
pub const Q_008: &str = "SELECT t.id, t.name, t.color
FROM tags t
JOIN message_tags mt ON mt.tag_id = t.id
WHERE mt.message_uid = ? AND mt.message_folder = ?
ORDER BY t.name ASC";
pub const Q_009: &str =
"SELECT m.uid, m.folder, m.message_id, m.in_reply_to, m.references_header,
m.subject, m.from_address, m.from_name, m.to_addresses, m.cc_addresses,
m.date, m.flags, m.size, m.has_attachments, m.snippet, m.reaction
FROM messages m
JOIN message_tags mt ON mt.message_uid = m.uid AND mt.message_folder = m.folder
WHERE mt.tag_id = ?
ORDER BY m.date_epoch DESC
LIMIT ? OFFSET ?";
pub const Q_010: &str = "SELECT COUNT(*) FROM message_tags WHERE tag_id = ?";
pub const Q_011: &str = "INSERT IGNORE INTO folders (name) VALUES (?)";
pub const Q_012: &str = "INSERT INTO messages (uid, folder, subject, from_address, from_name, to_addresses, date, flags, size, has_attachments, snippet)
VALUES (?, ?, 'Test', 'test@ex.com', 'Test', '[]', '2024-01-01', '', 100, 0, '')";
pub const FORMAT_001: &str = "SELECT mt.message_uid, mt.message_folder, t.id, t.name, t.color
FROM message_tags mt
JOIN tags t ON t.id = mt.tag_id
WHERE {}
ORDER BY t.name ASC";
}
pub mod db_trusted_senders {
pub const Q_001: &str = "SELECT email FROM trusted_senders ORDER BY lower(email)";
pub const Q_002: &str = "INSERT IGNORE INTO trusted_senders(email) VALUES(?)";
pub const Q_003: &str = "DELETE FROM trusted_senders WHERE email = ? ";
}
pub mod routes_contacts {
pub const Q_001: &str = "SELECT CURRENT_TIMESTAMP";
pub const Q_002: &str = "SELECT CURRENT_TIMESTAMP";
pub const Q_003: &str = "SELECT CURRENT_TIMESTAMP";
}
pub mod routes_diagnostics {
pub const Q_001: &str = "SELECT COUNT(*),
COALESCE(SUM(CASE WHEN messages_updated_at IS NULL THEN 1 ELSE 0 END), 0),
CASE WHEN MAX(updated_at) IS NULL THEN NULL ELSE replace(MAX(updated_at), ' ', 'T') || 'Z' END,
CASE WHEN MAX(messages_updated_at) IS NULL THEN NULL ELSE replace(MAX(messages_updated_at), ' ', 'T') || 'Z' END
FROM folders";
pub const Q_002: &str = "SELECT COUNT(*) FROM messages";
pub const Q_003: &str = "SELECT COUNT(*) FROM messages WHERE body_cached = 1";
}
pub mod routes_folder_mgmt {
pub const Q_001: &str = "UPDATE messages SET flags = CASE WHEN flags = '' THEN ? ELSE flags || ',' || ? END WHERE folder = ? AND instr(flags, ?) = 0";
pub const Q_002: &str = "UPDATE folders SET unread_count = 0 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 mod routes_messages_mod {
pub const Q_001: &str = "SELECT flags FROM messages WHERE folder = ? AND uid = ?";
}
pub mod routes_pgp {
pub const Q_001: &str = "UPDATE identities SET pgp_fingerprint=NULL,pgp_sign_default=0,pgp_encrypt_default=0,pgp_inline_default=0 WHERE replace(upper(COALESCE(pgp_fingerprint,'')),' ','')=?";
}
pub mod routes_special_folders {
pub const Q_001: &str = "SELECT EXISTS(SELECT 1 FROM folders WHERE name = ?)";
}
pub mod sessions {
pub const UPSERT: &str = r#"INSERT INTO sessions (
token, account_id, browser_id, email, password_ciphertext, user_hash,
imap_host, imap_port, imap_tls, imap_tls_verify, smtp_host, smtp_port,
smtp_tls, smtp_tls_verify, timeout_secs, created_at_unix, last_accessed_unix, user_agent
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
account_id=VALUES(account_id), browser_id=VALUES(browser_id), email=VALUES(email),
password_ciphertext=VALUES(password_ciphertext), user_hash=VALUES(user_hash), imap_host=VALUES(imap_host),
imap_port=VALUES(imap_port), imap_tls=VALUES(imap_tls), imap_tls_verify=VALUES(imap_tls_verify),
smtp_host=VALUES(smtp_host), smtp_port=VALUES(smtp_port), smtp_tls=VALUES(smtp_tls),
smtp_tls_verify=VALUES(smtp_tls_verify), timeout_secs=VALUES(timeout_secs),
last_accessed_unix=VALUES(last_accessed_unix), user_agent=VALUES(user_agent)"#;
pub const BY_TOKEN: &str = "SELECT * FROM sessions WHERE token = ?";
pub const BY_BROWSER: &str =
"SELECT * FROM sessions WHERE browser_id = ? ORDER BY created_at_unix";
pub const BY_USER: &str =
"SELECT * FROM sessions WHERE user_hash = ? ORDER BY last_accessed_unix DESC";
pub const BROWSER_EXISTS: &str = "SELECT COUNT(*) FROM sessions WHERE browser_id = ?";
pub const DELETE_ACCOUNT: &str = "DELETE FROM sessions WHERE account_id = ?";
pub const DELETE_BROWSER: &str = "DELETE FROM sessions WHERE browser_id = ?";
pub const DELETE_EXPIRED: &str =
"DELETE FROM sessions WHERE last_accessed_unix + timeout_secs < ?";
}
+1
View File
@@ -0,0 +1 @@
pub mod queries;
+486
View File
@@ -0,0 +1,486 @@
pub mod auth_user_data {
pub const Q_001: &str = "SELECT value FROM user_meta WHERE key = 'schema_version'";
}
pub mod db_calendar {
pub const EVENT_SELECT_COLS: &str = "id, title, description, location, start_time, end_time, \
all_day, recurrence_rule, attendees, organizer, status, source, source_uid, \
source_message_uid, source_message_folder, meeting_url, color, reminder_minutes, \
created_at, updated_at";
pub const SETTINGS_SELECT_COLS: &str = "default_view, week_starts_on, working_hours_start, \
working_hours_end, time_format, zoom_link, teams_link, meet_link, caldav_url, \
caldav_username, caldav_enabled, updated_at";
pub const Q_001: &str = "SELECT CURRENT_TIMESTAMP";
pub const Q_002: &str = "INSERT INTO calendar_events
(id, title, description, location, start_time, end_time, all_day,
recurrence_rule, attendees, organizer, status, source, source_uid,
source_message_uid, source_message_folder, meeting_url, color,
reminder_minutes, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20)";
pub const Q_003: &str = "DELETE FROM calendar_events WHERE id = $1";
pub const Q_004: &str = "INSERT OR IGNORE INTO calendar_settings (id) VALUES (1)";
pub const Q_005: &str = "INSERT OR IGNORE INTO calendar_settings (id) VALUES (1)";
pub const Q_006: &str = "SELECT id, name, url_template, icon, is_default, created_at
FROM meeting_templates ORDER BY is_default DESC, name ASC";
pub const Q_007: &str =
"INSERT INTO meeting_templates (name, url_template, icon) VALUES ($1, $2, $3)";
pub const Q_008: &str = "SELECT id, name, url_template, icon, is_default, created_at
FROM meeting_templates WHERE id = $1";
pub const Q_009: &str = "DELETE FROM meeting_templates WHERE id = $1";
pub const FORMAT_001: &str = "SELECT {EVENT_SELECT_COLS} FROM calendar_events
WHERE end_time >= $1 AND start_time <= $2
ORDER BY start_time ASC";
pub const FORMAT_002: &str =
"SELECT {EVENT_SELECT_COLS} FROM calendar_events ORDER BY start_time ASC, title ASC";
pub const FORMAT_003: &str = "SELECT {EVENT_SELECT_COLS} FROM calendar_events WHERE id = $1";
pub const FORMAT_004: &str = "UPDATE calendar_events SET {} WHERE id = ${idx}";
pub const FORMAT_005: &str =
"SELECT {EVENT_SELECT_COLS} FROM calendar_events WHERE source_uid = $1";
pub const FORMAT_006: &str =
"SELECT {SETTINGS_SELECT_COLS} FROM calendar_settings WHERE id = 1";
pub const FORMAT_007: &str = "UPDATE calendar_settings SET {} WHERE id = 1";
}
pub mod db_contact_groups {
pub const Q_001: &str = "INSERT INTO contact_groups (id, name) VALUES ($1, $2)";
pub const Q_002: &str = "SELECT g.id, g.name, g.created_at, g.updated_at,
(SELECT COUNT(*) FROM contact_group_members m WHERE m.group_id = g.id) AS member_count
FROM contact_groups g
ORDER BY g.name ASC";
pub const Q_003: &str =
"UPDATE contact_groups SET name = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2";
pub const Q_004: &str = "DELETE FROM contact_groups WHERE id = $1";
pub const Q_005: &str =
"INSERT OR IGNORE INTO contact_group_members (group_id, contact_id) VALUES ($1, $2)";
pub const Q_006: &str =
"DELETE FROM contact_group_members WHERE group_id = $1 AND contact_id = $2";
pub const Q_007: &str = "SELECT c.id, c.email, c.name, c.company, c.notes, c.phone, c.job_title, c.address, c.website, c.birthday, c.is_favorite,
c.last_contacted, c.contact_count, c.source, c.created_at, c.updated_at, c.emails_json, c.phones_json, c.addresses_json, EXISTS(SELECT 1 FROM contact_photos p WHERE p.contact_id = c.id) AS has_photo
FROM contacts c
JOIN contact_group_members m ON m.contact_id = c.id
WHERE m.group_id = $1
ORDER BY c.name ASC";
pub const Q_008: &str = "SELECT g.id, g.name, g.created_at, g.updated_at,
(SELECT COUNT(*) FROM contact_group_members m2 WHERE m2.group_id = g.id) AS member_count
FROM contact_groups g
JOIN contact_group_members m ON m.group_id = g.id
WHERE m.contact_id = $1
ORDER BY g.name ASC";
}
pub mod db_contacts {
pub const CONTACT_SELECT_COLS: &str = "id, email, name, company, notes, phone, job_title, address, website, birthday, is_favorite, last_contacted, contact_count, source, created_at, updated_at, emails_json, phones_json, addresses_json, EXISTS(SELECT 1 FROM contact_photos p WHERE p.contact_id = contacts.id) AS has_photo";
pub const Q_001: &str = "INSERT INTO contacts
(id, email, name, company, notes, phone, job_title, address, website, birthday, is_favorite, last_contacted,
contact_count, source, created_at, updated_at, emails_json, phones_json, addresses_json)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)
ON CONFLICT(id) DO UPDATE SET
email=excluded.email, name=excluded.name, company=excluded.company, notes=excluded.notes,
phone=excluded.phone, job_title=excluded.job_title, address=excluded.address, website=excluded.website, birthday=excluded.birthday,
is_favorite=excluded.is_favorite, last_contacted=excluded.last_contacted, contact_count=excluded.contact_count,
source=excluded.source, updated_at=excluded.updated_at, emails_json=excluded.emails_json,
phones_json=excluded.phones_json, addresses_json=excluded.addresses_json";
pub const Q_002: &str = "DELETE FROM contacts WHERE id = $1";
pub const Q_003: &str = r#"
SELECT email, name
FROM known_addresses
WHERE (email LIKE $1 ESCAPE '\' OR name LIKE $1 ESCAPE '\')
AND email NOT IN (SELECT email FROM contacts)
AND NOT EXISTS (
SELECT 1 FROM contacts c, json_each(c.emails_json) alt
WHERE lower(alt.value) = lower(known_addresses.email)
)
ORDER BY email ASC
LIMIT $2
"#;
pub const Q_004: &str = "INSERT INTO known_addresses (email, name) VALUES ($1, $2)
ON CONFLICT(email) DO UPDATE SET name = excluded.name WHERE excluded.name != ''";
pub const Q_005: &str = "INSERT INTO known_addresses (email, name) VALUES ($1, $2)
ON CONFLICT(email) DO UPDATE SET name = excluded.name WHERE excluded.name != ''";
pub const Q_006: &str = "UPDATE contacts
SET contact_count = contact_count + 1,
last_contacted = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
WHERE email = $1";
pub const Q_007: &str = "INSERT OR IGNORE INTO contacts (id, email, name, source)
VALUES ($1, $2, $3, 'auto')";
pub const Q_008: &str = "INSERT INTO contact_photos (contact_id, content_type, data, updated_at) VALUES ($1, $2, $3, CURRENT_TIMESTAMP) ON CONFLICT(contact_id) DO UPDATE SET content_type=excluded.content_type, data=excluded.data, updated_at=CURRENT_TIMESTAMP";
pub const Q_009: &str = "SELECT content_type, data FROM contact_photos WHERE contact_id = $1";
pub const Q_010: &str = "DELETE FROM contact_photos WHERE contact_id = $1";
pub const FORMAT_001: &str = "SELECT {CONTACT_SELECT_COLS} FROM contacts WHERE id = $1";
pub const FORMAT_002: &str = "SELECT {CONTACT_SELECT_COLS} FROM contacts \
WHERE lower(email) = lower($1) \
OR EXISTS (SELECT 1 FROM json_each(contacts.emails_json) WHERE lower(value) = lower($1))";
pub const FORMAT_003: &str = "SELECT {CONTACT_SELECT_COLS} FROM contacts
WHERE name LIKE $1 ESCAPE '\\' OR email LIKE $1 ESCAPE '\\' OR emails_json LIKE $1 ESCAPE '\\'
ORDER BY name ASC
LIMIT $2 OFFSET $3";
pub const FORMAT_004: &str = "SELECT {CONTACT_SELECT_COLS} FROM contacts
ORDER BY name ASC
LIMIT $1 OFFSET $2";
pub const FORMAT_005: &str = "SELECT {CONTACT_SELECT_COLS} FROM contacts
WHERE name LIKE $1 ESCAPE '\\' OR email LIKE $1 ESCAPE '\\' OR emails_json LIKE $1 ESCAPE '\\'
ORDER BY name ASC
LIMIT $2";
}
pub mod db_display_preferences {
pub const Q_001: &str = "SELECT density, theme, language, compose_format, deep_index, animation_mode, preview_pane, ui_scale, color_preset, accent_color, background_color, panel_color, text_color, font_family, font_size, row_height, folder_pane_width, message_pane_width, border_radius, contrast, show_avatars, show_attachment_icons, show_flags, reduce_motion, date_format, time_format, compose_autosave_ms, default_priority, request_mdn_default, attachment_reminder, icon_size, toolbar_density, sidebar_item_height, reader_max_width, composer_width, selection_color, unread_font_weight, unread_badge_style, compact_headers, show_borders, scrollbar_density, monochrome, folder_background, message_background, reader_background, compose_font_family, reply_position, quote_style, forward_mode, spellcheck_enabled, interface_skin, preset_scale_unlocked, updated_at FROM display_preferences WHERE id = 1";
pub const Q_002: &str = "INSERT OR IGNORE INTO display_preferences (id) VALUES (1)";
pub const FORMAT_001: &str = "UPDATE display_preferences SET {} WHERE id = ${}";
}
pub mod db_drafts {
pub const Q_001: &str = "INSERT INTO drafts (id, to_addresses, cc_addresses, bcc_addresses, subject, text_body, html_body, in_reply_to, references_header, request_mdn, priority, compose_format, pgp_mode, pgp_inline, pgp_attach_public_key, pgp_attach_recipient_keys, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, CURRENT_TIMESTAMP)
ON CONFLICT(id) DO UPDATE SET
to_addresses = excluded.to_addresses,
cc_addresses = excluded.cc_addresses,
bcc_addresses = excluded.bcc_addresses,
subject = excluded.subject,
text_body = excluded.text_body,
html_body = excluded.html_body,
in_reply_to = excluded.in_reply_to,
references_header = excluded.references_header,
request_mdn = excluded.request_mdn,
priority = excluded.priority,
compose_format = excluded.compose_format,
pgp_mode = excluded.pgp_mode,
pgp_inline = excluded.pgp_inline,
pgp_attach_public_key = excluded.pgp_attach_public_key,
pgp_attach_recipient_keys = excluded.pgp_attach_recipient_keys,
updated_at = CURRENT_TIMESTAMP";
pub const Q_002: &str = "SELECT id, to_addresses, cc_addresses, bcc_addresses, subject, text_body, html_body, in_reply_to, references_header, request_mdn, priority, compose_format, pgp_mode, pgp_inline, pgp_attach_public_key, pgp_attach_recipient_keys, remote_folder, remote_uid, remote_uid_validity, created_at, updated_at
FROM drafts WHERE id = $1";
pub const Q_003: &str = "SELECT id, to_addresses, cc_addresses, bcc_addresses, subject, text_body, html_body, in_reply_to, references_header, request_mdn, priority, compose_format, pgp_mode, pgp_inline, pgp_attach_public_key, pgp_attach_recipient_keys, remote_folder, remote_uid, remote_uid_validity, created_at, updated_at
FROM drafts ORDER BY updated_at DESC";
pub const Q_004: &str = "UPDATE drafts SET remote_folder = $2, remote_uid = $3, remote_uid_validity = $4 WHERE id = $1";
pub const Q_005: &str = "DELETE FROM drafts WHERE id = $1";
pub const Q_006: &str =
"INSERT INTO draft_attachments (id, draft_id, filename, content_type, size, file_path)
VALUES ($1, $2, $3, $4, $5, $6)";
pub const Q_007: &str =
"SELECT id, draft_id, filename, content_type, size, file_path, created_at
FROM draft_attachments WHERE draft_id = $1 ORDER BY created_at ASC";
pub const Q_008: &str = "DELETE FROM draft_attachments WHERE id = $1";
}
pub mod db_folder_preferences {
pub const Q_001: &str = "SELECT flagged_on_top FROM folder_preferences WHERE folder_name = $1";
pub const Q_002: &str = "SELECT flagged_on_top FROM mail_preferences WHERE id = 1";
pub const Q_003: &str = "SELECT EXISTS(SELECT 1 FROM folders WHERE name = $1)";
pub const Q_004: &str = "SELECT flagged_on_top FROM mail_preferences WHERE id = 1";
pub const Q_005: &str = "SELECT EXISTS(SELECT 1 FROM folders WHERE name = $1)";
pub const Q_006: &str = "INSERT INTO folder_preferences(folder_name, flagged_on_top, updated_at) VALUES($1, $2, CURRENT_TIMESTAMP)\n ON CONFLICT(folder_name) DO UPDATE SET flagged_on_top = excluded.flagged_on_top, updated_at = CURRENT_TIMESTAMP";
pub const Q_007: &str = "DELETE FROM folder_preferences WHERE folder_name = $1";
}
pub mod db_folders {
pub const Q_001: &str = "INSERT OR REPLACE 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, CURRENT_TIMESTAMP)";
pub const Q_002: &str = "INSERT OR IGNORE INTO folders
(name, delimiter, flags, is_subscribed, updated_at)
VALUES ($1, $2, $3, 1, CURRENT_TIMESTAMP)";
pub const Q_003: &str = "SELECT name, delimiter, parent, flags, is_subscribed,
total_count, unread_count, uid_validity, highest_modseq
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 = $1, total_count = $2, messages_updated_at = CURRENT_TIMESTAMP
WHERE name = $3";
pub const Q_007: &str =
"UPDATE folders SET uid_validity = $1, total_count = $2, highest_modseq = $3
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 = GREATEST(0, CAST(unread_count AS INTEGER) + $1) WHERE name = $2";
pub const Q_011: &str = "SELECT EXISTS(
SELECT 1 FROM folders
WHERE updated_at > datetime('now', $1)
)";
pub const Q_012: &str = "SELECT EXISTS(
SELECT 1 FROM folders
WHERE name = $1 AND messages_updated_at > datetime('now', $2)
)";
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
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, CURRENT_TIMESTAMP, 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";
pub const Q_022: &str =
"UPDATE calendar_events SET source_message_folder = $1 WHERE source_message_folder = $2";
pub const Q_023: &str = "UPDATE folders SET parent = $1 WHERE parent = $2";
pub const Q_024: &str = "DELETE FROM folders WHERE name = $1";
pub const Q_025: &str = "SELECT name FROM folders WHERE flags LIKE '%' || $1 || '%' LIMIT 1";
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 FORMAT_001: &str = "DELETE FROM folders WHERE name NOT IN ({})";
}
pub mod db_header_sync {
pub const Q_001: &str = "SELECT value FROM user_meta WHERE key = 'header_sync_generation'";
pub const Q_002: &str =
"INSERT INTO user_meta (key, value) VALUES ('header_sync_generation', '1')
ON CONFLICT(key) DO UPDATE SET value = CAST(value AS INTEGER) + 1";
}
pub mod db_identities {
pub const IDENTITY_SELECT_COLS: &str = "id, display_name, email, signature_html, is_default, created_at, updated_at, reply_to, bcc, organization, signature_text, signature_new, signature_reply, signature_forward, signature_position, pgp_fingerprint, pgp_sign_default, pgp_encrypt_default, pgp_inline_default";
pub const Q_001: &str = "INSERT INTO identities (display_name,email,signature_html,is_default,reply_to,bcc,organization,signature_text,signature_new,signature_reply,signature_forward,signature_position,pgp_fingerprint,pgp_sign_default,pgp_encrypt_default,pgp_inline_default) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16)";
pub const Q_002: &str = "DELETE FROM identities WHERE id = $1";
pub const Q_003: &str = "SELECT COUNT(*) FROM identities";
pub const Q_004: &str =
"UPDATE identities SET is_default = 0, updated_at = CURRENT_TIMESTAMP WHERE is_default = 1";
pub const FORMAT_001: &str =
"SELECT {IDENTITY_SELECT_COLS} FROM identities ORDER BY is_default DESC, email ASC";
pub const FORMAT_002: &str = "SELECT {IDENTITY_SELECT_COLS} FROM identities WHERE id = $1";
pub const FORMAT_003: &str =
"SELECT {IDENTITY_SELECT_COLS} FROM identities WHERE is_default = 1 LIMIT 1";
pub const FORMAT_004: &str = "UPDATE identities SET {} WHERE id = ${}";
}
pub mod db_mail_preferences {
pub const Q_001: &str = "SELECT messages_per_page, mark_read_delay_ms, remote_images, preferred_body, thread_mode, collapse_quotes, show_snippets, default_sort, flagged_on_top, sync_imap_keywords, updated_at FROM mail_preferences WHERE id = 1";
pub const Q_002: &str = "INSERT OR IGNORE INTO mail_preferences (id) VALUES (1)";
pub const Q_003: &str = "UPDATE mail_preferences SET messages_per_page = $1, mark_read_delay_ms = $2, remote_images = $3, preferred_body = $4, thread_mode = $5, collapse_quotes = $6, show_snippets = $7, default_sort = $8, flagged_on_top = $9, sync_imap_keywords = $10, updated_at = CURRENT_TIMESTAMP WHERE id = 1";
}
pub mod db_messages {
pub const MSG_SELECT_COLS: &str = "uid, folder, message_id, in_reply_to, references_header, subject, from_address, from_name, to_addresses, cc_addresses, date, flags, size, has_attachments, snippet, reaction";
pub const Q_001: &str = "INSERT OR REPLACE INTO messages
(uid, folder, message_id, in_reply_to, references_header,
subject, from_address, from_name, to_addresses, cc_addresses,
date, flags, size, has_attachments, snippet, date_epoch, reaction)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)";
pub const Q_002: &str = "DELETE FROM messages WHERE folder = $1";
pub const Q_003: &str = "SELECT COALESCE(MAX(uid), 0) FROM messages WHERE folder = $1";
pub const Q_004: &str = "SELECT uid, folder, subject, from_address, from_name, to_addresses, \
COALESCE(NULLIF(body_text, ''), snippet, ''), date_epoch, has_attachments \
FROM messages ORDER BY folder, uid";
pub const Q_005: &str = "SELECT COUNT(*) FROM messages WHERE folder = $1";
pub const Q_006: &str = "SELECT COUNT(*) FROM messages";
pub const Q_007: &str = "UPDATE messages SET flags = $1 WHERE folder = $2 AND uid = $3";
pub const Q_008: &str = "UPDATE messages
SET body_html = $1, body_text = $2, body_cached = 1,
attachments_json = $3, raw_headers = $4, email_theme = $5
WHERE folder = $6 AND uid = $7";
pub const Q_009: &str = "UPDATE messages SET email_theme = $1 WHERE folder = $2 AND uid = $3";
pub const Q_010: &str =
"SELECT body_cached, body_html, body_text, attachments_json, raw_headers, email_theme
FROM messages
WHERE folder = $1 AND uid = $2";
pub const Q_011: &str = "DELETE FROM messages WHERE folder = $1 AND uid = $2";
pub const Q_012: &str = "SELECT folder, uid FROM messages
WHERE body_cached = 0
ORDER BY date_epoch DESC
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 FORMAT_001: &str = "SELECT {MSG_SELECT_COLS}
FROM messages
WHERE folder = $1
ORDER BY date_epoch DESC
LIMIT $2 OFFSET $3";
pub const FORMAT_002: &str = "SELECT {MSG_SELECT_COLS}
FROM messages
WHERE folder = $1
ORDER BY {order_by}
LIMIT $2 OFFSET $3";
pub const FORMAT_003: &str = "SELECT {MSG_SELECT_COLS}
FROM messages
WHERE folder = $1 AND uid = $2";
pub const FORMAT_004: &str = "SELECT {MSG_SELECT_COLS}
FROM messages
WHERE message_id = $1
OR in_reply_to = $1
OR references_header LIKE $2 ESCAPE '\\'
ORDER BY date_epoch ASC";
pub const FORMAT_005: &str = "SELECT {MSG_SELECT_COLS}
FROM messages
{where_clause}
ORDER BY date_epoch DESC
LIMIT ${}";
}
pub mod db_notification_preferences {
pub const Q_001: &str = "SELECT enabled, sound, folders, dnd_enabled, dnd_start, dnd_end, updated_at FROM notification_preferences WHERE id = 1";
pub const Q_002: &str = "INSERT OR IGNORE INTO notification_preferences (id) VALUES (1)";
pub const FORMAT_001: &str =
"UPDATE notification_preferences SET {set_clause} WHERE id = ${idx}";
}
pub mod db_pgp {
pub const KEY_COLS: &str = "fingerprint,key_id,email,user_id,public_key_armor,secret_key_path,has_secret,trust,source,created_at,updated_at";
pub const Q_001: &str = "INSERT INTO pgp_keys (fingerprint,key_id,email,user_id,public_key_armor,secret_key_path,has_secret,trust,source) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)\n ON CONFLICT(fingerprint) DO UPDATE SET key_id=excluded.key_id,email=CASE WHEN excluded.email='' THEN pgp_keys.email ELSE excluded.email END,user_id=CASE WHEN excluded.user_id='' THEN pgp_keys.user_id ELSE excluded.user_id END,public_key_armor=excluded.public_key_armor,secret_key_path=COALESCE(excluded.secret_key_path,pgp_keys.secret_key_path),has_secret=MAX(pgp_keys.has_secret,excluded.has_secret),trust=excluded.trust,source=excluded.source,updated_at=CURRENT_TIMESTAMP";
pub const Q_002: &str = "DELETE FROM pgp_keys WHERE replace(upper(fingerprint),' ','')=$1";
pub const Q_003: &str = "UPDATE pgp_keys SET trust=$2,updated_at=CURRENT_TIMESTAMP WHERE replace(upper(fingerprint),' ','')=$1";
pub const Q_004: &str =
"SELECT wkd_enabled,hkp_enabled,require_trusted_encryption FROM pgp_preferences WHERE id=1";
pub const Q_005: &str = "UPDATE pgp_preferences SET wkd_enabled=$1,hkp_enabled=$2,require_trusted_encryption=$3,updated_at=CURRENT_TIMESTAMP WHERE id=1";
pub const FORMAT_001: &str =
"SELECT {KEY_COLS} FROM pgp_keys ORDER BY has_secret DESC, lower(email), fingerprint";
pub const FORMAT_002: &str =
"SELECT {KEY_COLS} FROM pgp_keys WHERE replace(upper(fingerprint),' ','')=$1";
pub const FORMAT_003: &str = "SELECT {KEY_COLS} FROM pgp_keys WHERE lower(email)=lower($1) ORDER BY CASE trust WHEN 'verified' THEN 0 WHEN 'trusted' THEN 1 WHEN 'unknown' THEN 2 ELSE 3 END, has_secret DESC";
pub const FORMAT_004: &str = "SELECT {KEY_COLS} FROM pgp_keys WHERE lower(email)=lower($1) ORDER BY CASE trust WHEN 'verified' THEN 0 WHEN 'trusted' THEN 1 WHEN 'unknown' THEN 2 ELSE 3 END, has_secret DESC LIMIT 1";
pub const FORMAT_005: &str = "SELECT {KEY_COLS} FROM pgp_keys WHERE has_secret=1 AND secret_key_path IS NOT NULL ORDER BY lower(email), fingerprint";
}
pub mod db_pool {
pub const Q_001: &str = "SELECT 0";
pub const Q_002: &str = "SELECT CURRENT_TIMESTAMP";
pub const Q_003: &str = "SELECT 1";
pub const Q_004: &str = "SELECT 1";
pub const Q_005: &str = "SELECT tablename FROM pg_catalog.pg_tables WHERE schemaname = current_schema() ORDER BY tablename";
pub const Q_006: &str = "SELECT 1";
}
pub mod db_response_templates {
pub const Q_001: &str =
"SELECT id,name,body,format,created_at,updated_at FROM response_templates ORDER BY name ";
pub const Q_002: &str =
"SELECT id,name,body,format,created_at,updated_at FROM response_templates WHERE id=$1";
pub const Q_003: &str = "INSERT INTO response_templates(name,body,format) VALUES ($1,$2,$3)";
pub const Q_004: &str = "UPDATE response_templates SET name=$1,body=$2,format=$3,updated_at=CURRENT_TIMESTAMP WHERE id=$4";
pub const Q_005: &str = "DELETE FROM response_templates WHERE id=$1";
}
pub mod db_saved_searches {
pub const Q_001: &str =
"SELECT id,name,query_string,created_at,updated_at FROM saved_searches ORDER BY name ";
pub const Q_002: &str = "INSERT INTO saved_searches(name,query_string) VALUES ($1,$2)";
pub const Q_003: &str = "UPDATE saved_searches SET name=$1,query_string=$2,updated_at=CURRENT_TIMESTAMP WHERE id=$3";
pub const Q_004: &str =
"SELECT id,name,query_string,created_at,updated_at FROM saved_searches WHERE id=$1";
pub const Q_005: &str = "DELETE FROM saved_searches WHERE id=$1";
}
pub mod db_special_folders {
pub const Q_001: &str = "SELECT folder_name FROM special_folders WHERE role = $1";
pub const Q_002: &str = "SELECT EXISTS(SELECT 1 FROM folders WHERE name = $1)";
pub const Q_003: &str = "INSERT INTO special_folders(role, folder_name, updated_at) VALUES($1, $2, CURRENT_TIMESTAMP)\n ON CONFLICT(role) DO UPDATE SET folder_name = excluded.folder_name, updated_at = CURRENT_TIMESTAMP";
pub const Q_004: &str = "DELETE FROM special_folders WHERE role = $1";
pub const Q_005: &str = "UPDATE special_folders SET folder_name = $1, updated_at = CURRENT_TIMESTAMP WHERE folder_name = $2";
pub const Q_006: &str = "DELETE FROM special_folders WHERE folder_name = $1";
}
pub mod db_tags {
pub const Q_001: &str =
"INSERT INTO tags (id, name, color, imap_keyword) VALUES ($1, $2, $3, $4)";
pub const Q_002: &str =
"SELECT t.id, t.name, t.color, t.imap_keyword, t.created_at, t.updated_at,
(SELECT COUNT(*) FROM message_tags mt WHERE mt.tag_id = t.id) AS message_count
FROM tags t
ORDER BY t.name ASC";
pub const Q_003: &str =
"SELECT message_uid, message_folder FROM message_tags WHERE tag_id = $1";
pub const Q_004: &str =
"UPDATE tags SET name = $1, color = $2, updated_at = CURRENT_TIMESTAMP WHERE id = $3";
pub const Q_005: &str = "DELETE FROM tags WHERE id = $1";
pub const Q_006: &str = "INSERT OR IGNORE INTO message_tags (tag_id, message_uid, message_folder) VALUES ($1, $2, $3)";
pub const Q_007: &str =
"DELETE FROM message_tags WHERE tag_id = $1 AND message_uid = $2 AND message_folder = $3";
pub const Q_008: &str = "SELECT t.id, t.name, t.color
FROM tags t
JOIN message_tags mt ON mt.tag_id = t.id
WHERE mt.message_uid = $1 AND mt.message_folder = $2
ORDER BY t.name ASC";
pub const Q_009: &str =
"SELECT m.uid, m.folder, m.message_id, m.in_reply_to, m.references_header,
m.subject, m.from_address, m.from_name, m.to_addresses, m.cc_addresses,
m.date, m.flags, m.size, m.has_attachments, m.snippet, m.reaction
FROM messages m
JOIN message_tags mt ON mt.message_uid = m.uid AND mt.message_folder = m.folder
WHERE mt.tag_id = $1
ORDER BY m.date_epoch DESC
LIMIT $2 OFFSET $3";
pub const Q_010: &str = "SELECT COUNT(*) FROM message_tags WHERE tag_id = $1";
pub const Q_011: &str = "INSERT OR IGNORE INTO folders (name) VALUES ($1)";
pub const Q_012: &str = "INSERT INTO messages (uid, folder, subject, from_address, from_name, to_addresses, date, flags, size, has_attachments, snippet)
VALUES ($1, $2, 'Test', 'test@ex.com', 'Test', '[]', '2024-01-01', '', 100, 0, '')";
pub const FORMAT_001: &str = "SELECT mt.message_uid, mt.message_folder, t.id, t.name, t.color
FROM message_tags mt
JOIN tags t ON t.id = mt.tag_id
WHERE {}
ORDER BY t.name ASC";
}
pub mod db_trusted_senders {
pub const Q_001: &str = "SELECT email FROM trusted_senders ORDER BY lower(email)";
pub const Q_002: &str = "INSERT OR IGNORE INTO trusted_senders(email) VALUES($1)";
pub const Q_003: &str = "DELETE FROM trusted_senders WHERE email = $1 ";
}
pub mod routes_contacts {
pub const Q_001: &str = "SELECT CURRENT_TIMESTAMP";
pub const Q_002: &str = "SELECT CURRENT_TIMESTAMP";
pub const Q_003: &str = "SELECT CURRENT_TIMESTAMP";
}
pub mod routes_diagnostics {
pub const Q_001: &str = "SELECT COUNT(*),
COALESCE(SUM(CASE WHEN messages_updated_at IS NULL THEN 1 ELSE 0 END), 0),
CASE WHEN MAX(updated_at) IS NULL THEN NULL ELSE replace(MAX(updated_at), ' ', 'T') || 'Z' END,
CASE WHEN MAX(messages_updated_at) IS NULL THEN NULL ELSE replace(MAX(messages_updated_at), ' ', 'T') || 'Z' END
FROM folders";
pub const Q_002: &str = "SELECT COUNT(*) FROM messages";
pub const Q_003: &str = "SELECT COUNT(*) FROM messages WHERE body_cached = 1";
}
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_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 WHERE name = $1";
}
pub mod routes_messages_mod {
pub const Q_001: &str = "SELECT flags FROM messages WHERE folder = $1 AND uid = $2";
}
pub mod routes_pgp {
pub const Q_001: &str = "UPDATE identities SET pgp_fingerprint=NULL,pgp_sign_default=0,pgp_encrypt_default=0,pgp_inline_default=0 WHERE replace(upper(COALESCE(pgp_fingerprint,'')),' ','')=$1";
}
pub mod routes_special_folders {
pub const Q_001: &str = "SELECT EXISTS(SELECT 1 FROM folders WHERE name = $1)";
}
pub mod sessions {
pub const UPSERT: &str = r#"INSERT INTO sessions (
token, account_id, browser_id, email, password_ciphertext, user_hash,
imap_host, imap_port, imap_tls, imap_tls_verify, smtp_host, smtp_port,
smtp_tls, smtp_tls_verify, timeout_secs, created_at_unix, last_accessed_unix, user_agent
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18)
ON CONFLICT(token) DO UPDATE SET
account_id=EXCLUDED.account_id, browser_id=EXCLUDED.browser_id, email=EXCLUDED.email,
password_ciphertext=EXCLUDED.password_ciphertext, user_hash=EXCLUDED.user_hash,
imap_host=EXCLUDED.imap_host, imap_port=EXCLUDED.imap_port, imap_tls=EXCLUDED.imap_tls,
imap_tls_verify=EXCLUDED.imap_tls_verify, smtp_host=EXCLUDED.smtp_host, smtp_port=EXCLUDED.smtp_port,
smtp_tls=EXCLUDED.smtp_tls, smtp_tls_verify=EXCLUDED.smtp_tls_verify, timeout_secs=EXCLUDED.timeout_secs,
last_accessed_unix=EXCLUDED.last_accessed_unix, user_agent=EXCLUDED.user_agent"#;
pub const BY_TOKEN: &str = "SELECT * FROM sessions WHERE token = $1";
pub const BY_BROWSER: &str =
"SELECT * FROM sessions WHERE browser_id = $1 ORDER BY created_at_unix";
pub const BY_USER: &str =
"SELECT * FROM sessions WHERE user_hash = $1 ORDER BY last_accessed_unix DESC";
pub const BROWSER_EXISTS: &str = "SELECT COUNT(*) FROM sessions WHERE browser_id = $1";
pub const DELETE_ACCOUNT: &str = "DELETE FROM sessions WHERE account_id = $1";
pub const DELETE_BROWSER: &str = "DELETE FROM sessions WHERE browser_id = $1";
pub const DELETE_EXPIRED: &str =
"DELETE FROM sessions WHERE last_accessed_unix + timeout_secs < $1";
}
+1
View File
@@ -0,0 +1 @@
pub mod queries;
+747
View File
@@ -0,0 +1,747 @@
pub mod auth_user_data {
pub const Q_001: &str = "SELECT value FROM user_meta WHERE key = 'schema_version'";
}
pub mod db_calendar {
pub const EVENT_SELECT_COLS: &str = "id, title, description, location, start_time, end_time, \
all_day, recurrence_rule, attendees, organizer, status, source, source_uid, \
source_message_uid, source_message_folder, meeting_url, color, reminder_minutes, \
created_at, updated_at";
pub const SETTINGS_SELECT_COLS: &str = "default_view, week_starts_on, working_hours_start, \
working_hours_end, time_format, zoom_link, teams_link, meet_link, caldav_url, \
caldav_username, caldav_enabled, updated_at";
pub const Q_001: &str = "SELECT datetime('now')";
pub const Q_002: &str = "INSERT INTO calendar_events
(id, title, description, location, start_time, end_time, all_day,
recurrence_rule, attendees, organizer, status, source, source_uid,
source_message_uid, source_message_folder, meeting_url, color,
reminder_minutes, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)";
pub const Q_003: &str = "DELETE FROM calendar_events WHERE id = ?1";
pub const Q_004: &str = "INSERT OR IGNORE INTO calendar_settings (id) VALUES (1)";
pub const Q_005: &str = "INSERT OR IGNORE INTO calendar_settings (id) VALUES (1)";
pub const Q_006: &str = "SELECT id, name, url_template, icon, is_default, created_at
FROM meeting_templates ORDER BY is_default DESC, name ASC";
pub const Q_007: &str =
"INSERT INTO meeting_templates (name, url_template, icon) VALUES (?1, ?2, ?3)";
pub const Q_008: &str = "SELECT id, name, url_template, icon, is_default, created_at
FROM meeting_templates WHERE id = ?1";
pub const Q_009: &str = "DELETE FROM meeting_templates WHERE id = ?1";
pub const FORMAT_001: &str = "SELECT {EVENT_SELECT_COLS} FROM calendar_events
WHERE end_time >= ?1 AND start_time <= ?2
ORDER BY start_time ASC";
pub const FORMAT_002: &str =
"SELECT {EVENT_SELECT_COLS} FROM calendar_events ORDER BY start_time ASC, title ASC";
pub const FORMAT_003: &str = "SELECT {EVENT_SELECT_COLS} FROM calendar_events WHERE id = ?1";
pub const FORMAT_004: &str = "UPDATE calendar_events SET {} WHERE id = ?{idx}";
pub const FORMAT_005: &str =
"SELECT {EVENT_SELECT_COLS} FROM calendar_events WHERE source_uid = ?1";
pub const FORMAT_006: &str =
"SELECT {SETTINGS_SELECT_COLS} FROM calendar_settings WHERE id = 1";
pub const FORMAT_007: &str = "UPDATE calendar_settings SET {} WHERE id = 1";
}
pub mod db_contact_groups {
pub const Q_001: &str = "INSERT INTO contact_groups (id, name) VALUES (?1, ?2)";
pub const Q_002: &str = "SELECT g.id, g.name, g.created_at, g.updated_at,
(SELECT COUNT(*) FROM contact_group_members m WHERE m.group_id = g.id) AS member_count
FROM contact_groups g
ORDER BY g.name ASC";
pub const Q_003: &str =
"UPDATE contact_groups SET name = ?1, updated_at = datetime('now') WHERE id = ?2";
pub const Q_004: &str = "DELETE FROM contact_groups WHERE id = ?1";
pub const Q_005: &str =
"INSERT OR IGNORE INTO contact_group_members (group_id, contact_id) VALUES (?1, ?2)";
pub const Q_006: &str =
"DELETE FROM contact_group_members WHERE group_id = ?1 AND contact_id = ?2";
pub const Q_007: &str = "SELECT c.id, c.email, c.name, c.company, c.notes, c.phone, c.job_title, c.address, c.website, c.birthday, c.is_favorite,
c.last_contacted, c.contact_count, c.source, c.created_at, c.updated_at, c.emails_json, c.phones_json, c.addresses_json, EXISTS(SELECT 1 FROM contact_photos p WHERE p.contact_id = c.id) AS has_photo
FROM contacts c
JOIN contact_group_members m ON m.contact_id = c.id
WHERE m.group_id = ?1
ORDER BY c.name ASC";
pub const Q_008: &str = "SELECT g.id, g.name, g.created_at, g.updated_at,
(SELECT COUNT(*) FROM contact_group_members m2 WHERE m2.group_id = g.id) AS member_count
FROM contact_groups g
JOIN contact_group_members m ON m.group_id = g.id
WHERE m.contact_id = ?1
ORDER BY g.name ASC";
}
pub mod db_contacts {
pub const CONTACT_SELECT_COLS: &str = "id, email, name, company, notes, phone, job_title, address, website, birthday, is_favorite, last_contacted, contact_count, source, created_at, updated_at, emails_json, phones_json, addresses_json, EXISTS(SELECT 1 FROM contact_photos p WHERE p.contact_id = contacts.id) AS has_photo";
pub const Q_001: &str = "INSERT INTO contacts
(id, email, name, company, notes, phone, job_title, address, website, birthday, is_favorite, last_contacted,
contact_count, source, created_at, updated_at, emails_json, phones_json, addresses_json)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19)
ON CONFLICT(id) DO UPDATE SET
email=excluded.email, name=excluded.name, company=excluded.company, notes=excluded.notes,
phone=excluded.phone, job_title=excluded.job_title, address=excluded.address, website=excluded.website, birthday=excluded.birthday,
is_favorite=excluded.is_favorite, last_contacted=excluded.last_contacted, contact_count=excluded.contact_count,
source=excluded.source, updated_at=excluded.updated_at, emails_json=excluded.emails_json,
phones_json=excluded.phones_json, addresses_json=excluded.addresses_json";
pub const Q_002: &str = "DELETE FROM contacts WHERE id = ?1";
pub const Q_003: &str = r#"
SELECT email, name
FROM known_addresses
WHERE (email LIKE ?1 ESCAPE '\' OR name LIKE ?1 ESCAPE '\')
AND email NOT IN (SELECT email FROM contacts)
AND NOT EXISTS (
SELECT 1 FROM contacts c, json_each(c.emails_json) alt
WHERE lower(alt.value) = lower(known_addresses.email)
)
ORDER BY email ASC
LIMIT ?2
"#;
pub const Q_004: &str = "INSERT INTO known_addresses (email, name) VALUES (?1, ?2)
ON CONFLICT(email) DO UPDATE SET name = excluded.name WHERE excluded.name != ''";
pub const Q_005: &str = "INSERT INTO known_addresses (email, name) VALUES (?1, ?2)
ON CONFLICT(email) DO UPDATE SET name = excluded.name WHERE excluded.name != ''";
pub const Q_006: &str = "UPDATE contacts
SET contact_count = contact_count + 1,
last_contacted = datetime('now'),
updated_at = datetime('now')
WHERE email = ?1";
pub const Q_007: &str = "INSERT OR IGNORE INTO contacts (id, email, name, source)
VALUES (?1, ?2, ?3, 'auto')";
pub const Q_008: &str = "INSERT INTO contact_photos (contact_id, content_type, data, updated_at) VALUES (?1, ?2, ?3, datetime('now')) ON CONFLICT(contact_id) DO UPDATE SET content_type=excluded.content_type, data=excluded.data, updated_at=datetime('now')";
pub const Q_009: &str = "SELECT content_type, data FROM contact_photos WHERE contact_id = ?1";
pub const Q_010: &str = "DELETE FROM contact_photos WHERE contact_id = ?1";
pub const FORMAT_001: &str = "SELECT {CONTACT_SELECT_COLS} FROM contacts WHERE id = ?1";
pub const FORMAT_002: &str = "SELECT {CONTACT_SELECT_COLS} FROM contacts \
WHERE lower(email) = lower(?1) \
OR EXISTS (SELECT 1 FROM json_each(contacts.emails_json) WHERE lower(value) = lower(?1))";
pub const FORMAT_003: &str = "SELECT {CONTACT_SELECT_COLS} FROM contacts
WHERE name LIKE ?1 ESCAPE '\\' OR email LIKE ?1 ESCAPE '\\' OR emails_json LIKE ?1 ESCAPE '\\'
ORDER BY name ASC
LIMIT ?2 OFFSET ?3";
pub const FORMAT_004: &str = "SELECT {CONTACT_SELECT_COLS} FROM contacts
ORDER BY name ASC
LIMIT ?1 OFFSET ?2";
pub const FORMAT_005: &str = "SELECT {CONTACT_SELECT_COLS} FROM contacts
WHERE name LIKE ?1 ESCAPE '\\' OR email LIKE ?1 ESCAPE '\\' OR emails_json LIKE ?1 ESCAPE '\\'
ORDER BY name ASC
LIMIT ?2";
}
pub mod db_display_preferences {
pub const Q_001: &str = "SELECT density, theme, language, compose_format, deep_index, animation_mode, preview_pane, ui_scale, color_preset, accent_color, background_color, panel_color, text_color, font_family, font_size, row_height, folder_pane_width, message_pane_width, border_radius, contrast, show_avatars, show_attachment_icons, show_flags, reduce_motion, date_format, time_format, compose_autosave_ms, default_priority, request_mdn_default, attachment_reminder, icon_size, toolbar_density, sidebar_item_height, reader_max_width, composer_width, selection_color, unread_font_weight, unread_badge_style, compact_headers, show_borders, scrollbar_density, monochrome, folder_background, message_background, reader_background, compose_font_family, reply_position, quote_style, forward_mode, spellcheck_enabled, interface_skin, preset_scale_unlocked, updated_at FROM display_preferences WHERE id = 1";
pub const Q_002: &str = "INSERT OR IGNORE INTO display_preferences (id) VALUES (1)";
pub const FORMAT_001: &str = "UPDATE display_preferences SET {} WHERE id = ?{}";
}
pub mod db_drafts {
pub const Q_001: &str = "INSERT INTO drafts (id, to_addresses, cc_addresses, bcc_addresses, subject, text_body, html_body, in_reply_to, references_header, request_mdn, priority, compose_format, pgp_mode, pgp_inline, pgp_attach_public_key, pgp_attach_recipient_keys, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, datetime('now'))
ON CONFLICT(id) DO UPDATE SET
to_addresses = excluded.to_addresses,
cc_addresses = excluded.cc_addresses,
bcc_addresses = excluded.bcc_addresses,
subject = excluded.subject,
text_body = excluded.text_body,
html_body = excluded.html_body,
in_reply_to = excluded.in_reply_to,
references_header = excluded.references_header,
request_mdn = excluded.request_mdn,
priority = excluded.priority,
compose_format = excluded.compose_format,
pgp_mode = excluded.pgp_mode,
pgp_inline = excluded.pgp_inline,
pgp_attach_public_key = excluded.pgp_attach_public_key,
pgp_attach_recipient_keys = excluded.pgp_attach_recipient_keys,
updated_at = datetime('now')";
pub const Q_002: &str = "SELECT id, to_addresses, cc_addresses, bcc_addresses, subject, text_body, html_body, in_reply_to, references_header, request_mdn, priority, compose_format, pgp_mode, pgp_inline, pgp_attach_public_key, pgp_attach_recipient_keys, remote_folder, remote_uid, remote_uid_validity, created_at, updated_at
FROM drafts WHERE id = ?1";
pub const Q_003: &str = "SELECT id, to_addresses, cc_addresses, bcc_addresses, subject, text_body, html_body, in_reply_to, references_header, request_mdn, priority, compose_format, pgp_mode, pgp_inline, pgp_attach_public_key, pgp_attach_recipient_keys, remote_folder, remote_uid, remote_uid_validity, created_at, updated_at
FROM drafts ORDER BY updated_at DESC";
pub const Q_004: &str = "UPDATE drafts SET remote_folder = ?2, remote_uid = ?3, remote_uid_validity = ?4 WHERE id = ?1";
pub const Q_005: &str = "DELETE FROM drafts WHERE id = ?1";
pub const Q_006: &str =
"INSERT INTO draft_attachments (id, draft_id, filename, content_type, size, file_path)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)";
pub const Q_007: &str =
"SELECT id, draft_id, filename, content_type, size, file_path, created_at
FROM draft_attachments WHERE draft_id = ?1 ORDER BY created_at ASC";
pub const Q_008: &str = "DELETE FROM draft_attachments WHERE id = ?1";
}
pub mod db_folder_preferences {
pub const Q_001: &str = "SELECT flagged_on_top FROM folder_preferences WHERE folder_name = ?1";
pub const Q_002: &str = "SELECT flagged_on_top FROM mail_preferences WHERE id = 1";
pub const Q_003: &str = "SELECT EXISTS(SELECT 1 FROM folders WHERE name = ?1)";
pub const Q_004: &str = "SELECT flagged_on_top FROM mail_preferences WHERE id = 1";
pub const Q_005: &str = "SELECT EXISTS(SELECT 1 FROM folders WHERE name = ?1)";
pub const Q_006: &str = "INSERT INTO folder_preferences(folder_name, flagged_on_top, updated_at) VALUES(?1, ?2, datetime('now'))\n ON CONFLICT(folder_name) DO UPDATE SET flagged_on_top = excluded.flagged_on_top, updated_at = datetime('now')";
pub const Q_007: &str = "DELETE FROM folder_preferences WHERE folder_name = ?1";
}
pub mod db_folders {
pub const Q_001: &str = "INSERT OR REPLACE 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'))";
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
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')
WHERE name = ?3";
pub const Q_007: &str =
"UPDATE folders SET uid_validity = ?1, total_count = ?2, highest_modseq = ?3
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_011: &str = "SELECT EXISTS(
SELECT 1 FROM folders
WHERE updated_at > datetime('now', ?1)
)";
pub const Q_012: &str = "SELECT EXISTS(
SELECT 1 FROM folders
WHERE name = ?1 AND messages_updated_at > datetime('now', ?2)
)";
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
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
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";
pub const Q_022: &str =
"UPDATE calendar_events SET source_message_folder = ?1 WHERE source_message_folder = ?2";
pub const Q_023: &str = "UPDATE folders SET parent = ?1 WHERE parent = ?2";
pub const Q_024: &str = "DELETE FROM folders WHERE name = ?1";
pub const Q_025: &str = "SELECT name FROM folders WHERE flags LIKE '%' || ?1 || '%' LIMIT 1";
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 FORMAT_001: &str = "DELETE FROM folders WHERE name NOT IN ({})";
}
pub mod db_header_sync {
pub const Q_001: &str = "SELECT value FROM user_meta WHERE key = 'header_sync_generation'";
pub const Q_002: &str =
"INSERT INTO user_meta (key, value) VALUES ('header_sync_generation', '1')
ON CONFLICT(key) DO UPDATE SET value = CAST(value AS INTEGER) + 1";
}
pub mod db_identities {
pub const IDENTITY_SELECT_COLS: &str = "id, display_name, email, signature_html, is_default, created_at, updated_at, reply_to, bcc, organization, signature_text, signature_new, signature_reply, signature_forward, signature_position, pgp_fingerprint, pgp_sign_default, pgp_encrypt_default, pgp_inline_default";
pub const Q_001: &str = "INSERT INTO identities (display_name,email,signature_html,is_default,reply_to,bcc,organization,signature_text,signature_new,signature_reply,signature_forward,signature_position,pgp_fingerprint,pgp_sign_default,pgp_encrypt_default,pgp_inline_default) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16)";
pub const Q_002: &str = "DELETE FROM identities WHERE id = ?1";
pub const Q_003: &str = "SELECT COUNT(*) FROM identities";
pub const Q_004: &str =
"UPDATE identities SET is_default = 0, updated_at = datetime('now') WHERE is_default = 1";
pub const FORMAT_001: &str =
"SELECT {IDENTITY_SELECT_COLS} FROM identities ORDER BY is_default DESC, email ASC";
pub const FORMAT_002: &str = "SELECT {IDENTITY_SELECT_COLS} FROM identities WHERE id = ?1";
pub const FORMAT_003: &str =
"SELECT {IDENTITY_SELECT_COLS} FROM identities WHERE is_default = 1 LIMIT 1";
pub const FORMAT_004: &str = "UPDATE identities SET {} WHERE id = ?{}";
}
pub mod db_mail_preferences {
pub const Q_001: &str = "SELECT messages_per_page, mark_read_delay_ms, remote_images, preferred_body, thread_mode, collapse_quotes, show_snippets, default_sort, flagged_on_top, sync_imap_keywords, updated_at FROM mail_preferences WHERE id = 1";
pub const Q_002: &str = "INSERT OR IGNORE INTO mail_preferences (id) VALUES (1)";
pub const Q_003: &str = "UPDATE mail_preferences SET messages_per_page = ?1, mark_read_delay_ms = ?2, remote_images = ?3, preferred_body = ?4, thread_mode = ?5, collapse_quotes = ?6, show_snippets = ?7, default_sort = ?8, flagged_on_top = ?9, sync_imap_keywords = ?10, updated_at = datetime('now') WHERE id = 1";
}
pub mod db_messages {
pub const MSG_SELECT_COLS: &str = "uid, folder, message_id, in_reply_to, references_header, subject, from_address, from_name, to_addresses, cc_addresses, date, flags, size, has_attachments, snippet, reaction";
pub const Q_001: &str = "INSERT OR REPLACE INTO messages
(uid, folder, message_id, in_reply_to, references_header,
subject, from_address, from_name, to_addresses, cc_addresses,
date, flags, size, has_attachments, snippet, date_epoch, reaction)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17)";
pub const Q_002: &str = "DELETE FROM messages WHERE folder = ?1";
pub const Q_003: &str = "SELECT COALESCE(MAX(uid), 0) FROM messages WHERE folder = ?1";
pub const Q_004: &str = "SELECT uid, folder, subject, from_address, from_name, to_addresses, \
COALESCE(NULLIF(body_text, ''), snippet, ''), date_epoch, has_attachments \
FROM messages ORDER BY folder, uid";
pub const Q_005: &str = "SELECT COUNT(*) FROM messages WHERE folder = ?1";
pub const Q_006: &str = "SELECT COUNT(*) FROM messages";
pub const Q_007: &str = "UPDATE messages SET flags = ?1 WHERE folder = ?2 AND uid = ?3";
pub const Q_008: &str = "UPDATE messages
SET body_html = ?1, body_text = ?2, body_cached = 1,
attachments_json = ?3, raw_headers = ?4, email_theme = ?5
WHERE folder = ?6 AND uid = ?7";
pub const Q_009: &str = "UPDATE messages SET email_theme = ?1 WHERE folder = ?2 AND uid = ?3";
pub const Q_010: &str =
"SELECT body_cached, body_html, body_text, attachments_json, raw_headers, email_theme
FROM messages
WHERE folder = ?1 AND uid = ?2";
pub const Q_011: &str = "DELETE FROM messages WHERE folder = ?1 AND uid = ?2";
pub const Q_012: &str = "SELECT folder, uid FROM messages
WHERE body_cached = 0
ORDER BY date_epoch DESC
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 FORMAT_001: &str = "SELECT {MSG_SELECT_COLS}
FROM messages
WHERE folder = ?1
ORDER BY date_epoch DESC
LIMIT ?2 OFFSET ?3";
pub const FORMAT_002: &str = "SELECT {MSG_SELECT_COLS}
FROM messages
WHERE folder = ?1
ORDER BY {order_by}
LIMIT ?2 OFFSET ?3";
pub const FORMAT_003: &str = "SELECT {MSG_SELECT_COLS}
FROM messages
WHERE folder = ?1 AND uid = ?2";
pub const FORMAT_004: &str = "SELECT {MSG_SELECT_COLS}
FROM messages
WHERE message_id = ?1
OR in_reply_to = ?1
OR references_header LIKE ?2 ESCAPE '\\'
ORDER BY date_epoch ASC";
pub const FORMAT_005: &str = "SELECT {MSG_SELECT_COLS}
FROM messages
{where_clause}
ORDER BY date_epoch DESC
LIMIT ?{}";
}
pub mod db_notification_preferences {
pub const Q_001: &str = "SELECT enabled, sound, folders, dnd_enabled, dnd_start, dnd_end, updated_at FROM notification_preferences WHERE id = 1";
pub const Q_002: &str = "INSERT OR IGNORE INTO notification_preferences (id) VALUES (1)";
pub const FORMAT_001: &str =
"UPDATE notification_preferences SET {set_clause} WHERE id = ?{idx}";
}
pub mod db_pgp {
pub const KEY_COLS: &str = "fingerprint,key_id,email,user_id,public_key_armor,secret_key_path,has_secret,trust,source,created_at,updated_at";
pub const Q_001: &str = "INSERT INTO pgp_keys (fingerprint,key_id,email,user_id,public_key_armor,secret_key_path,has_secret,trust,source) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9)\n ON CONFLICT(fingerprint) DO UPDATE SET key_id=excluded.key_id,email=CASE WHEN excluded.email='' THEN pgp_keys.email ELSE excluded.email END,user_id=CASE WHEN excluded.user_id='' THEN pgp_keys.user_id ELSE excluded.user_id END,public_key_armor=excluded.public_key_armor,secret_key_path=COALESCE(excluded.secret_key_path,pgp_keys.secret_key_path),has_secret=MAX(pgp_keys.has_secret,excluded.has_secret),trust=excluded.trust,source=excluded.source,updated_at=datetime('now')";
pub const Q_002: &str = "DELETE FROM pgp_keys WHERE replace(upper(fingerprint),' ','')=?1";
pub const Q_003: &str = "UPDATE pgp_keys SET trust=?2,updated_at=datetime('now') WHERE replace(upper(fingerprint),' ','')=?1";
pub const Q_004: &str =
"SELECT wkd_enabled,hkp_enabled,require_trusted_encryption FROM pgp_preferences WHERE id=1";
pub const Q_005: &str = "UPDATE pgp_preferences SET wkd_enabled=?1,hkp_enabled=?2,require_trusted_encryption=?3,updated_at=datetime('now') WHERE id=1";
pub const FORMAT_001: &str =
"SELECT {KEY_COLS} FROM pgp_keys ORDER BY has_secret DESC, lower(email), fingerprint";
pub const FORMAT_002: &str =
"SELECT {KEY_COLS} FROM pgp_keys WHERE replace(upper(fingerprint),' ','')=?1";
pub const FORMAT_003: &str = "SELECT {KEY_COLS} FROM pgp_keys WHERE lower(email)=lower(?1) ORDER BY CASE trust WHEN 'verified' THEN 0 WHEN 'trusted' THEN 1 WHEN 'unknown' THEN 2 ELSE 3 END, has_secret DESC";
pub const FORMAT_004: &str = "SELECT {KEY_COLS} FROM pgp_keys WHERE lower(email)=lower(?1) ORDER BY CASE trust WHEN 'verified' THEN 0 WHEN 'trusted' THEN 1 WHEN 'unknown' THEN 2 ELSE 3 END, has_secret DESC LIMIT 1";
pub const FORMAT_005: &str = "SELECT {KEY_COLS} FROM pgp_keys WHERE has_secret=1 AND secret_key_path IS NOT NULL ORDER BY lower(email), fingerprint";
}
pub mod db_pool {
pub const Q_001: &str = "PRAGMA user_version";
pub const Q_002: &str = "PRAGMA journal_mode=WAL;";
pub const Q_003: &str = "PRAGMA foreign_keys=ON;";
pub const Q_004: &str = "PRAGMA foreign_keys=ON;";
pub const Q_005: &str = "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name";
pub const Q_006: &str = "PRAGMA foreign_keys";
pub fn set_user_version(version: u32) -> String {
format!("PRAGMA user_version = {version};")
}
}
pub mod db_response_templates {
pub const Q_001: &str = "SELECT id,name,body,format,created_at,updated_at FROM response_templates ORDER BY name COLLATE NOCASE";
pub const Q_002: &str =
"SELECT id,name,body,format,created_at,updated_at FROM response_templates WHERE id=?1";
pub const Q_003: &str = "INSERT INTO response_templates(name,body,format) VALUES (?1,?2,?3)";
pub const Q_004: &str = "UPDATE response_templates SET name=?1,body=?2,format=?3,updated_at=datetime('now') WHERE id=?4";
pub const Q_005: &str = "DELETE FROM response_templates WHERE id=?1";
}
pub mod db_saved_searches {
pub const Q_001: &str = "SELECT id,name,query_string,created_at,updated_at FROM saved_searches ORDER BY name COLLATE NOCASE";
pub const Q_002: &str = "INSERT INTO saved_searches(name,query_string) VALUES (?1,?2)";
pub const Q_003: &str =
"UPDATE saved_searches SET name=?1,query_string=?2,updated_at=datetime('now') WHERE id=?3";
pub const Q_004: &str =
"SELECT id,name,query_string,created_at,updated_at FROM saved_searches WHERE id=?1";
pub const Q_005: &str = "DELETE FROM saved_searches WHERE id=?1";
}
pub mod db_special_folders {
pub const Q_001: &str = "SELECT folder_name FROM special_folders WHERE role = ?1";
pub const Q_002: &str = "SELECT EXISTS(SELECT 1 FROM folders WHERE name = ?1)";
pub const Q_003: &str = "INSERT INTO special_folders(role, folder_name, updated_at) VALUES(?1, ?2, datetime('now'))\n ON CONFLICT(role) DO UPDATE SET folder_name = excluded.folder_name, updated_at = datetime('now')";
pub const Q_004: &str = "DELETE FROM special_folders WHERE role = ?1";
pub const Q_005: &str = "UPDATE special_folders SET folder_name = ?1, updated_at = datetime('now') WHERE folder_name = ?2";
pub const Q_006: &str = "DELETE FROM special_folders WHERE folder_name = ?1";
}
pub mod db_tags {
pub const Q_001: &str =
"INSERT INTO tags (id, name, color, imap_keyword) VALUES (?1, ?2, ?3, ?4)";
pub const Q_002: &str =
"SELECT t.id, t.name, t.color, t.imap_keyword, t.created_at, t.updated_at,
(SELECT COUNT(*) FROM message_tags mt WHERE mt.tag_id = t.id) AS message_count
FROM tags t
ORDER BY t.name ASC";
pub const Q_003: &str =
"SELECT message_uid, message_folder FROM message_tags WHERE tag_id = ?1";
pub const Q_004: &str =
"UPDATE tags SET name = ?1, color = ?2, updated_at = datetime('now') WHERE id = ?3";
pub const Q_005: &str = "DELETE FROM tags WHERE id = ?1";
pub const Q_006: &str = "INSERT OR IGNORE INTO message_tags (tag_id, message_uid, message_folder) VALUES (?1, ?2, ?3)";
pub const Q_007: &str =
"DELETE FROM message_tags WHERE tag_id = ?1 AND message_uid = ?2 AND message_folder = ?3";
pub const Q_008: &str = "SELECT t.id, t.name, t.color
FROM tags t
JOIN message_tags mt ON mt.tag_id = t.id
WHERE mt.message_uid = ?1 AND mt.message_folder = ?2
ORDER BY t.name ASC";
pub const Q_009: &str =
"SELECT m.uid, m.folder, m.message_id, m.in_reply_to, m.references_header,
m.subject, m.from_address, m.from_name, m.to_addresses, m.cc_addresses,
m.date, m.flags, m.size, m.has_attachments, m.snippet, m.reaction
FROM messages m
JOIN message_tags mt ON mt.message_uid = m.uid AND mt.message_folder = m.folder
WHERE mt.tag_id = ?1
ORDER BY m.date_epoch DESC
LIMIT ?2 OFFSET ?3";
pub const Q_010: &str = "SELECT COUNT(*) FROM message_tags WHERE tag_id = ?1";
pub const Q_011: &str = "INSERT OR IGNORE INTO folders (name) VALUES (?1)";
pub const Q_012: &str = "INSERT INTO messages (uid, folder, subject, from_address, from_name, to_addresses, date, flags, size, has_attachments, snippet)
VALUES (?1, ?2, 'Test', 'test@ex.com', 'Test', '[]', '2024-01-01', '', 100, 0, '')";
pub const FORMAT_001: &str = "SELECT mt.message_uid, mt.message_folder, t.id, t.name, t.color
FROM message_tags mt
JOIN tags t ON t.id = mt.tag_id
WHERE {}
ORDER BY t.name ASC";
}
pub mod db_trusted_senders {
pub const Q_001: &str = "SELECT email FROM trusted_senders ORDER BY lower(email)";
pub const Q_002: &str = "INSERT OR IGNORE INTO trusted_senders(email) VALUES(?1)";
pub const Q_003: &str = "DELETE FROM trusted_senders WHERE email = ?1 COLLATE NOCASE";
}
pub mod routes_contacts {
pub const Q_001: &str = "SELECT datetime('now')";
pub const Q_002: &str = "SELECT datetime('now')";
pub const Q_003: &str = "SELECT datetime('now')";
}
pub mod routes_diagnostics {
pub const Q_001: &str = "SELECT COUNT(*),
COALESCE(SUM(CASE WHEN messages_updated_at IS NULL THEN 1 ELSE 0 END), 0),
CASE WHEN MAX(updated_at) IS NULL THEN NULL ELSE replace(MAX(updated_at), ' ', 'T') || 'Z' END,
CASE WHEN MAX(messages_updated_at) IS NULL THEN NULL ELSE replace(MAX(messages_updated_at), ' ', 'T') || 'Z' END
FROM folders";
pub const Q_002: &str = "SELECT COUNT(*) FROM messages";
pub const Q_003: &str = "SELECT COUNT(*) FROM messages WHERE body_cached = 1";
}
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_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 mod routes_messages_mod {
pub const Q_001: &str = "SELECT flags FROM messages WHERE folder = ?1 AND uid = ?2";
}
pub mod routes_pgp {
pub const Q_001: &str = "UPDATE identities SET pgp_fingerprint=NULL,pgp_sign_default=0,pgp_encrypt_default=0,pgp_inline_default=0 WHERE replace(upper(COALESCE(pgp_fingerprint,'')),' ','')=?1";
}
pub mod routes_special_folders {
pub const Q_001: &str = "SELECT EXISTS(SELECT 1 FROM folders WHERE name = ?1)";
}
#[macro_export]
macro_rules! rustbox_query_db_calendar_001 {
() => { format!("SELECT {EVENT_SELECT_COLS} FROM calendar_events
WHERE end_time >= ?1 AND start_time <= ?2
ORDER BY start_time ASC") };
($($args:tt)+) => { format!("SELECT {EVENT_SELECT_COLS} FROM calendar_events
WHERE end_time >= ?1 AND start_time <= ?2
ORDER BY start_time ASC", $($args)+) };
}
#[macro_export]
macro_rules! rustbox_query_db_calendar_002 {
() => { format!("SELECT {EVENT_SELECT_COLS} FROM calendar_events ORDER BY start_time ASC, title ASC") };
($($args:tt)+) => { format!("SELECT {EVENT_SELECT_COLS} FROM calendar_events ORDER BY start_time ASC, title ASC", $($args)+) };
}
#[macro_export]
macro_rules! rustbox_query_db_calendar_003 {
() => { format!("SELECT {EVENT_SELECT_COLS} FROM calendar_events WHERE id = ?1") };
($($args:tt)+) => { format!("SELECT {EVENT_SELECT_COLS} FROM calendar_events WHERE id = ?1", $($args)+) };
}
#[macro_export]
macro_rules! rustbox_query_db_calendar_004 {
($set_clause:expr, $id_param_idx:expr) => {
format!(
"UPDATE calendar_events SET {} WHERE id = ?{}",
$set_clause, $id_param_idx
)
};
}
#[macro_export]
macro_rules! rustbox_query_db_calendar_005 {
() => { format!("SELECT {EVENT_SELECT_COLS} FROM calendar_events WHERE source_uid = ?1") };
($($args:tt)+) => { format!("SELECT {EVENT_SELECT_COLS} FROM calendar_events WHERE source_uid = ?1", $($args)+) };
}
#[macro_export]
macro_rules! rustbox_query_db_calendar_006 {
() => { format!("SELECT {SETTINGS_SELECT_COLS} FROM calendar_settings WHERE id = 1") };
($($args:tt)+) => { format!("SELECT {SETTINGS_SELECT_COLS} FROM calendar_settings WHERE id = 1", $($args)+) };
}
#[macro_export]
macro_rules! rustbox_query_db_calendar_007 {
() => { format!("UPDATE calendar_settings SET {} WHERE id = 1") };
($($args:tt)+) => { format!("UPDATE calendar_settings SET {} WHERE id = 1", $($args)+) };
}
#[macro_export]
macro_rules! rustbox_query_db_contacts_001 {
() => { format!("SELECT {CONTACT_SELECT_COLS} FROM contacts WHERE id = ?1") };
($($args:tt)+) => { format!("SELECT {CONTACT_SELECT_COLS} FROM contacts WHERE id = ?1", $($args)+) };
}
#[macro_export]
macro_rules! rustbox_query_db_contacts_002 {
() => { format!("SELECT {CONTACT_SELECT_COLS} FROM contacts \
WHERE lower(email) = lower(?1) \
OR EXISTS (SELECT 1 FROM json_each(contacts.emails_json) WHERE lower(value) = lower(?1))") };
($($args:tt)+) => { format!("SELECT {CONTACT_SELECT_COLS} FROM contacts \
WHERE lower(email) = lower(?1) \
OR EXISTS (SELECT 1 FROM json_each(contacts.emails_json) WHERE lower(value) = lower(?1))", $($args)+) };
}
#[macro_export]
macro_rules! rustbox_query_db_contacts_003 {
() => { format!("SELECT {CONTACT_SELECT_COLS} FROM contacts
WHERE name LIKE ?1 ESCAPE '\\' OR email LIKE ?1 ESCAPE '\\' OR emails_json LIKE ?1 ESCAPE '\\'
ORDER BY name ASC
LIMIT ?2 OFFSET ?3") };
($($args:tt)+) => { format!("SELECT {CONTACT_SELECT_COLS} FROM contacts
WHERE name LIKE ?1 ESCAPE '\\' OR email LIKE ?1 ESCAPE '\\' OR emails_json LIKE ?1 ESCAPE '\\'
ORDER BY name ASC
LIMIT ?2 OFFSET ?3", $($args)+) };
}
#[macro_export]
macro_rules! rustbox_query_db_contacts_004 {
() => { format!("SELECT {CONTACT_SELECT_COLS} FROM contacts
ORDER BY name ASC
LIMIT ?1 OFFSET ?2") };
($($args:tt)+) => { format!("SELECT {CONTACT_SELECT_COLS} FROM contacts
ORDER BY name ASC
LIMIT ?1 OFFSET ?2", $($args)+) };
}
#[macro_export]
macro_rules! rustbox_query_db_contacts_005 {
() => { format!("SELECT {CONTACT_SELECT_COLS} FROM contacts
WHERE name LIKE ?1 ESCAPE '\\' OR email LIKE ?1 ESCAPE '\\' OR emails_json LIKE ?1 ESCAPE '\\'
ORDER BY name ASC
LIMIT ?2") };
($($args:tt)+) => { format!("SELECT {CONTACT_SELECT_COLS} FROM contacts
WHERE name LIKE ?1 ESCAPE '\\' OR email LIKE ?1 ESCAPE '\\' OR emails_json LIKE ?1 ESCAPE '\\'
ORDER BY name ASC
LIMIT ?2", $($args)+) };
}
#[macro_export]
macro_rules! rustbox_query_db_display_preferences_001 {
() => { format!("UPDATE display_preferences SET {} WHERE id = ?{}") };
($($args:tt)+) => { format!("UPDATE display_preferences SET {} WHERE id = ?{}", $($args)+) };
}
#[macro_export]
macro_rules! rustbox_query_db_folders_001 {
() => { format!("DELETE FROM folders WHERE name NOT IN ({})") };
($($args:tt)+) => { format!("DELETE FROM folders WHERE name NOT IN ({})", $($args)+) };
}
#[macro_export]
macro_rules! rustbox_query_db_identities_001 {
() => { format!("SELECT {IDENTITY_SELECT_COLS} FROM identities ORDER BY is_default DESC, email ASC") };
($($args:tt)+) => { format!("SELECT {IDENTITY_SELECT_COLS} FROM identities ORDER BY is_default DESC, email ASC", $($args)+) };
}
#[macro_export]
macro_rules! rustbox_query_db_identities_002 {
() => { format!("SELECT {IDENTITY_SELECT_COLS} FROM identities WHERE id = ?1") };
($($args:tt)+) => { format!("SELECT {IDENTITY_SELECT_COLS} FROM identities WHERE id = ?1", $($args)+) };
}
#[macro_export]
macro_rules! rustbox_query_db_identities_003 {
() => { format!("SELECT {IDENTITY_SELECT_COLS} FROM identities WHERE is_default = 1 LIMIT 1") };
($($args:tt)+) => { format!("SELECT {IDENTITY_SELECT_COLS} FROM identities WHERE is_default = 1 LIMIT 1", $($args)+) };
}
#[macro_export]
macro_rules! rustbox_query_db_identities_004 {
() => { format!("UPDATE identities SET {} WHERE id = ?{}") };
($($args:tt)+) => { format!("UPDATE identities SET {} WHERE id = ?{}", $($args)+) };
}
#[macro_export]
macro_rules! rustbox_query_db_messages_001 {
() => { format!("SELECT {MSG_SELECT_COLS}
FROM messages
WHERE folder = ?1
ORDER BY date_epoch DESC
LIMIT ?2 OFFSET ?3") };
($($args:tt)+) => { format!("SELECT {MSG_SELECT_COLS}
FROM messages
WHERE folder = ?1
ORDER BY date_epoch DESC
LIMIT ?2 OFFSET ?3", $($args)+) };
}
#[macro_export]
macro_rules! rustbox_query_db_messages_002 {
($order_by:expr) => {
format!(
"SELECT {MSG_SELECT_COLS}
FROM messages
WHERE folder = ?1
ORDER BY {}
LIMIT ?2 OFFSET ?3",
$order_by
)
};
}
#[macro_export]
macro_rules! rustbox_query_db_messages_003 {
() => { format!("SELECT {MSG_SELECT_COLS}
FROM messages
WHERE folder = ?1 AND uid = ?2") };
($($args:tt)+) => { format!("SELECT {MSG_SELECT_COLS}
FROM messages
WHERE folder = ?1 AND uid = ?2", $($args)+) };
}
#[macro_export]
macro_rules! rustbox_query_db_messages_004 {
() => { format!("SELECT {MSG_SELECT_COLS}
FROM messages
WHERE message_id = ?1
OR in_reply_to = ?1
OR references_header LIKE ?2 ESCAPE '\\'
ORDER BY date_epoch ASC") };
($($args:tt)+) => { format!("SELECT {MSG_SELECT_COLS}
FROM messages
WHERE message_id = ?1
OR in_reply_to = ?1
OR references_header LIKE ?2 ESCAPE '\\'
ORDER BY date_epoch ASC", $($args)+) };
}
#[macro_export]
macro_rules! rustbox_query_db_messages_005 {
($where_clause:expr, $limit_param_idx:expr) => {
format!(
"SELECT {MSG_SELECT_COLS}
FROM messages
{}
ORDER BY date_epoch DESC
LIMIT ?{}",
$where_clause, $limit_param_idx
)
};
}
#[macro_export]
macro_rules! rustbox_query_db_notification_preferences_001 {
($set_clause:expr, $id_param_idx:expr) => {
format!(
"UPDATE notification_preferences SET {} WHERE id = ?{}",
$set_clause, $id_param_idx
)
};
}
#[macro_export]
macro_rules! rustbox_query_db_pgp_001 {
() => { format!("SELECT {KEY_COLS} FROM pgp_keys ORDER BY has_secret DESC, lower(email), fingerprint") };
($($args:tt)+) => { format!("SELECT {KEY_COLS} FROM pgp_keys ORDER BY has_secret DESC, lower(email), fingerprint", $($args)+) };
}
#[macro_export]
macro_rules! rustbox_query_db_pgp_002 {
() => { format!("SELECT {KEY_COLS} FROM pgp_keys WHERE replace(upper(fingerprint),' ','')=?1") };
($($args:tt)+) => { format!("SELECT {KEY_COLS} FROM pgp_keys WHERE replace(upper(fingerprint),' ','')=?1", $($args)+) };
}
#[macro_export]
macro_rules! rustbox_query_db_pgp_003 {
() => { format!("SELECT {KEY_COLS} FROM pgp_keys WHERE lower(email)=lower(?1) ORDER BY CASE trust WHEN 'verified' THEN 0 WHEN 'trusted' THEN 1 WHEN 'unknown' THEN 2 ELSE 3 END, has_secret DESC") };
($($args:tt)+) => { format!("SELECT {KEY_COLS} FROM pgp_keys WHERE lower(email)=lower(?1) ORDER BY CASE trust WHEN 'verified' THEN 0 WHEN 'trusted' THEN 1 WHEN 'unknown' THEN 2 ELSE 3 END, has_secret DESC", $($args)+) };
}
#[macro_export]
macro_rules! rustbox_query_db_pgp_004 {
() => { format!("SELECT {KEY_COLS} FROM pgp_keys WHERE lower(email)=lower(?1) ORDER BY CASE trust WHEN 'verified' THEN 0 WHEN 'trusted' THEN 1 WHEN 'unknown' THEN 2 ELSE 3 END, has_secret DESC LIMIT 1") };
($($args:tt)+) => { format!("SELECT {KEY_COLS} FROM pgp_keys WHERE lower(email)=lower(?1) ORDER BY CASE trust WHEN 'verified' THEN 0 WHEN 'trusted' THEN 1 WHEN 'unknown' THEN 2 ELSE 3 END, has_secret DESC LIMIT 1", $($args)+) };
}
#[macro_export]
macro_rules! rustbox_query_db_pgp_005 {
() => { format!("SELECT {KEY_COLS} FROM pgp_keys WHERE has_secret=1 AND secret_key_path IS NOT NULL ORDER BY lower(email), fingerprint") };
($($args:tt)+) => { format!("SELECT {KEY_COLS} FROM pgp_keys WHERE has_secret=1 AND secret_key_path IS NOT NULL ORDER BY lower(email), fingerprint", $($args)+) };
}
#[macro_export]
macro_rules! rustbox_query_db_tags_001 {
() => { format!("SELECT mt.message_uid, mt.message_folder, t.id, t.name, t.color
FROM message_tags mt
JOIN tags t ON t.id = mt.tag_id
WHERE {}
ORDER BY t.name ASC") };
($($args:tt)+) => { format!("SELECT mt.message_uid, mt.message_folder, t.id, t.name, t.color
FROM message_tags mt
JOIN tags t ON t.id = mt.tag_id
WHERE {}
ORDER BY t.name ASC", $($args)+) };
}
pub mod sessions {
pub const UPSERT: &str = r#"INSERT INTO sessions (
token, account_id, browser_id, email, password_ciphertext, user_hash,
imap_host, imap_port, imap_tls, imap_tls_verify, smtp_host, smtp_port,
smtp_tls, smtp_tls_verify, timeout_secs, created_at_unix, last_accessed_unix, user_agent
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(token) DO UPDATE SET
account_id=excluded.account_id, browser_id=excluded.browser_id, email=excluded.email,
password_ciphertext=excluded.password_ciphertext, user_hash=excluded.user_hash,
imap_host=excluded.imap_host, imap_port=excluded.imap_port, imap_tls=excluded.imap_tls,
imap_tls_verify=excluded.imap_tls_verify, smtp_host=excluded.smtp_host, smtp_port=excluded.smtp_port,
smtp_tls=excluded.smtp_tls, smtp_tls_verify=excluded.smtp_tls_verify, timeout_secs=excluded.timeout_secs,
last_accessed_unix=excluded.last_accessed_unix, user_agent=excluded.user_agent"#;
pub const BY_TOKEN: &str = "SELECT * FROM sessions WHERE token = ?";
pub const BY_BROWSER: &str =
"SELECT * FROM sessions WHERE browser_id = ? ORDER BY created_at_unix";
pub const BY_USER: &str =
"SELECT * FROM sessions WHERE user_hash = ? ORDER BY last_accessed_unix DESC";
pub const BROWSER_EXISTS: &str = "SELECT COUNT(*) FROM sessions WHERE browser_id = ?";
pub const DELETE_ACCOUNT: &str = "DELETE FROM sessions WHERE account_id = ?";
pub const DELETE_BROWSER: &str = "DELETE FROM sessions WHERE browser_id = ?";
pub const DELETE_EXPIRED: &str =
"DELETE FROM sessions WHERE last_accessed_unix + timeout_secs < ?";
}
+4 -4
View File
@@ -58,10 +58,10 @@ impl EventBus {
pub async fn cleanup(&self, user_hash: &str) {
let mut channels = self.channels.write().await;
if let Some(tx) = channels.get(user_hash) {
if tx.receiver_count() == 0 {
channels.remove(user_hash);
}
if let Some(tx) = channels.get(user_hash)
&& tx.receiver_count() == 0
{
channels.remove(user_hash);
}
}
}
+8 -9
View File
@@ -368,12 +368,11 @@ async fn fetch_new_after_idle(
sorted.sort();
let flags_csv = sorted.join(",");
if db::messages::update_message_flags(&conn, folder, *uid, &flags_csv).is_ok() {
if sync_keywords {
if let Err(error) =
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");
}
{
tracing::debug!(uid = uid, error = %error, "IDLE keyword import failed");
}
flags_updated += 1;
}
@@ -778,10 +777,10 @@ async fn fetch_and_store_headers(
) {
tracing::warn!(uid = uid, error = %e, "IDLE fetch: failed to populate known addresses");
}
if sync_keywords {
if let Err(error) = db::tags::import_imap_keywords(&conn, uid, folder, &flags) {
tracing::debug!(uid = uid, error = %error, "IDLE keyword import failed");
}
if sync_keywords
&& let Err(error) = db::tags::import_imap_keywords(&conn, uid, folder, &flags)
{
tracing::debug!(uid = uid, error = %error, "IDLE keyword import failed");
}
new_count += 1;
}
+1 -1
View File
@@ -59,7 +59,7 @@ pub async fn ws_handler(
.into_response();
};
let accounts = store.get_browser_accounts(&browser_id);
let accounts = store.get_browser_accounts_shared(&browser_id).await;
if accounts.is_empty() {
return (axum::http::StatusCode::UNAUTHORIZED, "No active accounts").into_response();
+38 -28
View File
@@ -7,7 +7,7 @@ use axum::{Extension, Json};
use serde::{Deserialize, Serialize};
use crate::auth::imap_auth::{self, AuthResult};
use crate::auth::session::{SessionState, SessionStore};
use crate::auth::session::{NewAccountSession, SessionState, SessionStore};
use crate::auth::user_data;
use crate::config::AppConfig;
use crate::db;
@@ -208,7 +208,7 @@ pub async fn login(
.or_else(|| extract_browser_id(&headers));
if let Some(ref browser_id) = provided_browser_id {
let existing_accounts = store.get_browser_accounts(browser_id);
let existing_accounts = store.get_browser_accounts_shared(browser_id).await;
let duplicate = existing_accounts
.iter()
.find(|a| a.email == body.email && a.imap_host == imap_host);
@@ -308,26 +308,30 @@ pub async fn login(
} else {
None
};
let (token, account_id) = store.add_account_to_browser_with_timeout(
&browser_id,
body.email.clone(),
body.password,
user_hash,
imap_host.clone(),
imap_port,
imap_tls,
smtp_host.clone(),
smtp_port,
smtp_tls,
timeout_override,
);
let (token, account_id) = store
.add_account_to_browser_shared(
&browser_id,
NewAccountSession {
email: body.email.clone(),
password: body.password,
user_hash,
imap_host: imap_host.clone(),
imap_port,
imap_tls,
smtp_host: smtp_host.clone(),
smtp_port,
smtp_tls,
timeout_override,
},
)
.await;
let user_agent = headers
.get(axum::http::header::USER_AGENT)
.and_then(|value| value.to_str().ok())
.unwrap_or("")
.to_string();
store.set_user_agent(&account_id, user_agent);
store.set_user_agent_shared(&account_id, user_agent).await;
let max_age = session_hours * 3600;
let secure = config.environment != "development";
@@ -420,7 +424,7 @@ pub async fn list_accounts(
.into_response();
};
let browser_exists = store.browser_exists(&browser_id);
let browser_exists = store.browser_exists_shared(&browser_id).await;
if !browser_exists {
tracing::debug!(browser_id = %browser_id, "list_accounts: browser_id not found on server");
let clear_browser = clearing_browser_cookie(secure, same_site, cookie_path(&config));
@@ -439,7 +443,7 @@ pub async fn list_accounts(
let session_map: std::collections::HashMap<String, String> =
session_cookies.into_iter().collect();
let browser_accounts = store.get_browser_accounts(&browser_id);
let browser_accounts = store.get_browser_accounts_shared(&browser_id).await;
let browser_account_ids: Vec<String> = browser_accounts
.iter()
.map(|s| s.account_id.clone())
@@ -450,13 +454,13 @@ pub async fn list_accounts(
for session in &browser_accounts {
if let Some(token) = session_map.get(&session.account_id) {
if let Some(valid_session) = store.get(token) {
if let Some(valid_session) = store.get_shared(token).await {
valid_accounts.push(serde_json::json!({
"id": valid_session.account_id,
"email": valid_session.email
}));
} else {
store.remove_account(&session.account_id);
store.remove_account_shared(&session.account_id).await;
clear_cookies.push(clearing_account_cookie(
&session.account_id,
secure,
@@ -465,7 +469,7 @@ pub async fn list_accounts(
));
}
} else {
store.remove_account(&session.account_id);
store.remove_account_shared(&session.account_id).await;
}
}
@@ -554,7 +558,7 @@ pub async fn remove_account(
.into_response();
};
let browser_accounts = store.get_browser_accounts(&browser_id);
let browser_accounts = store.get_browser_accounts_shared(&browser_id).await;
let account_belongs_to_browser = browser_accounts.iter().any(|s| s.account_id == account_id);
if !account_belongs_to_browser {
@@ -573,7 +577,7 @@ pub async fn remove_account(
.into_response();
}
store.remove_account(&account_id);
store.remove_account_shared(&account_id).await;
let secure = config.environment != "development";
let same_site = same_site_policy(&config);
@@ -651,7 +655,8 @@ pub async fn list_active_sessions(
Extension(session): Extension<SessionState>,
) -> Response {
let sessions: Vec<ActiveSessionResponse> = store
.get_user_sessions(&session.user_hash)
.get_user_sessions_shared(&session.user_hash)
.await
.into_iter()
.map(|item| {
let timeout_secs = item
@@ -694,7 +699,10 @@ pub async fn revoke_active_session(
.into_response();
}
if store.remove_user_session(&session.user_hash, &account_id) {
if store
.remove_user_session_shared(&session.user_hash, &account_id)
.await
{
Json(serde_json::json!({ "status": "revoked" })).into_response()
} else {
(
@@ -715,7 +723,9 @@ pub async fn revoke_other_sessions(
Extension(store): Extension<Arc<SessionStore>>,
Extension(session): Extension<SessionState>,
) -> Response {
let revoked = store.remove_other_user_sessions(&session.user_hash, &session.account_id);
let revoked = store
.remove_other_user_sessions_shared(&session.user_hash, &session.account_id)
.await;
Json(serde_json::json!({ "status": "revoked", "count": revoked })).into_response()
}
@@ -732,7 +742,7 @@ pub async fn logout(
let same_site = same_site_policy(&config);
if let Some(ref browser_id) = browser_id {
let accounts = store.get_browser_accounts(browser_id);
let accounts = store.get_browser_accounts_shared(browser_id).await;
for account in accounts {
cookies_to_clear.push(clearing_account_cookie(
&account.account_id,
@@ -741,7 +751,7 @@ pub async fn logout(
cookie_path(&config),
));
}
store.remove_browser(browser_id);
store.remove_browser_shared(browser_id).await;
}
cookies_to_clear.push(clearing_browser_cookie(
+1 -1
View File
@@ -174,7 +174,7 @@ pub async fn export_ics(
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
let mut body = String::from(
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//RustBox//Calendar 0.0.6//EN\r\nCALSCALE:GREGORIAN\r\n",
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//RustBox//Calendar 0.1.0//EN\r\nCALSCALE:GREGORIAN\r\n",
);
for event in &events {
append_ics_event(&mut body, event);
+22 -10
View File
@@ -184,7 +184,11 @@ pub async fn create_contact_handler(
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
let now: String = conn
.query_row("SELECT datetime('now')", [], |row| row.get(0))
.query_row(
crate::queries::sqlite::queries::routes_contacts::Q_001,
[],
|row| row.get(0),
)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
let id = body.id.unwrap_or_else(|| Uuid::new_v4().to_string());
let email = body.email.trim().to_string();
@@ -357,7 +361,11 @@ pub async fn merge_contacts_handler(
target.last_contacted = source.last_contacted.clone();
}
target.updated_at = tx
.query_row("SELECT datetime('now')", [], |row| row.get(0))
.query_row(
crate::queries::sqlite::queries::routes_contacts::Q_002,
[],
|row| row.get(0),
)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
for group in db::contact_groups::list_contact_groups(&tx, &source.id)
@@ -367,14 +375,14 @@ pub async fn merge_contacts_handler(
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
}
if !target.has_photo && source.has_photo {
if let Some((content_type, data)) = db::contacts::get_contact_photo(&tx, &source.id)
if !target.has_photo
&& source.has_photo
&& let Some((content_type, data)) = db::contacts::get_contact_photo(&tx, &source.id)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?
{
db::contacts::set_contact_photo(&tx, &target.id, &content_type, &data)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
target.has_photo = true;
}
{
db::contacts::set_contact_photo(&tx, &target.id, &content_type, &data)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
target.has_photo = true;
}
db::contacts::upsert_contact(&tx, &target)
@@ -958,7 +966,11 @@ pub async fn import_contacts_handler(
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
let now: String = conn
.query_row("SELECT datetime('now')", [], |row| row.get(0))
.query_row(
crate::queries::sqlite::queries::routes_contacts::Q_003,
[],
|row| row.get(0),
)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
let parsed = parse_vcards(&vcf_content);
+13 -9
View File
@@ -215,21 +215,25 @@ pub async fn sync_diagnostics(
let conn = db::pool::open_user_db(&data_dir, &user_hash)
.map_err(|e| format!("Database error: {e}"))?;
let (cached_folders, unsynced_folders, latest_folder_list_sync, latest_message_sync):
(i64, i64, Option<String>, Option<String>) = conn
let (cached_folders, unsynced_folders, latest_folder_list_sync, latest_message_sync): (
i64,
i64,
Option<String>,
Option<String>,
) = conn
.query_row(
"SELECT COUNT(*),
COALESCE(SUM(CASE WHEN messages_updated_at IS NULL THEN 1 ELSE 0 END), 0),
CASE WHEN MAX(updated_at) IS NULL THEN NULL ELSE replace(MAX(updated_at), ' ', 'T') || 'Z' END,
CASE WHEN MAX(messages_updated_at) IS NULL THEN NULL ELSE replace(MAX(messages_updated_at), ' ', 'T') || 'Z' END
FROM folders",
crate::queries::sqlite::queries::routes_diagnostics::Q_001,
[],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
)
.map_err(|e| format!("Failed to read folder sync metadata: {e}"))?;
let cached_messages: i64 = conn
.query_row("SELECT COUNT(*) FROM messages", [], |row| row.get(0))
.query_row(
crate::queries::sqlite::queries::routes_diagnostics::Q_002,
[],
|row| row.get(0),
)
.map_err(|e| format!("Failed to count cached messages: {e}"))?;
Ok(SyncDiagnosticsResponse {
@@ -270,7 +274,7 @@ pub async fn search_diagnostics(
.count();
let cached_bodies: usize = conn
.query_row(
"SELECT COUNT(*) FROM messages WHERE body_cached = 1",
crate::queries::sqlite::queries::routes_diagnostics::Q_003,
[],
|row| row.get(0),
)
+1 -1
View File
@@ -353,7 +353,7 @@ fn build_draft_rfc822(
.parse()
.map_err(|e: lettre::address::AddressError| e.to_string())?;
let draft_id = req.id.replace('\r', "").replace('\n', "");
let draft_id = req.id.replace(['\r', '\n'], "");
let mut builder = lettre::Message::builder()
.from(from_mailbox)
.subject(&req.subject)
+4 -4
View File
@@ -199,12 +199,12 @@ pub async fn mark_all_read(
let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
conn.execute(
"UPDATE messages SET flags = CASE WHEN flags = '' THEN ?2 ELSE flags || ',' || ?2 END WHERE folder = ?1 AND instr(flags, ?2) = 0",
crate::queries::sqlite::queries::routes_folder_mgmt::Q_001,
rusqlite::params![&name, "\\Seen"],
)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
conn.execute(
"UPDATE folders SET unread_count = 0 WHERE name = ?1",
crate::queries::sqlite::queries::routes_folder_mgmt::Q_002,
[&name],
)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
@@ -229,7 +229,7 @@ pub async fn empty_folder(
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
let uids: Vec<u32> = {
let mut statement = conn
.prepare("SELECT uid FROM messages WHERE folder = ?1")
.prepare(crate::queries::sqlite::queries::routes_folder_mgmt::Q_003)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
let rows = statement
.query_map([&name], |row| row.get(0))
@@ -239,7 +239,7 @@ pub async fn empty_folder(
db::messages::delete_folder_messages(&conn, &name)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
conn.execute(
"UPDATE folders SET total_count = 0, unread_count = 0, messages_updated_at = datetime('now') WHERE name = ?1",
crate::queries::sqlite::queries::routes_folder_mgmt::Q_004,
[&name],
)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
+3 -3
View File
@@ -853,7 +853,7 @@ pub async fn update_flags(
let current_flags_csv: String = conn
.query_row(
"SELECT flags FROM messages WHERE folder = ?1 AND uid = ?2",
crate::queries::sqlite::queries::routes_messages_mod::Q_001,
rusqlite::params![&folder, uid],
|row| row.get(0),
)
@@ -1196,7 +1196,7 @@ pub async fn download_eml_source(
.await
.map_err(|e| AppError::ServiceUnavailable(format!("IMAP error: {e}")))?;
let filename = format!("message-{uid}.eml");
Ok(Response::builder()
Response::builder()
.header("content-type", "message/rfc822")
.header(
"content-disposition",
@@ -1204,7 +1204,7 @@ pub async fn download_eml_source(
)
.header("cache-control", "private, no-store")
.body(axum::body::Body::from(raw))
.map_err(|e| AppError::InternalError(format!("Failed to build response: {e}")))?)
.map_err(|e| AppError::InternalError(format!("Failed to build response: {e}")))
}
#[cfg(test)]
+2 -4
View File
@@ -472,7 +472,7 @@ pub fn create_router(
}
};
let router = router
router
.layer(Extension(idle_manager))
.layer(Extension(event_bus))
.layer(Extension(smtp_client))
@@ -480,9 +480,7 @@ pub fn create_router(
.layer(Extension(imap_client))
.layer(Extension(store))
.layer(Extension(config.clone()))
.layer(TraceLayer::new_for_http());
router
.layer(TraceLayer::new_for_http())
}
#[cfg(test)]
+18 -13
View File
@@ -205,10 +205,10 @@ pub async fn process_message(
let mut result = tokio::task::spawn_blocking(move || {
let mut secrets = Vec::new();
for key in secret_rows {
if let Some(path) = key.secret_key_path {
if let Ok(armor) = crate::pgp::read_secret_key(&path) {
secrets.push(armor);
}
if let Some(path) = key.secret_key_path
&& let Ok(armor) = crate::pgp::read_secret_key(&path)
{
secrets.push(armor);
}
}
crate::pgp::process_message(&raw, &secrets, &passphrase, &public_keys)
@@ -478,11 +478,17 @@ pub async fn delete_key(
};
let delete_fp = normalized.clone();
database.run(move |conn| {
conn.execute("UPDATE identities SET pgp_fingerprint=NULL,pgp_sign_default=0,pgp_encrypt_default=0,pgp_inline_default=0 WHERE replace(upper(COALESCE(pgp_fingerprint,'')),' ','')=?1", rusqlite::params![delete_fp])
database
.run(move |conn| {
conn.execute(
crate::queries::sqlite::queries::routes_pgp::Q_001,
rusqlite::params![delete_fp],
)
.map_err(|e| format!("Failed to detach OpenPGP key from identities: {e}"))?;
db::pgp::delete_key(conn, &normalized)
}).await.map_err(AppError::InternalError)?;
db::pgp::delete_key(conn, &normalized)
})
.await
.map_err(AppError::InternalError)?;
if let Some(path) = key.secret_key_path {
tokio::task::spawn_blocking(move || crate::pgp::delete_secret_key(&path))
@@ -837,11 +843,10 @@ async fn store_material_raw(
.run(move |conn| db::pgp::upsert_key(conn, &stored, path.as_deref()))
.await
{
if let Some((path, existed)) = secret_store {
if !existed {
let _ =
tokio::task::spawn_blocking(move || crate::pgp::delete_secret_key(&path)).await;
}
if let Some((path, existed)) = secret_store
&& !existed
{
let _ = tokio::task::spawn_blocking(move || crate::pgp::delete_secret_key(&path)).await;
}
return Err(AppError::InternalError(error));
}
+13 -11
View File
@@ -1,18 +1,13 @@
use std::sync::Arc;
use axum::{
Extension, Json,
extract::Path,
response::{IntoResponse, Response},
Extension, Json,
};
use serde::Deserialize;
use crate::{
auth::session::SessionState,
config::AppConfig,
db,
error::AppError,
};
use crate::{auth::session::SessionState, config::AppConfig, db, error::AppError};
#[derive(Debug, Deserialize)]
pub struct ResponseTemplateRequest {
@@ -23,7 +18,9 @@ pub struct ResponseTemplateRequest {
format: String,
}
fn default_template_format() -> String { "text".to_string() }
fn default_template_format() -> String {
"text".to_string()
}
pub async fn list(
Extension(session): Extension<SessionState>,
@@ -56,9 +53,12 @@ pub async fn update(
let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash)
.map_err(|e| AppError::InternalError(format!("Failed to open database: {e}")))?;
match db::response_templates::update(&conn, id, &data.name, &data.body, &data.format)
.map_err(AppError::BadRequest)? {
.map_err(AppError::BadRequest)?
{
Some(template) => Ok(Json(template).into_response()),
None => Err(AppError::NotFound("Response template not found".to_string())),
None => Err(AppError::NotFound(
"Response template not found".to_string(),
)),
}
}
@@ -72,6 +72,8 @@ pub async fn delete(
if db::response_templates::delete(&conn, id).map_err(AppError::InternalError)? {
Ok(Json(serde_json::json!({ "status": "deleted" })).into_response())
} else {
Err(AppError::NotFound("Response template not found".to_string()))
Err(AppError::NotFound(
"Response template not found".to_string(),
))
}
}
+1 -1
View File
@@ -479,7 +479,7 @@ pub async fn create_saved_search(
let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash)
.map_err(|e| AppError::InternalError(format!("Failed to open database: {e}")))?;
let saved = db::saved_searches::create(&conn, &data.name, &data.query_string)
.map_err(|e| AppError::BadRequest(e))?;
.map_err(AppError::BadRequest)?;
Ok((axum::http::StatusCode::CREATED, Json(saved)).into_response())
}
+6 -7
View File
@@ -441,10 +441,10 @@ fn resolve_sender_public_key(
identity: Option<&crate::db::identities::Identity>,
sender_email: &str,
) -> Result<(String, String), AppError> {
if let Some(fingerprint) = identity.and_then(|item| item.pgp_fingerprint.as_deref()) {
if let Some(key) = db::pgp::get_key(conn, fingerprint).map_err(AppError::InternalError)? {
return Ok((key.record.key_id, key.record.public_key_armor));
}
if let Some(fingerprint) = identity.and_then(|item| item.pgp_fingerprint.as_deref())
&& let Some(key) = db::pgp::get_key(conn, fingerprint).map_err(AppError::InternalError)?
{
return Ok((key.record.key_id, key.record.public_key_armor));
}
let key = db::pgp::find_key_internal_by_email(conn, sender_email)
.map_err(AppError::InternalError)?
@@ -555,10 +555,9 @@ fn build_pgp_send_config(
if let Some(key) = db::pgp::find_key_internal_by_email(conn, sender_email)
.map_err(AppError::InternalError)?
&& seen.insert(key.record.fingerprint.clone())
{
if seen.insert(key.record.fingerprint.clone()) {
recipient_public_keys.push(key.record.public_key_armor);
}
recipient_public_keys.push(key.record.public_key_armor);
}
}
+1 -1
View File
@@ -23,7 +23,7 @@ fn configured_folder_if_present(
};
let exists: bool = conn
.query_row(
"SELECT EXISTS(SELECT 1 FROM folders WHERE name = ?1)",
crate::queries::sqlite::queries::routes_special_folders::Q_001,
[folder],
|row| row.get(0),
)
+13 -15
View File
@@ -384,21 +384,19 @@ pub async fn bulk_tag_handler(
};
let mut synced = 0usize;
if enabled {
if let Some(tag_keyword) = tag_keyword {
for msg in &body.messages {
if sync_keyword_to_imap(
imap_client.as_ref(),
&session,
&msg.folder,
msg.uid,
&tag_keyword,
true,
)
.await
{
synced += 1;
}
if enabled && let Some(tag_keyword) = tag_keyword {
for msg in &body.messages {
if sync_keyword_to_imap(
imap_client.as_ref(),
&session,
&msg.folder,
msg.uid,
&tag_keyword,
true,
)
.await
{
synced += 1;
}
}
}
+1 -1
View File
@@ -228,7 +228,7 @@ async fn post_one_click_to_address(
format!("{host}:{port}")
};
let request = format!(
"POST {path} HTTP/1.1\r\nHost: {host_header}\r\nUser-Agent: RustBox/0.0.6\r\nContent-Type: application/x-www-form-urlencoded\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{ONE_CLICK_BODY}",
"POST {path} HTTP/1.1\r\nHost: {host_header}\r\nUser-Agent: RustBox/0.1.0\r\nContent-Type: application/x-www-form-urlencoded\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{ONE_CLICK_BODY}",
ONE_CLICK_BODY.len()
);
+105
View File
@@ -0,0 +1,105 @@
use std::fs;
use std::path::Path;
use aes_gcm::{Aes256Gcm, KeyInit, Nonce, aead::Aead};
use base64::{Engine, engine::general_purpose::STANDARD};
use rand::RngCore;
use crate::config::AppConfig;
#[derive(Clone)]
pub struct SessionCrypto {
key: [u8; 32],
}
impl SessionCrypto {
pub fn load_or_create(config: &AppConfig) -> Result<Self, String> {
if let Some(raw) = config
.session_encryption_key
.as_deref()
.map(str::trim)
.filter(|v| !v.is_empty())
{
let decoded = STANDARD
.decode(raw)
.map_err(|e| format!("SESSION_ENCRYPTION_KEY must be base64: {e}"))?;
if decoded.len() != 32 {
return Err("SESSION_ENCRYPTION_KEY must decode to exactly 32 bytes".to_string());
}
let mut key = [0u8; 32];
key.copy_from_slice(&decoded);
return Ok(Self { key });
}
if config.cluster_mode
|| config.database_backend != "sqlite"
|| config.session_backend == "redis"
{
return Err(
"SESSION_ENCRYPTION_KEY is required for shared/cluster session storage".to_string(),
);
}
fs::create_dir_all(&config.data_dir)
.map_err(|e| format!("failed to create data_dir: {e}"))?;
let path = Path::new(&config.data_dir).join("session.key");
if path.is_file() {
let raw = fs::read(&path).map_err(|e| format!("failed to read session key: {e}"))?;
if raw.len() != 32 {
return Err(format!("invalid session key length in {}", path.display()));
}
let mut key = [0u8; 32];
key.copy_from_slice(&raw);
return Ok(Self { key });
}
let mut key = [0u8; 32];
rand::rng().fill_bytes(&mut key);
#[cfg(unix)]
{
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
let mut file = fs::OpenOptions::new()
.create_new(true)
.write(true)
.mode(0o600)
.open(&path)
.map_err(|e| format!("failed to create session key: {e}"))?;
file.write_all(&key)
.map_err(|e| format!("failed to write session key: {e}"))?;
}
#[cfg(not(unix))]
fs::write(&path, key).map_err(|e| format!("failed to write session key: {e}"))?;
Ok(Self { key })
}
pub fn encrypt(&self, plaintext: &str) -> Result<String, String> {
let cipher =
Aes256Gcm::new_from_slice(&self.key).map_err(|_| "invalid session key".to_string())?;
let mut nonce_bytes = [0u8; 12];
rand::rng().fill_bytes(&mut nonce_bytes);
let ciphertext = cipher
.encrypt(Nonce::from_slice(&nonce_bytes), plaintext.as_bytes())
.map_err(|_| "failed to encrypt session credential".to_string())?;
let mut combined = nonce_bytes.to_vec();
combined.extend_from_slice(&ciphertext);
Ok(STANDARD.encode(combined))
}
pub fn decrypt(&self, encoded: &str) -> Result<String, String> {
let raw = STANDARD
.decode(encoded)
.map_err(|e| format!("invalid encrypted session credential: {e}"))?;
if raw.len() < 13 {
return Err("invalid encrypted session credential".to_string());
}
let (nonce, ciphertext) = raw.split_at(12);
let cipher =
Aes256Gcm::new_from_slice(&self.key).map_err(|_| "invalid session key".to_string())?;
let plaintext = cipher
.decrypt(Nonce::from_slice(nonce), ciphertext)
.map_err(|_| "failed to decrypt session credential".to_string())?;
String::from_utf8(plaintext)
.map_err(|e| format!("invalid UTF-8 in session credential: {e}"))
}
}
+469
View File
@@ -0,0 +1,469 @@
use std::path::{Path, PathBuf};
use async_trait::async_trait;
use rusqlite::{Connection, params};
use sqlx::{FromRow, MySqlPool, PgPool};
use super::{PersistedSession, SessionPersistence};
use crate::config::AppConfig;
use crate::queries;
#[derive(Clone)]
enum Pool {
Sqlite(PathBuf),
Mysql(MySqlPool),
Pgsql(PgPool),
}
#[derive(Debug, FromRow)]
struct DbSessionRow {
token: String,
account_id: String,
browser_id: String,
email: String,
password_ciphertext: String,
user_hash: String,
imap_host: String,
imap_port: i64,
imap_tls: i64,
imap_tls_verify: i64,
smtp_host: String,
smtp_port: i64,
smtp_tls: i64,
smtp_tls_verify: i64,
timeout_secs: i64,
created_at_unix: i64,
last_accessed_unix: i64,
user_agent: String,
}
impl TryFrom<DbSessionRow> for PersistedSession {
type Error = String;
fn try_from(row: DbSessionRow) -> Result<Self, Self::Error> {
Ok(Self {
token: row.token,
account_id: row.account_id,
browser_id: row.browser_id,
email: row.email,
password_ciphertext: row.password_ciphertext,
user_hash: row.user_hash,
imap_host: row.imap_host,
imap_port: u16::try_from(row.imap_port)
.map_err(|_| "invalid imap_port in session database")?,
imap_tls: row.imap_tls != 0,
imap_tls_verify: row.imap_tls_verify != 0,
smtp_host: row.smtp_host,
smtp_port: u16::try_from(row.smtp_port)
.map_err(|_| "invalid smtp_port in session database")?,
smtp_tls: row.smtp_tls != 0,
smtp_tls_verify: row.smtp_tls_verify != 0,
timeout_secs: u64::try_from(row.timeout_secs)
.map_err(|_| "invalid timeout_secs in session database")?,
created_at_unix: u64::try_from(row.created_at_unix)
.map_err(|_| "invalid created_at_unix in session database")?,
last_accessed_unix: u64::try_from(row.last_accessed_unix)
.map_err(|_| "invalid last_accessed_unix in session database")?,
user_agent: row.user_agent,
})
}
}
fn sqlite_path(config: &AppConfig) -> Result<PathBuf, String> {
let path = match config
.database_url
.as_deref()
.map(str::trim)
.filter(|v| !v.is_empty())
{
Some(raw) => {
let without_scheme = raw
.strip_prefix("sqlite://")
.or_else(|| raw.strip_prefix("sqlite:"))
.unwrap_or(raw);
let path = without_scheme.split('?').next().unwrap_or_default().trim();
if path.is_empty() {
return Err("SQLite DATABASE_URL does not contain a database path".to_string());
}
PathBuf::from(path)
}
None => PathBuf::from(&config.data_dir).join("rustbox.sqlite"),
};
if path == Path::new(":memory:") {
return Err(
"SQLite session storage must use a file so sessions survive restart".to_string(),
);
}
if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
std::fs::create_dir_all(parent)
.map_err(|e| format!("failed to create SQLite database directory: {e}"))?;
}
Ok(path)
}
fn sqlite_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<DbSessionRow> {
Ok(DbSessionRow {
token: row.get("token")?,
account_id: row.get("account_id")?,
browser_id: row.get("browser_id")?,
email: row.get("email")?,
password_ciphertext: row.get("password_ciphertext")?,
user_hash: row.get("user_hash")?,
imap_host: row.get("imap_host")?,
imap_port: row.get("imap_port")?,
imap_tls: row.get("imap_tls")?,
imap_tls_verify: row.get("imap_tls_verify")?,
smtp_host: row.get("smtp_host")?,
smtp_port: row.get("smtp_port")?,
smtp_tls: row.get("smtp_tls")?,
smtp_tls_verify: row.get("smtp_tls_verify")?,
timeout_secs: row.get("timeout_secs")?,
created_at_unix: row.get("created_at_unix")?,
last_accessed_unix: row.get("last_accessed_unix")?,
user_agent: row.get("user_agent")?,
})
}
async fn sqlite_task<T, F>(path: PathBuf, operation: F) -> Result<T, String>
where
T: Send + 'static,
F: FnOnce(Connection) -> Result<T, String> + Send + 'static,
{
tokio::task::spawn_blocking(move || {
let conn = Connection::open(path)
.map_err(|e| format!("failed to open SQLite session database: {e}"))?;
conn.busy_timeout(std::time::Duration::from_secs(5))
.map_err(|e| format!("failed to configure SQLite busy timeout: {e}"))?;
operation(conn)
})
.await
.map_err(|e| format!("SQLite session task failed: {e}"))?
}
#[derive(Clone)]
pub struct DatabaseSessionStore {
pool: Pool,
}
impl DatabaseSessionStore {
pub async fn connect(config: &AppConfig) -> Result<Self, String> {
let pool = match config.database_backend.as_str() {
"sqlite" => {
let path = sqlite_path(config)?;
let migration_path = path.clone();
sqlite_task(migration_path, |conn| {
conn.execute_batch(include_str!("../../migrations/sqlite/V047__sessions.sql"))
.map_err(|e| format!("SQLite session migration failed: {e}"))?;
Ok(())
})
.await?;
Pool::Sqlite(path)
}
"mysql" => {
let url = config
.database_url
.as_deref()
.ok_or("DATABASE_URL required for mysql")?;
let pool = MySqlPool::connect(url)
.await
.map_err(|e| format!("failed to connect MySQL session database: {e}"))?;
sqlx::raw_sql(include_str!("../../migrations/mysql/V047__sessions.sql"))
.execute(&pool)
.await
.map_err(|e| format!("MySQL session migration failed: {e}"))?;
Pool::Mysql(pool)
}
"pgsql" | "postgres" | "postgresql" => {
let url = config
.database_url
.as_deref()
.ok_or("DATABASE_URL required for pgsql")?;
let pool = PgPool::connect(url)
.await
.map_err(|e| format!("failed to connect PostgreSQL session database: {e}"))?;
sqlx::raw_sql(include_str!("../../migrations/pgsql/V047__sessions.sql"))
.execute(&pool)
.await
.map_err(|e| format!("PostgreSQL session migration failed: {e}"))?;
Pool::Pgsql(pool)
}
other => return Err(format!("unsupported database backend: {other}")),
};
Ok(Self { pool })
}
async fn rows(
&self,
sql_sqlite: &str,
sql_mysql: &str,
sql_pgsql: &str,
value: &str,
) -> Result<Vec<PersistedSession>, String> {
let rows: Vec<DbSessionRow> = match &self.pool {
Pool::Sqlite(path) => {
let path = path.clone();
let sql = sql_sqlite.to_string();
let value = value.to_string();
sqlite_task(path, move |conn| {
let mut stmt = conn
.prepare(&sql)
.map_err(|e| format!("failed to prepare SQLite session query: {e}"))?;
let mapped = stmt
.query_map(params![value], sqlite_row)
.map_err(|e| format!("SQLite session query failed: {e}"))?;
mapped
.collect::<rusqlite::Result<Vec<_>>>()
.map_err(|e| format!("failed to read SQLite session rows: {e}"))
})
.await?
}
Pool::Mysql(pool) => sqlx::query_as(sql_mysql)
.bind(value)
.fetch_all(pool)
.await
.map_err(|e| format!("MySQL session query failed: {e}"))?,
Pool::Pgsql(pool) => sqlx::query_as(sql_pgsql)
.bind(value)
.fetch_all(pool)
.await
.map_err(|e| format!("PostgreSQL session query failed: {e}"))?,
};
rows.into_iter().map(TryInto::try_into).collect()
}
}
macro_rules! bind_session {
($q:expr, $s:expr) => {
$q.bind(&$s.token)
.bind(&$s.account_id)
.bind(&$s.browser_id)
.bind(&$s.email)
.bind(&$s.password_ciphertext)
.bind(&$s.user_hash)
.bind(&$s.imap_host)
.bind(i64::from($s.imap_port))
.bind(if $s.imap_tls { 1_i64 } else { 0_i64 })
.bind(if $s.imap_tls_verify { 1_i64 } else { 0_i64 })
.bind(&$s.smtp_host)
.bind(i64::from($s.smtp_port))
.bind(if $s.smtp_tls { 1_i64 } else { 0_i64 })
.bind(if $s.smtp_tls_verify { 1_i64 } else { 0_i64 })
.bind($s.timeout_secs as i64)
.bind($s.created_at_unix as i64)
.bind($s.last_accessed_unix as i64)
.bind(&$s.user_agent)
};
}
#[async_trait]
impl SessionPersistence for DatabaseSessionStore {
async fn save(&self, s: &PersistedSession) -> Result<(), String> {
match &self.pool {
Pool::Sqlite(path) => {
let path = path.clone();
let s = s.clone();
sqlite_task(path, move |conn| {
conn.execute(
queries::sqlite::queries::sessions::UPSERT,
params![
s.token,
s.account_id,
s.browser_id,
s.email,
s.password_ciphertext,
s.user_hash,
s.imap_host,
i64::from(s.imap_port),
if s.imap_tls { 1_i64 } else { 0_i64 },
if s.imap_tls_verify { 1_i64 } else { 0_i64 },
s.smtp_host,
i64::from(s.smtp_port),
if s.smtp_tls { 1_i64 } else { 0_i64 },
if s.smtp_tls_verify { 1_i64 } else { 0_i64 },
s.timeout_secs as i64,
s.created_at_unix as i64,
s.last_accessed_unix as i64,
s.user_agent,
],
)
.map_err(|e| format!("failed to persist SQLite session: {e}"))?;
Ok(())
})
.await
}
Pool::Mysql(pool) => {
bind_session!(sqlx::query(queries::mysql::queries::sessions::UPSERT), s)
.execute(pool)
.await
.map(|_| ())
.map_err(|e| format!("failed to persist MySQL session: {e}"))
}
Pool::Pgsql(pool) => {
bind_session!(sqlx::query(queries::pgsql::queries::sessions::UPSERT), s)
.execute(pool)
.await
.map(|_| ())
.map_err(|e| format!("failed to persist PostgreSQL session: {e}"))
}
}
}
async fn load_token(&self, token: &str) -> Result<Option<PersistedSession>, String> {
Ok(self
.rows(
queries::sqlite::queries::sessions::BY_TOKEN,
queries::mysql::queries::sessions::BY_TOKEN,
queries::pgsql::queries::sessions::BY_TOKEN,
token,
)
.await?
.into_iter()
.next())
}
async fn load_browser(&self, browser_id: &str) -> Result<Vec<PersistedSession>, String> {
self.rows(
queries::sqlite::queries::sessions::BY_BROWSER,
queries::mysql::queries::sessions::BY_BROWSER,
queries::pgsql::queries::sessions::BY_BROWSER,
browser_id,
)
.await
}
async fn load_user(&self, user_hash: &str) -> Result<Vec<PersistedSession>, String> {
self.rows(
queries::sqlite::queries::sessions::BY_USER,
queries::mysql::queries::sessions::BY_USER,
queries::pgsql::queries::sessions::BY_USER,
user_hash,
)
.await
}
async fn browser_exists(&self, browser_id: &str) -> Result<bool, String> {
let count = match &self.pool {
Pool::Sqlite(path) => {
let path = path.clone();
let browser_id = browser_id.to_string();
sqlite_task(path, move |conn| {
conn.query_row(
queries::sqlite::queries::sessions::BROWSER_EXISTS,
params![browser_id],
|row| row.get::<_, i64>(0),
)
.map_err(|e| format!("failed to query SQLite browser session: {e}"))
})
.await?
}
Pool::Mysql(pool) => {
sqlx::query_scalar(queries::mysql::queries::sessions::BROWSER_EXISTS)
.bind(browser_id)
.fetch_one(pool)
.await
.map_err(|e| format!("failed to query MySQL browser session: {e}"))?
}
Pool::Pgsql(pool) => {
sqlx::query_scalar(queries::pgsql::queries::sessions::BROWSER_EXISTS)
.bind(browser_id)
.fetch_one(pool)
.await
.map_err(|e| format!("failed to query PostgreSQL browser session: {e}"))?
}
};
Ok(count > 0)
}
async fn delete_account(&self, account_id: &str) -> Result<bool, String> {
let affected = match &self.pool {
Pool::Sqlite(path) => {
let path = path.clone();
let account_id = account_id.to_string();
sqlite_task(path, move |conn| {
conn.execute(
queries::sqlite::queries::sessions::DELETE_ACCOUNT,
params![account_id],
)
.map(|count| count as u64)
.map_err(|e| format!("failed to delete SQLite session account: {e}"))
})
.await?
}
Pool::Mysql(pool) => sqlx::query(queries::mysql::queries::sessions::DELETE_ACCOUNT)
.bind(account_id)
.execute(pool)
.await
.map(|r| r.rows_affected())
.map_err(|e| format!("failed to delete MySQL session account: {e}"))?,
Pool::Pgsql(pool) => sqlx::query(queries::pgsql::queries::sessions::DELETE_ACCOUNT)
.bind(account_id)
.execute(pool)
.await
.map(|r| r.rows_affected())
.map_err(|e| format!("failed to delete PostgreSQL session account: {e}"))?,
};
Ok(affected > 0)
}
async fn delete_browser(&self, browser_id: &str) -> Result<(), String> {
match &self.pool {
Pool::Sqlite(path) => {
let path = path.clone();
let browser_id = browser_id.to_string();
sqlite_task(path, move |conn| {
conn.execute(
queries::sqlite::queries::sessions::DELETE_BROWSER,
params![browser_id],
)
.map_err(|e| format!("failed to delete SQLite browser sessions: {e}"))?;
Ok(())
})
.await
}
Pool::Mysql(pool) => sqlx::query(queries::mysql::queries::sessions::DELETE_BROWSER)
.bind(browser_id)
.execute(pool)
.await
.map(|_| ())
.map_err(|e| format!("failed to delete MySQL browser sessions: {e}")),
Pool::Pgsql(pool) => sqlx::query(queries::pgsql::queries::sessions::DELETE_BROWSER)
.bind(browser_id)
.execute(pool)
.await
.map(|_| ())
.map_err(|e| format!("failed to delete PostgreSQL browser sessions: {e}")),
}
}
async fn purge_expired(&self, now_unix: u64) -> Result<u64, String> {
match &self.pool {
Pool::Sqlite(path) => {
let path = path.clone();
sqlite_task(path, move |conn| {
conn.execute(
queries::sqlite::queries::sessions::DELETE_EXPIRED,
params![now_unix as i64],
)
.map(|count| count as u64)
.map_err(|e| format!("failed to purge expired SQLite sessions: {e}"))
})
.await
}
Pool::Mysql(pool) => sqlx::query(queries::mysql::queries::sessions::DELETE_EXPIRED)
.bind(now_unix as i64)
.execute(pool)
.await
.map(|r| r.rows_affected())
.map_err(|e| format!("failed to purge expired MySQL sessions: {e}")),
Pool::Pgsql(pool) => sqlx::query(queries::pgsql::queries::sessions::DELETE_EXPIRED)
.bind(now_unix as i64)
.execute(pool)
.await
.map(|r| r.rows_affected())
.map_err(|e| format!("failed to purge expired PostgreSQL sessions: {e}")),
}
}
}
+62
View File
@@ -0,0 +1,62 @@
pub mod crypto;
pub mod database;
pub mod redis_store;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use crate::config::AppConfig;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PersistedSession {
pub token: String,
pub account_id: String,
pub browser_id: String,
pub email: String,
pub password_ciphertext: String,
pub user_hash: String,
pub imap_host: String,
pub imap_port: u16,
pub imap_tls: bool,
pub imap_tls_verify: bool,
pub smtp_host: String,
pub smtp_port: u16,
pub smtp_tls: bool,
pub smtp_tls_verify: bool,
pub timeout_secs: u64,
pub created_at_unix: u64,
pub last_accessed_unix: u64,
pub user_agent: String,
}
impl PersistedSession {
pub fn expired_at(&self, now_unix: u64) -> bool {
now_unix.saturating_sub(self.last_accessed_unix) > self.timeout_secs
}
}
#[async_trait]
pub trait SessionPersistence: Send + Sync {
async fn save(&self, session: &PersistedSession) -> Result<(), String>;
async fn load_token(&self, token: &str) -> Result<Option<PersistedSession>, String>;
async fn load_browser(&self, browser_id: &str) -> Result<Vec<PersistedSession>, String>;
async fn load_user(&self, user_hash: &str) -> Result<Vec<PersistedSession>, String>;
async fn browser_exists(&self, browser_id: &str) -> Result<bool, String>;
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>;
}
pub async fn from_config(
config: &AppConfig,
) -> Result<std::sync::Arc<dyn SessionPersistence>, String> {
match config.session_backend.as_str() {
"database" => Ok(std::sync::Arc::new(
database::DatabaseSessionStore::connect(config).await?,
)),
"redis" => Ok(std::sync::Arc::new(
redis_store::RedisSessionStore::connect(config).await?,
)),
other => Err(format!("unsupported session backend: {other}")),
}
}
+280
View File
@@ -0,0 +1,280 @@
use async_trait::async_trait;
use redis::AsyncCommands;
use super::{PersistedSession, SessionPersistence};
use crate::config::AppConfig;
#[derive(Clone)]
struct SentinelConfig {
nodes: Vec<String>,
master: String,
master_password: Option<String>,
sentinel_password: Option<String>,
}
#[derive(Clone)]
enum RedisMode {
Direct(redis::Client),
Sentinel(SentinelConfig),
}
#[derive(Clone)]
pub struct RedisSessionStore {
mode: RedisMode,
}
impl RedisSessionStore {
pub async fn connect(config: &AppConfig) -> Result<Self, String> {
let mode = if let Some(url) = config
.redis_url
.as_deref()
.map(str::trim)
.filter(|v| !v.is_empty())
{
RedisMode::Direct(
redis::Client::open(url.to_string())
.map_err(|e| format!("invalid Redis URL: {e}"))?,
)
} else {
RedisMode::Sentinel(SentinelConfig {
nodes: config.redis_sentinel_nodes.clone(),
master: config
.redis_sentinel_master
.clone()
.ok_or("REDIS_SENTINEL_MASTER is required")?,
master_password: config.redis_password.clone(),
sentinel_password: config.redis_sentinel_password.clone(),
})
};
let store = Self { mode };
let mut con = store.connection().await?;
let _: String = redis::cmd("PING")
.query_async(&mut con)
.await
.map_err(|e| format!("Redis PING failed: {e}"))?;
Ok(store)
}
fn auth_fragment(password: Option<&str>) -> String {
password
.map(|p| {
format!(
":{}@",
url::form_urlencoded::byte_serialize(p.as_bytes()).collect::<String>()
)
})
.unwrap_or_default()
}
async fn sentinel_master_url(config: &SentinelConfig) -> Result<String, String> {
let mut last_error = String::new();
for node in &config.nodes {
let sentinel_url = if node.contains("://") {
node.clone()
} else {
format!(
"redis://{}{}",
Self::auth_fragment(config.sentinel_password.as_deref()),
node
)
};
let sentinel_url = if sentinel_url.ends_with('/') {
sentinel_url
} else {
format!("{sentinel_url}/")
};
match redis::Client::open(sentinel_url) {
Ok(client) => match client.get_multiplexed_async_connection().await {
Ok(mut con) => {
let result: redis::RedisResult<Vec<String>> = redis::cmd("SENTINEL")
.arg("get-master-addr-by-name")
.arg(&config.master)
.query_async(&mut con)
.await;
match result {
Ok(parts) if parts.len() >= 2 => {
let auth = Self::auth_fragment(config.master_password.as_deref());
return Ok(format!("redis://{auth}{}:{}/", parts[0], parts[1]));
}
Ok(_) => last_error = format!("Sentinel {node} returned no master"),
Err(e) => last_error = format!("Sentinel {node} query failed: {e}"),
}
}
Err(e) => last_error = format!("Sentinel {node} connection failed: {e}"),
},
Err(e) => last_error = format!("invalid Sentinel URL {node}: {e}"),
}
}
Err(if last_error.is_empty() {
"no Redis Sentinel nodes configured".to_string()
} else {
last_error
})
}
fn session_key(token: &str) -> String {
format!("rustbox:session:{token}")
}
fn account_key(account_id: &str) -> String {
format!("rustbox:account:{account_id}")
}
fn browser_key(browser_id: &str) -> String {
format!("rustbox:browser:{browser_id}")
}
fn user_key(user_hash: &str) -> String {
format!("rustbox:user:{user_hash}")
}
async fn connection(&self) -> Result<redis::aio::MultiplexedConnection, String> {
let client = match &self.mode {
RedisMode::Direct(client) => client.clone(),
RedisMode::Sentinel(config) => {
let url = Self::sentinel_master_url(config).await?;
redis::Client::open(url).map_err(|e| format!("invalid Redis master URL: {e}"))?
}
};
client
.get_multiplexed_async_connection()
.await
.map_err(|e| format!("Redis connection failed: {e}"))
}
}
#[async_trait]
impl SessionPersistence for RedisSessionStore {
async fn save(&self, session: &PersistedSession) -> Result<(), String> {
let mut con = self.connection().await?;
let json = serde_json::to_string(session)
.map_err(|e| format!("failed to encode Redis session: {e}"))?;
let ttl = session.timeout_secs.max(1) as i64;
let mut pipe = redis::pipe();
pipe.atomic()
.set(Self::session_key(&session.token), json)
.ignore()
.expire(Self::session_key(&session.token), ttl)
.ignore()
.set(Self::account_key(&session.account_id), &session.token)
.ignore()
.expire(Self::account_key(&session.account_id), ttl)
.ignore()
.sadd(Self::browser_key(&session.browser_id), &session.account_id)
.ignore()
.expire(Self::browser_key(&session.browser_id), ttl)
.ignore()
.sadd(Self::user_key(&session.user_hash), &session.account_id)
.ignore()
.expire(Self::user_key(&session.user_hash), ttl)
.ignore();
pipe.query_async::<()>(&mut con)
.await
.map_err(|e| format!("failed to persist Redis session: {e}"))
}
async fn load_token(&self, token: &str) -> Result<Option<PersistedSession>, String> {
let mut con = self.connection().await?;
let raw: Option<String> = con
.get(Self::session_key(token))
.await
.map_err(|e| format!("Redis GET failed: {e}"))?;
raw.map(|json| {
serde_json::from_str(&json).map_err(|e| format!("invalid Redis session payload: {e}"))
})
.transpose()
}
async fn load_browser(&self, browser_id: &str) -> Result<Vec<PersistedSession>, String> {
let mut con = self.connection().await?;
let accounts: Vec<String> = con
.smembers(Self::browser_key(browser_id))
.await
.map_err(|e| format!("Redis SMEMBERS failed: {e}"))?;
let mut result = Vec::new();
for account_id in accounts {
let token: Option<String> = con
.get(Self::account_key(&account_id))
.await
.map_err(|e| format!("Redis GET failed: {e}"))?;
if let Some(token) = token
&& let Some(session) = self.load_token(&token).await?
{
result.push(session);
}
}
result.sort_by_key(|s| s.created_at_unix);
Ok(result)
}
async fn load_user(&self, user_hash: &str) -> Result<Vec<PersistedSession>, String> {
let mut con = self.connection().await?;
let accounts: Vec<String> = con
.smembers(Self::user_key(user_hash))
.await
.map_err(|e| format!("Redis SMEMBERS failed: {e}"))?;
let mut result = Vec::new();
for account_id in accounts {
let token: Option<String> = con
.get(Self::account_key(&account_id))
.await
.map_err(|e| format!("Redis GET failed: {e}"))?;
if let Some(token) = token
&& let Some(session) = self.load_token(&token).await?
{
result.push(session);
}
}
result.sort_by_key(|s| std::cmp::Reverse(s.last_accessed_unix));
Ok(result)
}
async fn browser_exists(&self, browser_id: &str) -> Result<bool, String> {
let mut con = self.connection().await?;
con.exists(Self::browser_key(browser_id))
.await
.map_err(|e| format!("Redis EXISTS failed: {e}"))
}
async fn delete_account(&self, account_id: &str) -> Result<bool, String> {
let mut con = self.connection().await?;
let token: Option<String> = con
.get(Self::account_key(account_id))
.await
.map_err(|e| format!("Redis GET failed: {e}"))?;
let Some(token) = token else {
return Ok(false);
};
let session = self.load_token(&token).await?;
let mut pipe = redis::pipe();
pipe.atomic()
.del(Self::account_key(account_id))
.ignore()
.del(Self::session_key(&token))
.ignore();
if let Some(session) = session {
pipe.srem(Self::browser_key(&session.browser_id), account_id)
.ignore()
.srem(Self::user_key(&session.user_hash), account_id)
.ignore();
}
pipe.query_async::<()>(&mut con)
.await
.map_err(|e| format!("Redis delete failed: {e}"))?;
Ok(true)
}
async fn delete_browser(&self, browser_id: &str) -> Result<(), String> {
let sessions = self.load_browser(browser_id).await?;
for session in sessions {
self.delete_account(&session.account_id).await?;
}
let mut con = self.connection().await?;
let _: usize = con
.del(Self::browser_key(browser_id))
.await
.map_err(|e| format!("Redis DEL failed: {e}"))?;
Ok(())
}
async fn purge_expired(&self, _now_unix: u64) -> Result<u64, String> {
Ok(0)
}
}
+2 -3
View File
@@ -488,10 +488,9 @@ fn parse_status(line: &WireLine) -> Option<(ResponseStatus, Option<String>)> {
(ResponseStatus::Ok, rest)
} else if let Some(rest) = strip_token_ci(text, "NO") {
(ResponseStatus::No, rest)
} else if let Some(rest) = strip_token_ci(text, "BYE") {
(ResponseStatus::Bye, rest)
} else {
return None;
let rest = strip_token_ci(text, "BYE")?;
(ResponseStatus::Bye, rest)
};
let rest = rest.trim_start();
+1 -1
View File
@@ -29,7 +29,7 @@ impl fmt::Display for SieveError {
write!(f, "ManageSieve server rejected the command: {msg}")
}
Self::Timeout => write!(f, "ManageSieve operation timed out"),
Self::ScriptTooLarge => write!(f, "Sieve script exceeds the RustBox 0.0.6 size limit"),
Self::ScriptTooLarge => write!(f, "Sieve script exceeds the RustBox 0.1.0 size limit"),
}
}
}
+1
View File
@@ -42,6 +42,7 @@ pub fn build_message(
builder.multipart(content).map_err(|e| e.to_string())
}
#[cfg(test)]
pub fn build_rfc822(message: &SendableMessage, message_id: &str) -> Result<Vec<u8>, String> {
Ok(build_message(message, message_id)?.formatted())
}
+114 -1
View File
@@ -981,4 +981,117 @@ html[data-interface-skin="nord"] .button.primary {
background: var(--accent);
border-color: var(--accent);
color: var(--accent-ink);
}
}
/* 0.0.7: additional website-inspired interface palettes. */
html[data-interface-skin="github"],
html[data-interface-skin="gitlab"],
html[data-interface-skin="discord"],
html[data-interface-skin="slack"],
html[data-interface-skin="reddit"],
html[data-interface-skin="notion"] {
--font-sans: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
html[data-interface-skin="github"][data-theme="light"] {
color-scheme: light;
--bg: #f6f8fa; --panel: #ffffff; --panel-2: #f6f8fa; --panel-3: #eaeef2; --sidebar: #f6f8fa;
--line: #d8dee4; --line-strong: #afb8c1; --text: #1f2328; --muted: #656d76; --subtle: #818b98;
--accent: #0969da; --accent-strong: #0550ae; --accent-ink: #ffffff; --danger: #cf222e; --warning: #9a6700; --success: #1a7f37;
--field: #ffffff; --selected: #ddf4ff; --hover: #eef4f8; --shadow: rgba(31, 35, 40, .15);
--folder-bg: #f6f8fa; --message-bg: #ffffff; --reader-bg: #ffffff;
}
html[data-interface-skin="github"][data-theme="dark"] {
color-scheme: dark;
--bg: #0d1117; --panel: #161b22; --panel-2: #21262d; --panel-3: #2d333b; --sidebar: #0d1117;
--line: #30363d; --line-strong: #484f58; --text: #f0f6fc; --muted: #8b949e; --subtle: #6e7681;
--accent: #58a6ff; --accent-strong: #79c0ff; --accent-ink: #0d1117; --danger: #f85149; --warning: #d29922; --success: #3fb950;
--field: #0d1117; --selected: #1f3d5c; --hover: #1c2128; --shadow: rgba(0, 0, 0, .45);
--folder-bg: #0d1117; --message-bg: #161b22; --reader-bg: #161b22;
}
html[data-interface-skin="gitlab"][data-theme="light"] {
color-scheme: light;
--bg: #f7f7fb; --panel: #ffffff; --panel-2: #faf9ff; --panel-3: #f0edf8; --sidebar: #f4f1fa;
--line: #ddd8e8; --line-strong: #bbb2cb; --text: #171321; --muted: #626168; --subtle: #85818c;
--accent: #7759c2; --accent-strong: #5943a6; --accent-ink: #ffffff; --danger: #c0341d; --warning: #b14f18; --success: #108548;
--field: #ffffff; --selected: #eee7ff; --hover: #f5f1ff; --shadow: rgba(55, 35, 95, .14);
--folder-bg: #f4f1fa; --message-bg: #ffffff; --reader-bg: #ffffff;
}
html[data-interface-skin="gitlab"][data-theme="dark"] {
color-scheme: dark;
--bg: #171321; --panel: #211b2f; --panel-2: #2b243a; --panel-3: #362e47; --sidebar: #1b1627;
--line: #40364f; --line-strong: #625773; --text: #f4f0ff; --muted: #bbb2c9; --subtle: #91869f;
--accent: #a989f5; --accent-strong: #c0a7ff; --accent-ink: #1c132d; --danger: #ff8a74; --warning: #fc8d43; --success: #67c98f;
--field: #1b1627; --selected: #49386a; --hover: #2c243b; --shadow: rgba(0, 0, 0, .42);
--folder-bg: #1b1627; --message-bg: #211b2f; --reader-bg: #211b2f;
}
html[data-interface-skin="discord"][data-theme="light"] {
color-scheme: light;
--bg: #f2f3f5; --panel: #ffffff; --panel-2: #f8f8f9; --panel-3: #e9eaed; --sidebar: #ebedef;
--line: #d5d7da; --line-strong: #b5bac1; --text: #313338; --muted: #5c5e66; --subtle: #80848e;
--accent: #5865f2; --accent-strong: #4752c4; --accent-ink: #ffffff; --danger: #da373c; --warning: #a66a00; --success: #248046;
--field: #ffffff; --selected: #e0e3ff; --hover: #eceef2; --shadow: rgba(30, 31, 34, .14);
--folder-bg: #ebedef; --message-bg: #ffffff; --reader-bg: #ffffff;
}
html[data-interface-skin="discord"][data-theme="dark"] {
color-scheme: dark;
--bg: #1e1f22; --panel: #313338; --panel-2: #2b2d31; --panel-3: #383a40; --sidebar: #2b2d31;
--line: #3f4147; --line-strong: #5d6068; --text: #f2f3f5; --muted: #b5bac1; --subtle: #949ba4;
--accent: #5865f2; --accent-strong: #7983f5; --accent-ink: #ffffff; --danger: #f23f42; --warning: #f0b232; --success: #23a559;
--field: #1e1f22; --selected: #41456c; --hover: #35373c; --shadow: rgba(0, 0, 0, .44);
--folder-bg: #2b2d31; --message-bg: #313338; --reader-bg: #313338;
}
html[data-interface-skin="slack"][data-theme="light"] {
color-scheme: light;
--bg: #f8f8f8; --panel: #ffffff; --panel-2: #faf7fb; --panel-3: #f0e8f1; --sidebar: #f3e9f4;
--line: #ddd5df; --line-strong: #b9acbc; --text: #1d1c1d; --muted: #616061; --subtle: #868286;
--accent: #611f69; --accent-strong: #4a154b; --accent-ink: #ffffff; --danger: #e01e5a; --warning: #b56b00; --success: #007a5a;
--field: #ffffff; --selected: #f3e6f5; --hover: #f8f0f8; --shadow: rgba(74, 21, 75, .14);
--folder-bg: #f3e9f4; --message-bg: #ffffff; --reader-bg: #ffffff;
}
html[data-interface-skin="slack"][data-theme="dark"] {
color-scheme: dark;
--bg: #1a111b; --panel: #241725; --panel-2: #2d1c2f; --panel-3: #38233a; --sidebar: #211322;
--line: #49304b; --line-strong: #6c4e70; --text: #f8f3f8; --muted: #c9bccb; --subtle: #a08ca4;
--accent: #d786d9; --accent-strong: #e8a4e9; --accent-ink: #281129; --danger: #ff719e; --warning: #ecb22e; --success: #2eb67d;
--field: #211322; --selected: #553459; --hover: #302033; --shadow: rgba(0, 0, 0, .42);
--folder-bg: #211322; --message-bg: #241725; --reader-bg: #241725;
}
html[data-interface-skin="reddit"][data-theme="light"] {
color-scheme: light;
--bg: #f6f7f8; --panel: #ffffff; --panel-2: #f8f9fa; --panel-3: #edeff1; --sidebar: #f2f4f5;
--line: #d7d9dc; --line-strong: #b8bdc2; --text: #1a1a1b; --muted: #6a6a6b; --subtle: #878a8c;
--accent: #ff4500; --accent-strong: #d93a00; --accent-ink: #ffffff; --danger: #d63c3c; --warning: #a96800; --success: #2e7d32;
--field: #ffffff; --selected: #fff0e8; --hover: #f7eee9; --shadow: rgba(26, 26, 27, .14);
--folder-bg: #f2f4f5; --message-bg: #ffffff; --reader-bg: #ffffff;
}
html[data-interface-skin="reddit"][data-theme="dark"] {
color-scheme: dark;
--bg: #030303; --panel: #1a1a1b; --panel-2: #222223; --panel-3: #2d2d2e; --sidebar: #121213;
--line: #343536; --line-strong: #565758; --text: #d7dadc; --muted: #a8aaab; --subtle: #818384;
--accent: #ff4500; --accent-strong: #ff6a33; --accent-ink: #ffffff; --danger: #ff6b6b; --warning: #e6b450; --success: #55bd75;
--field: #121213; --selected: #4b281c; --hover: #242425; --shadow: rgba(0, 0, 0, .5);
--folder-bg: #121213; --message-bg: #1a1a1b; --reader-bg: #1a1a1b;
}
html[data-interface-skin="notion"][data-theme="light"] {
color-scheme: light;
--bg: #fbfbfa; --panel: #ffffff; --panel-2: #f7f7f5; --panel-3: #eeeeec; --sidebar: #f7f6f3;
--line: #e6e5e2; --line-strong: #cbc9c4; --text: #37352f; --muted: #787774; --subtle: #9b9a97;
--accent: #37352f; --accent-strong: #1f1e1b; --accent-ink: #ffffff; --danger: #e03e3e; --warning: #9a6b25; --success: #448361;
--field: #ffffff; --selected: #efefef; --hover: #f1f1ef; --shadow: rgba(15, 15, 15, .10);
--folder-bg: #f7f6f3; --message-bg: #ffffff; --reader-bg: #ffffff;
}
html[data-interface-skin="notion"][data-theme="dark"] {
color-scheme: dark;
--bg: #191919; --panel: #202020; --panel-2: #262626; --panel-3: #303030; --sidebar: #202020;
--line: #353535; --line-strong: #505050; --text: #ebebea; --muted: #b4b4b2; --subtle: #8b8b89;
--accent: #e3e3e2; --accent-strong: #ffffff; --accent-ink: #202020; --danger: #ff7369; --warning: #cfa95f; --success: #6baa85;
--field: #191919; --selected: #353535; --hover: #2b2b2b; --shadow: rgba(0, 0, 0, .38);
--folder-bg: #202020; --message-bg: #202020; --reader-bg: #202020;
}
+7 -1
View File
@@ -623,7 +623,13 @@
"Estimated final message size": "Szacowany końcowy rozmiar wiadomości",
"Estimated size. The exact RFC822/MIME size is checked by the server before SMTP delivery.": "Rozmiar szacowany. Dokładny rozmiar RFC822/MIME jest sprawdzany przez serwer przed wysłaniem przez SMTP.",
"Estimated message size exceeds the configured limit": "Szacowany rozmiar wiadomości przekracza skonfigurowany limit",
"The exact size will be checked before sending.": "Dokładny rozmiar zostanie sprawdzony przed wysłaniem."
"The exact size will be checked before sending.": "Dokładny rozmiar zostanie sprawdzony przed wysłaniem.",
"GitHub inspired": "Inspirowany GitHub",
"GitLab inspired": "Inspirowany GitLab",
"Discord inspired": "Inspirowany Discord",
"Slack inspired": "Inspirowany Slack",
"Reddit inspired": "Inspirowany Reddit",
"Notion inspired": "Inspirowany Notion"
},
"fixed_interface_style_note": "stały styl interfejsu. Motyw nadal przełącza jasny/ciemny; wybierz RustBox (niestandardowy), aby edytować kolory, odstępy i geometrię.",
"custom_interface_style_note": "RustBox można w pełni dostosować: kolory, odstępy, panele, typografię i zaawansowane ustawienia interfejsu.",
+6
View File
@@ -606,6 +606,12 @@
<option value="yahoo">Yahoo Mail inspired</option>
<option value="hey">HEY inspired</option>
<option value="nord">Nord</option>
<option value="github">GitHub inspired</option>
<option value="gitlab">GitLab inspired</option>
<option value="discord">Discord inspired</option>
<option value="slack">Slack inspired</option>
<option value="reddit">Reddit inspired</option>
<option value="notion">Notion inspired</option>
</select><small id="pref-interface-skin-note" class="muted">RustBox is fully customizable: colors,
spacing, panes and typography.</small></label>
<label id="pref-preset-scale-unlocked-row" class="checkbox-row hidden"><input
+24
View File
@@ -82,6 +82,30 @@ const interfaceStyleProfiles = {
label: "High contrast",
preferences: { density: "comfortable", preview: "right", scale: "normal", fontFamily: "system", fontSize: 14, rowHeight: 60, folderPaneWidth: 240, messagePaneWidth: 440, borderRadius: 2, contrast: "high", iconSize: 20, toolbarDensity: "normal", sidebarItemHeight: 34, readerMaxWidth: 980, composerWidth: 960, selectionColor: "#ffe500", unreadFontWeight: 800, unreadBadgeStyle: "solid", compactHeaders: false, showBorders: true, scrollbarDensity: "normal", monochrome: false },
},
github: {
label: "GitHub inspired",
preferences: { density: "compact", preview: "right", scale: "normal", fontFamily: "system", fontSize: 13, rowHeight: 52, folderPaneWidth: 236, messagePaneWidth: 430, borderRadius: 6, contrast: "normal", iconSize: 18, toolbarDensity: "normal", sidebarItemHeight: 30, readerMaxWidth: 980, composerWidth: 940, selectionColor: "#ddf4ff", unreadFontWeight: 720, unreadBadgeStyle: "dot", compactHeaders: false, showBorders: true, scrollbarDensity: "thin", monochrome: false },
},
gitlab: {
label: "GitLab inspired",
preferences: { density: "compact", preview: "right", scale: "normal", fontFamily: "system", fontSize: 13, rowHeight: 52, folderPaneWidth: 238, messagePaneWidth: 430, borderRadius: 7, contrast: "normal", iconSize: 18, toolbarDensity: "normal", sidebarItemHeight: 30, readerMaxWidth: 980, composerWidth: 940, selectionColor: "#eee7ff", unreadFontWeight: 720, unreadBadgeStyle: "dot", compactHeaders: false, showBorders: true, scrollbarDensity: "thin", monochrome: false },
},
discord: {
label: "Discord inspired",
preferences: { density: "comfortable", preview: "right", scale: "normal", fontFamily: "system", fontSize: 13, rowHeight: 56, folderPaneWidth: 246, messagePaneWidth: 440, borderRadius: 8, contrast: "normal", iconSize: 19, toolbarDensity: "normal", sidebarItemHeight: 34, readerMaxWidth: 980, composerWidth: 950, selectionColor: "#e0e3ff", unreadFontWeight: 720, unreadBadgeStyle: "solid", compactHeaders: false, showBorders: false, scrollbarDensity: "thin", monochrome: false },
},
slack: {
label: "Slack inspired",
preferences: { density: "comfortable", preview: "right", scale: "normal", fontFamily: "system", fontSize: 13, rowHeight: 56, folderPaneWidth: 244, messagePaneWidth: 435, borderRadius: 8, contrast: "normal", iconSize: 18, toolbarDensity: "normal", sidebarItemHeight: 33, readerMaxWidth: 960, composerWidth: 950, selectionColor: "#f3e6f5", unreadFontWeight: 720, unreadBadgeStyle: "dot", compactHeaders: false, showBorders: false, scrollbarDensity: "thin", monochrome: false },
},
reddit: {
label: "Reddit inspired",
preferences: { density: "compact", preview: "right", scale: "normal", fontFamily: "system", fontSize: 13, rowHeight: 52, folderPaneWidth: 236, messagePaneWidth: 430, borderRadius: 9, contrast: "normal", iconSize: 18, toolbarDensity: "normal", sidebarItemHeight: 31, readerMaxWidth: 980, composerWidth: 940, selectionColor: "#fff0e8", unreadFontWeight: 720, unreadBadgeStyle: "solid", compactHeaders: false, showBorders: true, scrollbarDensity: "thin", monochrome: false },
},
notion: {
label: "Notion inspired",
preferences: { density: "comfortable", preview: "right", scale: "normal", fontFamily: "system", fontSize: 14, rowHeight: 58, folderPaneWidth: 238, messagePaneWidth: 430, borderRadius: 5, contrast: "soft", iconSize: 18, toolbarDensity: "normal", sidebarItemHeight: 32, readerMaxWidth: 900, composerWidth: 920, selectionColor: "#efefef", unreadFontWeight: 700, unreadBadgeStyle: "outline", compactHeaders: false, showBorders: false, scrollbarDensity: "thin", monochrome: false },
},
};
function resolvedInterfaceSkin(prefs = state.prefs) {
+1 -1
View File
@@ -562,7 +562,7 @@ function renderMessageBody(detail, forceRemote = false) {
if (hasRemote && !allowRemote && (policy === "ask" || policy === "contacts")) remoteBar.classList.remove("hidden");
const iframe = document.createElement("iframe");
iframe.setAttribute("sandbox", "allow-popups allow-popups-to-escape-sandbox");
iframe.setAttribute("sandbox", "allow-popups allow-popups-to-escape-sandbox allow-downloads");
iframe.setAttribute("referrerpolicy", "no-referrer");
const imageSources = allowRemote ? "data: cid: https: http:" : "data: cid:";
const csp = `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src ${imageSources}; style-src 'unsafe-inline'; font-src data:">`;