1 parent
6e74b33f03
commit
3b9110c8c8
44 files changed
+3057
-128
No files matched your search
@@ -16,6 +16,7 @@ pub mod pool;
|
||||
pub mod response_templates;
|
||||
pub mod search_outbox;
|
||||
pub mod settings;
|
||||
pub mod snooze;
|
||||
pub mod special_folders;
|
||||
pub mod tags;
|
||||
pub mod trusted_senders;
|
||||
|
||||
+29
-14
@@ -21,20 +21,33 @@ const SQLITE_SCHEMA: &str = include_str!("../../migrations/sqlite/schema.sql");
|
||||
const MYSQL_SCHEMA: &str = include_str!("../../migrations/mysql/schema.sql");
|
||||
const PGSQL_SCHEMA: &str = include_str!("../../migrations/pgsql/schema.sql");
|
||||
|
||||
const SQLITE_MIGRATIONS: &[(u32, &str)] = &[(
|
||||
51,
|
||||
include_str!(
|
||||
"../../migrations/sqlite/V051__adaptive_email_colors_and_message_loading_mode.sql"
|
||||
const SQLITE_MIGRATIONS: &[(u32, &str)] = &[
|
||||
(
|
||||
51,
|
||||
include_str!(
|
||||
"../../migrations/sqlite/V051__adaptive_email_colors_and_message_loading_mode.sql"
|
||||
),
|
||||
),
|
||||
)];
|
||||
const MYSQL_MIGRATIONS: &[(u32, &str)] = &[(
|
||||
51,
|
||||
include_str!("../../migrations/mysql/V051__adaptive_email_colors_and_message_loading_mode.sql"),
|
||||
)];
|
||||
const PGSQL_MIGRATIONS: &[(u32, &str)] = &[(
|
||||
51,
|
||||
include_str!("../../migrations/pgsql/V051__adaptive_email_colors_and_message_loading_mode.sql"),
|
||||
)];
|
||||
(52, include_str!("../../migrations/sqlite/V052__snooze.sql")),
|
||||
];
|
||||
const MYSQL_MIGRATIONS: &[(u32, &str)] = &[
|
||||
(
|
||||
51,
|
||||
include_str!(
|
||||
"../../migrations/mysql/V051__adaptive_email_colors_and_message_loading_mode.sql"
|
||||
),
|
||||
),
|
||||
(52, include_str!("../../migrations/mysql/V052__snooze.sql")),
|
||||
];
|
||||
const PGSQL_MIGRATIONS: &[(u32, &str)] = &[
|
||||
(
|
||||
51,
|
||||
include_str!(
|
||||
"../../migrations/pgsql/V051__adaptive_email_colors_and_message_loading_mode.sql"
|
||||
),
|
||||
),
|
||||
(52, include_str!("../../migrations/pgsql/V052__snooze.sql")),
|
||||
];
|
||||
|
||||
const TENANT_TABLES: &[&str] = &[
|
||||
"blob_delete_outbox",
|
||||
@@ -61,6 +74,7 @@ const TENANT_TABLES: &[&str] = &[
|
||||
"response_templates",
|
||||
"saved_searches",
|
||||
"search_outbox",
|
||||
"snoozed_messages",
|
||||
"special_folders",
|
||||
"tags",
|
||||
"trusted_senders",
|
||||
@@ -87,6 +101,7 @@ const IMPORT_TABLES: &[&str] = &[
|
||||
"messages",
|
||||
"tags",
|
||||
"message_tags",
|
||||
"snoozed_messages",
|
||||
"notification_preferences",
|
||||
"pgp_keys",
|
||||
"pgp_preferences",
|
||||
@@ -2648,7 +2663,7 @@ mod tests {
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(version, 51);
|
||||
assert_eq!(version, 52);
|
||||
|
||||
let adaptive: i64 = conn
|
||||
.query_row(
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
//! Snoozing a message hides it from its folder listing until a chosen wake
|
||||
//! time. This is purely local cache state: nothing is moved on the real IMAP
|
||||
//! server, so snoozing never risks the actual mailbox. The client is
|
||||
//! responsible for excluding a still-sleeping message from the folder it
|
||||
//! renders and for showing the "Snoozed" view built from [`list_snoozed`].
|
||||
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use crate::db::pool::{Connection, params};
|
||||
|
||||
pub fn now_epoch() -> i64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs()
|
||||
.min(i64::MAX as u64) as i64
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct SnoozedMessage {
|
||||
pub id: String,
|
||||
pub message_uid: u32,
|
||||
pub message_folder: String,
|
||||
pub wake_at: i64,
|
||||
pub subject: String,
|
||||
pub from_name: String,
|
||||
pub from_address: String,
|
||||
pub snippet: String,
|
||||
}
|
||||
|
||||
/// Snoozes `(folder, uid)` until `wake_at` (epoch seconds), replacing any
|
||||
/// existing snooze for the same message. Returns the new snooze id.
|
||||
pub fn snooze_message(
|
||||
conn: &Connection,
|
||||
folder: &str,
|
||||
uid: u32,
|
||||
wake_at: i64,
|
||||
) -> Result<String, String> {
|
||||
if folder.trim().is_empty() {
|
||||
return Err("folder must not be empty".to_string());
|
||||
}
|
||||
if wake_at <= now_epoch() {
|
||||
return Err("snooze wake time must be in the future".to_string());
|
||||
}
|
||||
conn.execute(
|
||||
crate::dbq!(db_snoozed_messages::Q_002),
|
||||
params![uid, folder],
|
||||
)
|
||||
.map_err(|e| format!("failed to clear existing snooze: {e}"))?;
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
conn.execute(
|
||||
crate::dbq!(db_snoozed_messages::Q_001),
|
||||
params![&id, uid, folder, wake_at],
|
||||
)
|
||||
.map_err(|e| format!("failed to snooze message: {e}"))?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Removes any snooze on `(folder, uid)`. Returns whether a row was removed.
|
||||
pub fn unsnooze_message(conn: &Connection, folder: &str, uid: u32) -> Result<bool, String> {
|
||||
let changed = conn
|
||||
.execute(
|
||||
crate::dbq!(db_snoozed_messages::Q_002),
|
||||
params![uid, folder],
|
||||
)
|
||||
.map_err(|e| format!("failed to unsnooze message: {e}"))?;
|
||||
Ok(changed > 0)
|
||||
}
|
||||
|
||||
/// Every snoozed message (woken or not), joined with the cached message
|
||||
/// metadata needed to render the "Snoozed" view, ordered by wake time.
|
||||
pub fn list_snoozed(conn: &Connection) -> Result<Vec<SnoozedMessage>, String> {
|
||||
let mut stmt = conn
|
||||
.prepare(crate::dbq!(db_snoozed_messages::Q_003))
|
||||
.map_err(|e| format!("failed to prepare snoozed-messages query: {e}"))?;
|
||||
let rows = stmt
|
||||
.query_map([], |row| {
|
||||
Ok(SnoozedMessage {
|
||||
id: row.get(0)?,
|
||||
message_uid: row.get::<_, i64>(1)?.max(0) as u32,
|
||||
message_folder: row.get(2)?,
|
||||
wake_at: row.get(3)?,
|
||||
subject: row.get::<_, Option<String>>(4)?.unwrap_or_default(),
|
||||
from_name: row.get::<_, Option<String>>(5)?.unwrap_or_default(),
|
||||
from_address: row.get::<_, Option<String>>(6)?.unwrap_or_default(),
|
||||
snippet: row.get::<_, Option<String>>(7)?.unwrap_or_default(),
|
||||
})
|
||||
})
|
||||
.map_err(|e| format!("failed to query snoozed messages: {e}"))?;
|
||||
rows.map(|row| row.map_err(|e| format!("failed to read snoozed message row: {e}")))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The UIDs in `folder` that are still asleep as of `now` (i.e. due to be
|
||||
/// hidden from that folder's listing right now). Cheap enough for the client
|
||||
/// to call once per folder open; no IMAP or background work is involved.
|
||||
pub fn list_sleeping_uids_in_folder(
|
||||
conn: &Connection,
|
||||
folder: &str,
|
||||
now: i64,
|
||||
) -> Result<Vec<u32>, String> {
|
||||
let mut stmt = conn
|
||||
.prepare(crate::dbq!(db_snoozed_messages::Q_004))
|
||||
.map_err(|e| format!("failed to prepare sleeping-uids query: {e}"))?;
|
||||
let rows = stmt
|
||||
.query_map(params![folder, now], |row| {
|
||||
Ok(row.get::<_, i64>(0)?.max(0) as u32)
|
||||
})
|
||||
.map_err(|e| format!("failed to query sleeping uids: {e}"))?;
|
||||
rows.map(|row| row.map_err(|e| format!("failed to read sleeping uid row: {e}")))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::pool::open_test_db;
|
||||
|
||||
fn insert_cached_message(conn: &Connection, folder: &str, uid: u32, subject: &str) {
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO folders (name) VALUES (?1)",
|
||||
params![folder],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO messages (uid, folder, subject, date, size) VALUES (?1, ?2, ?3, '', 0)",
|
||||
params![uid, folder, subject],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snoozing_rejects_a_wake_time_in_the_past() {
|
||||
let conn = open_test_db();
|
||||
let error = snooze_message(&conn, "INBOX", 1, now_epoch() - 10).unwrap_err();
|
||||
assert!(error.contains("future"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn re_snoozing_the_same_message_replaces_the_wake_time() {
|
||||
let conn = open_test_db();
|
||||
let future = now_epoch() + 3600;
|
||||
let later = now_epoch() + 7200;
|
||||
snooze_message(&conn, "INBOX", 42, future).unwrap();
|
||||
snooze_message(&conn, "INBOX", 42, later).unwrap();
|
||||
let snoozed = list_snoozed(&conn).unwrap();
|
||||
assert_eq!(snoozed.len(), 1);
|
||||
assert_eq!(snoozed[0].wake_at, later);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsnoozing_removes_the_entry() {
|
||||
let conn = open_test_db();
|
||||
snooze_message(&conn, "INBOX", 7, now_epoch() + 60).unwrap();
|
||||
assert!(unsnooze_message(&conn, "INBOX", 7).unwrap());
|
||||
assert!(list_snoozed(&conn).unwrap().is_empty());
|
||||
assert!(!unsnooze_message(&conn, "INBOX", 7).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_snoozed_carries_cached_message_metadata() {
|
||||
let conn = open_test_db();
|
||||
insert_cached_message(&conn, "INBOX", 11, "Quarterly report");
|
||||
snooze_message(&conn, "INBOX", 11, now_epoch() + 60).unwrap();
|
||||
let snoozed = list_snoozed(&conn).unwrap();
|
||||
assert_eq!(snoozed.len(), 1);
|
||||
assert_eq!(snoozed[0].subject, "Quarterly report");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sleeping_uids_exclude_already_woken_snoozes() {
|
||||
let conn = open_test_db();
|
||||
let now = now_epoch();
|
||||
snooze_message(&conn, "INBOX", 1, now + 60).unwrap();
|
||||
snooze_message(&conn, "INBOX", 2, now + 120).unwrap();
|
||||
let sleeping = list_sleeping_uids_in_folder(&conn, "INBOX", now).unwrap();
|
||||
assert_eq!(sleeping.len(), 2);
|
||||
let sleeping_later = list_sleeping_uids_in_folder(&conn, "INBOX", now + 90).unwrap();
|
||||
assert_eq!(sleeping_later, vec![2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sleeping_uids_are_scoped_to_their_folder() {
|
||||
let conn = open_test_db();
|
||||
let now = now_epoch();
|
||||
snooze_message(&conn, "INBOX", 1, now + 60).unwrap();
|
||||
snooze_message(&conn, "Archive", 2, now + 60).unwrap();
|
||||
let sleeping = list_sleeping_uids_in_folder(&conn, "INBOX", now).unwrap();
|
||||
assert_eq!(sleeping, vec![1]);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user