1 parent
0421994fe6
commit
425ea7449b
138 files changed
+5705
-1012
No files matched your search
+71
-34
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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
@@ -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
@@ -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(¤t.remote_images);
|
||||
let preferred_body = data.preferred_body.as_deref().unwrap_or(¤t.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(¤t.remote_images);
|
||||
let preferred_body = data
|
||||
.preferred_body
|
||||
.as_deref()
|
||||
.unwrap_or(¤t.preferred_body);
|
||||
let thread_mode = data.thread_mode.as_deref().unwrap_or(¤t.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(¤t.default_sort);
|
||||
let default_sort = data
|
||||
.default_sort
|
||||
.as_deref()
|
||||
.unwrap_or(¤t.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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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]
|
||||
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
pub mod queries;
|
||||
@@ -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 < ?";
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod queries;
|
||||
@@ -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";
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod queries;
|
||||
@@ -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 < ?";
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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(
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}")))?;
|
||||
|
||||
@@ -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
@@ -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
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
|
||||
|
||||
@@ -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}"))
|
||||
}
|
||||
}
|
||||
@@ -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}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}")),
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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:">`;
|
||||
|
||||
Reference in new issue
Block a user