commit
bc5fbd6ba9
180 files changed
+63750
No files matched your search
@@ -0,0 +1,800 @@
|
||||
use rusqlite::{Connection, params};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Structs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A calendar event record, mirroring the `calendar_events` table.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct CalendarEvent {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub location: String,
|
||||
pub start_time: String,
|
||||
pub end_time: String,
|
||||
pub all_day: bool,
|
||||
pub recurrence_rule: Option<String>,
|
||||
pub attendees: String,
|
||||
pub organizer: String,
|
||||
pub status: String,
|
||||
pub source: String,
|
||||
pub source_uid: Option<String>,
|
||||
pub source_message_uid: Option<i64>,
|
||||
pub source_message_folder: Option<String>,
|
||||
pub meeting_url: Option<String>,
|
||||
pub color: Option<String>,
|
||||
pub reminder_minutes: Option<i64>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
/// Fields for creating a new calendar event.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateEvent {
|
||||
pub title: String,
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
#[serde(default)]
|
||||
pub location: String,
|
||||
pub start_time: String,
|
||||
pub end_time: String,
|
||||
#[serde(default)]
|
||||
pub all_day: bool,
|
||||
pub recurrence_rule: Option<String>,
|
||||
#[serde(default = "default_attendees")]
|
||||
pub attendees: String,
|
||||
#[serde(default)]
|
||||
pub organizer: String,
|
||||
#[serde(default = "default_status")]
|
||||
pub status: String,
|
||||
#[serde(default = "default_source")]
|
||||
pub source: String,
|
||||
pub source_uid: Option<String>,
|
||||
pub source_message_uid: Option<i64>,
|
||||
pub source_message_folder: Option<String>,
|
||||
pub meeting_url: Option<String>,
|
||||
pub color: Option<String>,
|
||||
pub reminder_minutes: Option<i64>,
|
||||
}
|
||||
|
||||
fn default_attendees() -> String {
|
||||
"[]".to_string()
|
||||
}
|
||||
|
||||
fn default_status() -> String {
|
||||
"confirmed".to_string()
|
||||
}
|
||||
|
||||
fn default_source() -> String {
|
||||
"manual".to_string()
|
||||
}
|
||||
|
||||
/// Fields for updating an existing calendar event. All optional.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateEvent {
|
||||
pub title: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub location: Option<String>,
|
||||
pub start_time: Option<String>,
|
||||
pub end_time: Option<String>,
|
||||
pub all_day: Option<bool>,
|
||||
pub recurrence_rule: Option<String>,
|
||||
pub attendees: Option<String>,
|
||||
pub organizer: Option<String>,
|
||||
pub status: Option<String>,
|
||||
pub meeting_url: Option<String>,
|
||||
pub color: Option<String>,
|
||||
pub reminder_minutes: Option<i64>,
|
||||
}
|
||||
|
||||
/// Calendar settings record, mirroring the `calendar_settings` table.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct CalendarSettings {
|
||||
pub default_view: String,
|
||||
pub week_starts_on: i64,
|
||||
pub working_hours_start: String,
|
||||
pub working_hours_end: String,
|
||||
pub time_format: String,
|
||||
pub zoom_link: String,
|
||||
pub teams_link: String,
|
||||
pub meet_link: String,
|
||||
pub caldav_url: String,
|
||||
pub caldav_username: String,
|
||||
pub caldav_enabled: bool,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
/// Fields for updating calendar settings. All optional.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateCalendarSettings {
|
||||
pub default_view: Option<String>,
|
||||
pub week_starts_on: Option<i64>,
|
||||
pub working_hours_start: Option<String>,
|
||||
pub working_hours_end: Option<String>,
|
||||
pub time_format: Option<String>,
|
||||
pub zoom_link: Option<String>,
|
||||
pub teams_link: Option<String>,
|
||||
pub meet_link: Option<String>,
|
||||
pub caldav_url: Option<String>,
|
||||
pub caldav_username: Option<String>,
|
||||
pub caldav_password: Option<String>,
|
||||
pub caldav_enabled: Option<bool>,
|
||||
}
|
||||
|
||||
/// A meeting template record.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MeetingTemplate {
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
pub url_template: String,
|
||||
pub icon: String,
|
||||
pub is_default: bool,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Row mappers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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 {
|
||||
id: row.get(0)?,
|
||||
title: row.get(1)?,
|
||||
description: row.get(2)?,
|
||||
location: row.get(3)?,
|
||||
start_time: row.get(4)?,
|
||||
end_time: row.get(5)?,
|
||||
all_day: all_day_int != 0,
|
||||
recurrence_rule: row.get(7)?,
|
||||
attendees: row.get(8)?,
|
||||
organizer: row.get(9)?,
|
||||
status: row.get(10)?,
|
||||
source: row.get(11)?,
|
||||
source_uid: row.get(12)?,
|
||||
source_message_uid: row.get(13)?,
|
||||
source_message_folder: row.get(14)?,
|
||||
meeting_url: row.get(15)?,
|
||||
color: row.get(16)?,
|
||||
reminder_minutes: row.get(17)?,
|
||||
created_at: row.get(18)?,
|
||||
updated_at: row.get(19)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn row_to_settings(row: &rusqlite::Row<'_>) -> rusqlite::Result<CalendarSettings> {
|
||||
let caldav_enabled_int: i32 = row.get(10)?;
|
||||
Ok(CalendarSettings {
|
||||
default_view: row.get(0)?,
|
||||
week_starts_on: row.get(1)?,
|
||||
working_hours_start: row.get(2)?,
|
||||
working_hours_end: row.get(3)?,
|
||||
time_format: row.get(4)?,
|
||||
zoom_link: row.get(5)?,
|
||||
teams_link: row.get(6)?,
|
||||
meet_link: row.get(7)?,
|
||||
caldav_url: row.get(8)?,
|
||||
caldav_username: row.get(9)?,
|
||||
caldav_enabled: caldav_enabled_int != 0,
|
||||
updated_at: row.get(11)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn row_to_template(row: &rusqlite::Row<'_>) -> rusqlite::Result<MeetingTemplate> {
|
||||
let is_default_int: i32 = row.get(4)?;
|
||||
Ok(MeetingTemplate {
|
||||
id: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
url_template: row.get(2)?,
|
||||
icon: row.get(3)?,
|
||||
is_default: is_default_int != 0,
|
||||
created_at: row.get(5)?,
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API — Events
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// List events in the given time range (inclusive start, inclusive end).
|
||||
pub fn list_events(
|
||||
conn: &Connection,
|
||||
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 mut stmt = conn
|
||||
.prepare(&sql)
|
||||
.map_err(|e| format!("Failed to prepare list_events: {e}"))?;
|
||||
let rows = stmt
|
||||
.query_map(params![start, end], row_to_event)
|
||||
.map_err(|e| format!("Failed to query events: {e}"))?;
|
||||
|
||||
let mut events = Vec::new();
|
||||
for row in rows {
|
||||
events.push(row.map_err(|e| format!("Failed to read event row: {e}"))?);
|
||||
}
|
||||
Ok(events)
|
||||
}
|
||||
|
||||
/// List all calendar events ordered by start time.
|
||||
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 mut stmt = conn
|
||||
.prepare(&sql)
|
||||
.map_err(|e| format!("Failed to prepare list_all_events: {e}"))?;
|
||||
let rows = stmt
|
||||
.query_map([], row_to_event)
|
||||
.map_err(|e| format!("Failed to query all calendar events: {e}"))?;
|
||||
|
||||
let mut events = Vec::new();
|
||||
for row in rows {
|
||||
events.push(row.map_err(|e| format!("Failed to read calendar event: {e}"))?);
|
||||
}
|
||||
Ok(events)
|
||||
}
|
||||
|
||||
/// Get a single event by ID.
|
||||
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 result = conn.query_row(&sql, params![id], row_to_event);
|
||||
|
||||
match result {
|
||||
Ok(e) => Ok(Some(e)),
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||||
Err(e) => Err(format!("Failed to get event: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new calendar event. Generates a UUID for the ID.
|
||||
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))
|
||||
.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)",
|
||||
params![
|
||||
id,
|
||||
data.title,
|
||||
data.description,
|
||||
data.location,
|
||||
data.start_time,
|
||||
data.end_time,
|
||||
data.all_day as i32,
|
||||
data.recurrence_rule,
|
||||
data.attendees,
|
||||
data.organizer,
|
||||
data.status,
|
||||
data.source,
|
||||
data.source_uid,
|
||||
data.source_message_uid,
|
||||
data.source_message_folder,
|
||||
data.meeting_url,
|
||||
data.color,
|
||||
data.reminder_minutes,
|
||||
now,
|
||||
now,
|
||||
],
|
||||
)
|
||||
.map_err(|e| format!("Failed to create event: {e}"))?;
|
||||
|
||||
get_event(conn, &id)?.ok_or_else(|| "Event not found after insert".to_string())
|
||||
}
|
||||
|
||||
/// Update an existing calendar event. Returns the updated event, or None if not found.
|
||||
pub fn update_event(
|
||||
conn: &Connection,
|
||||
id: &str,
|
||||
data: &UpdateEvent,
|
||||
) -> Result<Option<CalendarEvent>, String> {
|
||||
// Build SET clauses dynamically for non-None fields.
|
||||
let mut sets = Vec::new();
|
||||
let mut values: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
|
||||
let mut idx = 1u32;
|
||||
|
||||
macro_rules! maybe_set {
|
||||
($field:ident, $col:expr) => {
|
||||
if let Some(ref v) = data.$field {
|
||||
sets.push(format!("{} = ?{idx}", $col));
|
||||
values.push(Box::new(v.clone()));
|
||||
idx += 1;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
maybe_set!(title, "title");
|
||||
maybe_set!(description, "description");
|
||||
maybe_set!(location, "location");
|
||||
maybe_set!(start_time, "start_time");
|
||||
maybe_set!(end_time, "end_time");
|
||||
maybe_set!(attendees, "attendees");
|
||||
maybe_set!(organizer, "organizer");
|
||||
maybe_set!(status, "status");
|
||||
maybe_set!(meeting_url, "meeting_url");
|
||||
maybe_set!(color, "color");
|
||||
maybe_set!(recurrence_rule, "recurrence_rule");
|
||||
|
||||
if let Some(v) = data.all_day {
|
||||
sets.push(format!("all_day = ?{idx}"));
|
||||
values.push(Box::new(v as i32));
|
||||
idx += 1;
|
||||
}
|
||||
|
||||
if let Some(v) = data.reminder_minutes {
|
||||
sets.push(format!("reminder_minutes = ?{idx}"));
|
||||
values.push(Box::new(v));
|
||||
idx += 1;
|
||||
}
|
||||
|
||||
if sets.is_empty() {
|
||||
return get_event(conn, id);
|
||||
}
|
||||
|
||||
sets.push("updated_at = datetime('now')".to_string());
|
||||
|
||||
let sql = format!(
|
||||
"UPDATE calendar_events SET {} WHERE id = ?{idx}",
|
||||
sets.join(", ")
|
||||
);
|
||||
values.push(Box::new(id.to_string()));
|
||||
|
||||
let params_refs: Vec<&dyn rusqlite::types::ToSql> = values.iter().map(|b| b.as_ref()).collect();
|
||||
let updated = conn
|
||||
.execute(&sql, params_refs.as_slice())
|
||||
.map_err(|e| format!("Failed to update event: {e}"))?;
|
||||
|
||||
if updated == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
get_event(conn, id)
|
||||
}
|
||||
|
||||
/// Delete an event by ID. Returns true if a row was deleted.
|
||||
pub fn delete_event(conn: &Connection, id: &str) -> Result<bool, String> {
|
||||
let deleted = conn
|
||||
.execute("DELETE FROM calendar_events WHERE id = ?1", params![id])
|
||||
.map_err(|e| format!("Failed to delete event: {e}"))?;
|
||||
Ok(deleted > 0)
|
||||
}
|
||||
|
||||
/// Find an event by its source_uid (for deduplication during ICS import).
|
||||
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 result = conn.query_row(&sql, params![source_uid], row_to_event);
|
||||
|
||||
match result {
|
||||
Ok(e) => Ok(Some(e)),
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||||
Err(e) => Err(format!("Failed to find event by source_uid: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API — Settings
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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";
|
||||
|
||||
/// Get calendar settings, inserting a default row if none exists.
|
||||
pub fn get_calendar_settings(conn: &Connection) -> Result<CalendarSettings, String> {
|
||||
// Ensure the singleton row exists.
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO calendar_settings (id) VALUES (1)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| format!("Failed to ensure calendar_settings row: {e}"))?;
|
||||
|
||||
let sql = format!("SELECT {SETTINGS_SELECT_COLS} FROM calendar_settings WHERE id = 1");
|
||||
conn.query_row(&sql, [], row_to_settings)
|
||||
.map_err(|e| format!("Failed to get calendar settings: {e}"))
|
||||
}
|
||||
|
||||
/// Update calendar settings. Returns the updated settings.
|
||||
pub fn update_calendar_settings(
|
||||
conn: &Connection,
|
||||
data: &UpdateCalendarSettings,
|
||||
) -> Result<CalendarSettings, String> {
|
||||
// Ensure the singleton row exists.
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO calendar_settings (id) VALUES (1)",
|
||||
[],
|
||||
)
|
||||
.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();
|
||||
let mut idx = 1u32;
|
||||
|
||||
macro_rules! maybe_set {
|
||||
($field:ident, $col:expr) => {
|
||||
if let Some(ref v) = data.$field {
|
||||
sets.push(format!("{} = ?{idx}", $col));
|
||||
values.push(Box::new(v.clone()));
|
||||
idx += 1;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
maybe_set!(default_view, "default_view");
|
||||
maybe_set!(working_hours_start, "working_hours_start");
|
||||
maybe_set!(working_hours_end, "working_hours_end");
|
||||
maybe_set!(time_format, "time_format");
|
||||
maybe_set!(zoom_link, "zoom_link");
|
||||
maybe_set!(teams_link, "teams_link");
|
||||
maybe_set!(meet_link, "meet_link");
|
||||
maybe_set!(caldav_url, "caldav_url");
|
||||
maybe_set!(caldav_username, "caldav_username");
|
||||
maybe_set!(caldav_password, "caldav_password");
|
||||
|
||||
if let Some(v) = data.week_starts_on {
|
||||
sets.push(format!("week_starts_on = ?{idx}"));
|
||||
values.push(Box::new(v));
|
||||
idx += 1;
|
||||
}
|
||||
|
||||
if let Some(v) = data.caldav_enabled {
|
||||
sets.push(format!("caldav_enabled = ?{idx}"));
|
||||
values.push(Box::new(v as i32));
|
||||
#[allow(unused_assignments)]
|
||||
{
|
||||
idx += 1;
|
||||
}
|
||||
}
|
||||
|
||||
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 params_refs: Vec<&dyn rusqlite::types::ToSql> =
|
||||
values.iter().map(|b| b.as_ref()).collect();
|
||||
conn.execute(&sql, params_refs.as_slice())
|
||||
.map_err(|e| format!("Failed to update calendar settings: {e}"))?;
|
||||
}
|
||||
|
||||
get_calendar_settings(conn)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API — Meeting Templates
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// List all meeting templates.
|
||||
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",
|
||||
)
|
||||
.map_err(|e| format!("Failed to prepare list_meeting_templates: {e}"))?;
|
||||
let rows = stmt
|
||||
.query_map([], row_to_template)
|
||||
.map_err(|e| format!("Failed to query meeting templates: {e}"))?;
|
||||
|
||||
let mut templates = Vec::new();
|
||||
for row in rows {
|
||||
templates.push(row.map_err(|e| format!("Failed to read template row: {e}"))?);
|
||||
}
|
||||
Ok(templates)
|
||||
}
|
||||
|
||||
/// Create a new meeting template.
|
||||
pub fn create_meeting_template(
|
||||
conn: &Connection,
|
||||
name: &str,
|
||||
url_template: &str,
|
||||
icon: &str,
|
||||
) -> Result<MeetingTemplate, String> {
|
||||
conn.execute(
|
||||
"INSERT INTO meeting_templates (name, url_template, icon) VALUES (?1, ?2, ?3)",
|
||||
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";
|
||||
conn.query_row(sql, params![id], row_to_template)
|
||||
.map_err(|e| format!("Failed to read created template: {e}"))
|
||||
}
|
||||
|
||||
/// Delete a meeting template by ID. Returns true if a row was deleted.
|
||||
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])
|
||||
.map_err(|e| format!("Failed to delete meeting template: {e}"))?;
|
||||
Ok(deleted > 0)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::pool::open_test_db;
|
||||
|
||||
#[test]
|
||||
fn test_create_and_get_event() {
|
||||
let conn = open_test_db();
|
||||
let data = CreateEvent {
|
||||
title: "Team Standup".to_string(),
|
||||
description: "Daily standup meeting".to_string(),
|
||||
location: "Room A".to_string(),
|
||||
start_time: "2025-03-06T09:00:00Z".to_string(),
|
||||
end_time: "2025-03-06T09:30:00Z".to_string(),
|
||||
all_day: false,
|
||||
recurrence_rule: None,
|
||||
attendees: "[]".to_string(),
|
||||
organizer: "alice@example.com".to_string(),
|
||||
status: "confirmed".to_string(),
|
||||
source: "manual".to_string(),
|
||||
source_uid: None,
|
||||
source_message_uid: None,
|
||||
source_message_folder: None,
|
||||
meeting_url: Some("https://zoom.us/j/123".to_string()),
|
||||
color: Some("blue".to_string()),
|
||||
reminder_minutes: Some(15),
|
||||
};
|
||||
|
||||
let event = create_event(&conn, &data).unwrap();
|
||||
assert_eq!(event.title, "Team Standup");
|
||||
assert_eq!(event.location, "Room A");
|
||||
assert!(!event.all_day);
|
||||
|
||||
let fetched = get_event(&conn, &event.id).unwrap();
|
||||
assert!(fetched.is_some());
|
||||
assert_eq!(fetched.unwrap().title, "Team Standup");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_events_range() {
|
||||
let conn = open_test_db();
|
||||
|
||||
let early = CreateEvent {
|
||||
title: "Early".to_string(),
|
||||
description: String::new(),
|
||||
location: String::new(),
|
||||
start_time: "2025-03-01T09:00:00Z".to_string(),
|
||||
end_time: "2025-03-01T10:00:00Z".to_string(),
|
||||
all_day: false,
|
||||
recurrence_rule: None,
|
||||
attendees: "[]".to_string(),
|
||||
organizer: String::new(),
|
||||
status: "confirmed".to_string(),
|
||||
source: "manual".to_string(),
|
||||
source_uid: None,
|
||||
source_message_uid: None,
|
||||
source_message_folder: None,
|
||||
meeting_url: None,
|
||||
color: None,
|
||||
reminder_minutes: None,
|
||||
};
|
||||
|
||||
let late = CreateEvent {
|
||||
title: "Late".to_string(),
|
||||
start_time: "2025-04-01T09:00:00Z".to_string(),
|
||||
end_time: "2025-04-01T10:00:00Z".to_string(),
|
||||
..early.clone_fields()
|
||||
};
|
||||
|
||||
create_event(&conn, &early).unwrap();
|
||||
create_event(&conn, &late).unwrap();
|
||||
|
||||
let march = list_events(&conn, "2025-03-01T00:00:00Z", "2025-03-31T23:59:59Z").unwrap();
|
||||
assert_eq!(march.len(), 1);
|
||||
assert_eq!(march[0].title, "Early");
|
||||
|
||||
let all = list_events(&conn, "2025-01-01T00:00:00Z", "2025-12-31T23:59:59Z").unwrap();
|
||||
assert_eq!(all.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_event() {
|
||||
let conn = open_test_db();
|
||||
let data = CreateEvent {
|
||||
title: "Original".to_string(),
|
||||
description: String::new(),
|
||||
location: String::new(),
|
||||
start_time: "2025-03-06T09:00:00Z".to_string(),
|
||||
end_time: "2025-03-06T10:00:00Z".to_string(),
|
||||
all_day: false,
|
||||
recurrence_rule: None,
|
||||
attendees: "[]".to_string(),
|
||||
organizer: String::new(),
|
||||
status: "confirmed".to_string(),
|
||||
source: "manual".to_string(),
|
||||
source_uid: None,
|
||||
source_message_uid: None,
|
||||
source_message_folder: None,
|
||||
meeting_url: None,
|
||||
color: None,
|
||||
reminder_minutes: None,
|
||||
};
|
||||
|
||||
let event = create_event(&conn, &data).unwrap();
|
||||
let update = UpdateEvent {
|
||||
title: Some("Updated".to_string()),
|
||||
description: None,
|
||||
location: None,
|
||||
start_time: None,
|
||||
end_time: None,
|
||||
all_day: None,
|
||||
recurrence_rule: None,
|
||||
attendees: None,
|
||||
organizer: None,
|
||||
status: None,
|
||||
meeting_url: None,
|
||||
color: None,
|
||||
reminder_minutes: None,
|
||||
};
|
||||
|
||||
let updated = update_event(&conn, &event.id, &update).unwrap();
|
||||
assert!(updated.is_some());
|
||||
assert_eq!(updated.unwrap().title, "Updated");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_event() {
|
||||
let conn = open_test_db();
|
||||
let data = CreateEvent {
|
||||
title: "To Delete".to_string(),
|
||||
description: String::new(),
|
||||
location: String::new(),
|
||||
start_time: "2025-03-06T09:00:00Z".to_string(),
|
||||
end_time: "2025-03-06T10:00:00Z".to_string(),
|
||||
all_day: false,
|
||||
recurrence_rule: None,
|
||||
attendees: "[]".to_string(),
|
||||
organizer: String::new(),
|
||||
status: "confirmed".to_string(),
|
||||
source: "manual".to_string(),
|
||||
source_uid: None,
|
||||
source_message_uid: None,
|
||||
source_message_folder: None,
|
||||
meeting_url: None,
|
||||
color: None,
|
||||
reminder_minutes: None,
|
||||
};
|
||||
|
||||
let event = create_event(&conn, &data).unwrap();
|
||||
assert!(delete_event(&conn, &event.id).unwrap());
|
||||
assert!(!delete_event(&conn, &event.id).unwrap());
|
||||
assert!(get_event(&conn, &event.id).unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_event_by_source_uid() {
|
||||
let conn = open_test_db();
|
||||
let data = CreateEvent {
|
||||
title: "Imported".to_string(),
|
||||
description: String::new(),
|
||||
location: String::new(),
|
||||
start_time: "2025-03-06T09:00:00Z".to_string(),
|
||||
end_time: "2025-03-06T10:00:00Z".to_string(),
|
||||
all_day: false,
|
||||
recurrence_rule: None,
|
||||
attendees: "[]".to_string(),
|
||||
organizer: String::new(),
|
||||
status: "confirmed".to_string(),
|
||||
source: "ics".to_string(),
|
||||
source_uid: Some("unique-uid-123@example.com".to_string()),
|
||||
source_message_uid: None,
|
||||
source_message_folder: None,
|
||||
meeting_url: None,
|
||||
color: None,
|
||||
reminder_minutes: None,
|
||||
};
|
||||
|
||||
create_event(&conn, &data).unwrap();
|
||||
|
||||
let found = find_event_by_source_uid(&conn, "unique-uid-123@example.com").unwrap();
|
||||
assert!(found.is_some());
|
||||
assert_eq!(found.unwrap().title, "Imported");
|
||||
|
||||
let missing = find_event_by_source_uid(&conn, "nonexistent@example.com").unwrap();
|
||||
assert!(missing.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_calendar_settings() {
|
||||
let conn = open_test_db();
|
||||
|
||||
let settings = get_calendar_settings(&conn).unwrap();
|
||||
assert_eq!(settings.default_view, "month");
|
||||
assert_eq!(settings.week_starts_on, 0);
|
||||
assert_eq!(settings.time_format, "12h");
|
||||
|
||||
let update = UpdateCalendarSettings {
|
||||
default_view: Some("week".to_string()),
|
||||
week_starts_on: Some(1),
|
||||
working_hours_start: None,
|
||||
working_hours_end: None,
|
||||
time_format: Some("24h".to_string()),
|
||||
zoom_link: None,
|
||||
teams_link: None,
|
||||
meet_link: None,
|
||||
caldav_url: None,
|
||||
caldav_username: None,
|
||||
caldav_password: None,
|
||||
caldav_enabled: None,
|
||||
};
|
||||
|
||||
let updated = update_calendar_settings(&conn, &update).unwrap();
|
||||
assert_eq!(updated.default_view, "week");
|
||||
assert_eq!(updated.week_starts_on, 1);
|
||||
assert_eq!(updated.time_format, "24h");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_meeting_templates() {
|
||||
let conn = open_test_db();
|
||||
|
||||
let template =
|
||||
create_meeting_template(&conn, "Zoom", "https://zoom.us/j/{id}", "video").unwrap();
|
||||
assert_eq!(template.name, "Zoom");
|
||||
|
||||
let all = list_meeting_templates(&conn).unwrap();
|
||||
assert_eq!(all.len(), 1);
|
||||
|
||||
assert!(delete_meeting_template(&conn, template.id).unwrap());
|
||||
assert!(!delete_meeting_template(&conn, template.id).unwrap());
|
||||
|
||||
let all = list_meeting_templates(&conn).unwrap();
|
||||
assert_eq!(all.len(), 0);
|
||||
}
|
||||
|
||||
// Helper to create CreateEvent variants without full Clone impl
|
||||
impl CreateEvent {
|
||||
fn clone_fields(&self) -> Self {
|
||||
CreateEvent {
|
||||
title: String::new(),
|
||||
description: self.description.clone(),
|
||||
location: self.location.clone(),
|
||||
start_time: String::new(),
|
||||
end_time: String::new(),
|
||||
all_day: self.all_day,
|
||||
recurrence_rule: self.recurrence_rule.clone(),
|
||||
attendees: self.attendees.clone(),
|
||||
organizer: self.organizer.clone(),
|
||||
status: self.status.clone(),
|
||||
source: self.source.clone(),
|
||||
source_uid: self.source_uid.clone(),
|
||||
source_message_uid: self.source_message_uid,
|
||||
source_message_folder: self.source_message_folder.clone(),
|
||||
meeting_url: self.meeting_url.clone(),
|
||||
color: self.color.clone(),
|
||||
reminder_minutes: self.reminder_minutes,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
use rusqlite::{Connection, params};
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::db::contacts::Contact;
|
||||
|
||||
/// A contact group record, mirroring the `contact_groups` table.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ContactGroup {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub member_count: i64,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Create a new contact group.
|
||||
pub fn create_group(conn: &Connection, id: &str, name: &str) -> Result<(), String> {
|
||||
conn.execute(
|
||||
"INSERT INTO contact_groups (id, name) VALUES (?1, ?2)",
|
||||
params![id, name],
|
||||
)
|
||||
.map_err(|e| format!("Failed to create contact group: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// List all contact groups with their member counts.
|
||||
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",
|
||||
)
|
||||
.map_err(|e| format!("Failed to prepare list_groups: {e}"))?;
|
||||
|
||||
let rows = stmt
|
||||
.query_map([], |row| {
|
||||
Ok(ContactGroup {
|
||||
id: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
created_at: row.get(2)?,
|
||||
updated_at: row.get(3)?,
|
||||
member_count: row.get(4)?,
|
||||
})
|
||||
})
|
||||
.map_err(|e| format!("Failed to query groups: {e}"))?;
|
||||
|
||||
let mut groups = Vec::new();
|
||||
for row in rows {
|
||||
groups.push(row.map_err(|e| format!("Failed to read group row: {e}"))?);
|
||||
}
|
||||
Ok(groups)
|
||||
}
|
||||
|
||||
/// Update a group's name.
|
||||
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",
|
||||
params![name, id],
|
||||
)
|
||||
.map_err(|e| format!("Failed to update contact group: {e}"))?;
|
||||
Ok(updated > 0)
|
||||
}
|
||||
|
||||
/// Delete a contact group. CASCADE handles member rows.
|
||||
pub fn delete_group(conn: &Connection, id: &str) -> Result<bool, String> {
|
||||
let deleted = conn
|
||||
.execute("DELETE FROM contact_groups WHERE id = ?1", params![id])
|
||||
.map_err(|e| format!("Failed to delete contact group: {e}"))?;
|
||||
Ok(deleted > 0)
|
||||
}
|
||||
|
||||
/// Add a contact to a group (INSERT OR IGNORE to avoid duplicates).
|
||||
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)",
|
||||
params![group_id, contact_id],
|
||||
)
|
||||
.map_err(|e| format!("Failed to add group member: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove a contact from a group.
|
||||
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",
|
||||
params![group_id, contact_id],
|
||||
)
|
||||
.map_err(|e| format!("Failed to remove group member: {e}"))?;
|
||||
Ok(deleted > 0)
|
||||
}
|
||||
|
||||
/// List members (full Contact objects) for a group.
|
||||
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",
|
||||
)
|
||||
.map_err(|e| format!("Failed to prepare list_group_members: {e}"))?;
|
||||
|
||||
let rows = stmt
|
||||
.query_map(params![group_id], |row| {
|
||||
Ok(Contact {
|
||||
id: row.get(0)?,
|
||||
email: row.get(1)?,
|
||||
name: row.get(2)?,
|
||||
company: row.get(3)?,
|
||||
notes: row.get(4)?,
|
||||
phone: row.get(5)?,
|
||||
job_title: row.get(6)?,
|
||||
address: row.get(7)?,
|
||||
website: row.get(8)?,
|
||||
birthday: row.get(9)?,
|
||||
emails: serde_json::from_str::<Vec<String>>(&row.get::<_, String>(16)?)
|
||||
.unwrap_or_default(),
|
||||
phones: serde_json::from_str::<Vec<String>>(&row.get::<_, String>(17)?)
|
||||
.unwrap_or_default(),
|
||||
addresses: serde_json::from_str::<Vec<String>>(&row.get::<_, String>(18)?)
|
||||
.unwrap_or_default(),
|
||||
is_favorite: row.get::<_, i32>(10)? != 0,
|
||||
last_contacted: row.get(11)?,
|
||||
contact_count: row.get(12)?,
|
||||
source: row.get(13)?,
|
||||
created_at: row.get(14)?,
|
||||
updated_at: row.get(15)?,
|
||||
has_photo: row.get::<_, i32>(19)? != 0,
|
||||
})
|
||||
})
|
||||
.map_err(|e| format!("Failed to query group members: {e}"))?;
|
||||
|
||||
let mut contacts = Vec::new();
|
||||
for row in rows {
|
||||
contacts.push(row.map_err(|e| format!("Failed to read group member row: {e}"))?);
|
||||
}
|
||||
Ok(contacts)
|
||||
}
|
||||
|
||||
/// List all groups a given contact belongs to.
|
||||
pub fn list_contact_groups(
|
||||
conn: &Connection,
|
||||
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",
|
||||
)
|
||||
.map_err(|e| format!("Failed to prepare list_contact_groups: {e}"))?;
|
||||
|
||||
let rows = stmt
|
||||
.query_map(params![contact_id], |row| {
|
||||
Ok(ContactGroup {
|
||||
id: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
created_at: row.get(2)?,
|
||||
updated_at: row.get(3)?,
|
||||
member_count: row.get(4)?,
|
||||
})
|
||||
})
|
||||
.map_err(|e| format!("Failed to query contact groups: {e}"))?;
|
||||
|
||||
let mut groups = Vec::new();
|
||||
for row in rows {
|
||||
groups.push(row.map_err(|e| format!("Failed to read contact group row: {e}"))?);
|
||||
}
|
||||
Ok(groups)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::contacts::{Contact as DbContact, upsert_contact};
|
||||
use crate::db::pool::open_test_db;
|
||||
|
||||
fn sample_contact(id: &str, email: &str, name: &str) -> DbContact {
|
||||
DbContact {
|
||||
id: id.to_string(),
|
||||
email: email.to_string(),
|
||||
name: name.to_string(),
|
||||
company: String::new(),
|
||||
notes: String::new(),
|
||||
phone: String::new(),
|
||||
job_title: String::new(),
|
||||
address: String::new(),
|
||||
website: String::new(),
|
||||
birthday: String::new(),
|
||||
emails: Vec::new(),
|
||||
phones: Vec::new(),
|
||||
addresses: Vec::new(),
|
||||
is_favorite: false,
|
||||
last_contacted: None,
|
||||
contact_count: 0,
|
||||
source: "manual".to_string(),
|
||||
created_at: "2024-01-01 00:00:00".to_string(),
|
||||
updated_at: "2024-01-01 00:00:00".to_string(),
|
||||
has_photo: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_and_list_groups() {
|
||||
let conn = open_test_db();
|
||||
create_group(&conn, "g1", "Friends").unwrap();
|
||||
create_group(&conn, "g2", "Work").unwrap();
|
||||
|
||||
let groups = list_groups(&conn).unwrap();
|
||||
assert_eq!(groups.len(), 2);
|
||||
assert_eq!(groups[0].name, "Friends");
|
||||
assert_eq!(groups[1].name, "Work");
|
||||
assert_eq!(groups[0].member_count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_add_and_list_members() {
|
||||
let conn = open_test_db();
|
||||
create_group(&conn, "g1", "Friends").unwrap();
|
||||
upsert_contact(&conn, &sample_contact("c1", "alice@ex.com", "Alice")).unwrap();
|
||||
upsert_contact(&conn, &sample_contact("c2", "bob@ex.com", "Bob")).unwrap();
|
||||
|
||||
add_member(&conn, "g1", "c1").unwrap();
|
||||
add_member(&conn, "g1", "c2").unwrap();
|
||||
// Duplicate add should be ignored
|
||||
add_member(&conn, "g1", "c1").unwrap();
|
||||
|
||||
let members = list_group_members(&conn, "g1").unwrap();
|
||||
assert_eq!(members.len(), 2);
|
||||
|
||||
let groups = list_groups(&conn).unwrap();
|
||||
assert_eq!(groups[0].member_count, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_remove_member() {
|
||||
let conn = open_test_db();
|
||||
create_group(&conn, "g1", "Friends").unwrap();
|
||||
upsert_contact(&conn, &sample_contact("c1", "alice@ex.com", "Alice")).unwrap();
|
||||
add_member(&conn, "g1", "c1").unwrap();
|
||||
|
||||
let removed = remove_member(&conn, "g1", "c1").unwrap();
|
||||
assert!(removed);
|
||||
|
||||
let members = list_group_members(&conn, "g1").unwrap();
|
||||
assert_eq!(members.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_group_cascades() {
|
||||
let conn = open_test_db();
|
||||
create_group(&conn, "g1", "Friends").unwrap();
|
||||
upsert_contact(&conn, &sample_contact("c1", "alice@ex.com", "Alice")).unwrap();
|
||||
add_member(&conn, "g1", "c1").unwrap();
|
||||
|
||||
let deleted = delete_group(&conn, "g1").unwrap();
|
||||
assert!(deleted);
|
||||
|
||||
let groups = list_groups(&conn).unwrap();
|
||||
assert_eq!(groups.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_contact_groups() {
|
||||
let conn = open_test_db();
|
||||
create_group(&conn, "g1", "Friends").unwrap();
|
||||
create_group(&conn, "g2", "Work").unwrap();
|
||||
upsert_contact(&conn, &sample_contact("c1", "alice@ex.com", "Alice")).unwrap();
|
||||
add_member(&conn, "g1", "c1").unwrap();
|
||||
add_member(&conn, "g2", "c1").unwrap();
|
||||
|
||||
let groups = list_contact_groups(&conn, "c1").unwrap();
|
||||
assert_eq!(groups.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_group() {
|
||||
let conn = open_test_db();
|
||||
create_group(&conn, "g1", "Friends").unwrap();
|
||||
|
||||
let updated = update_group(&conn, "g1", "Close Friends").unwrap();
|
||||
assert!(updated);
|
||||
|
||||
let groups = list_groups(&conn).unwrap();
|
||||
assert_eq!(groups[0].name, "Close Friends");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,686 @@
|
||||
use rusqlite::{params, Connection};
|
||||
use serde::Serialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// A known address extracted from message headers (not necessarily a contact).
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct KnownAddress {
|
||||
pub email: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// A contact record, mirroring the `contacts` table.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct Contact {
|
||||
pub id: String,
|
||||
pub email: String,
|
||||
pub name: String,
|
||||
pub company: String,
|
||||
pub notes: String,
|
||||
pub phone: String,
|
||||
pub job_title: String,
|
||||
pub address: String,
|
||||
pub website: String,
|
||||
pub birthday: String,
|
||||
pub emails: Vec<String>,
|
||||
pub phones: Vec<String>,
|
||||
pub addresses: Vec<String>,
|
||||
pub is_favorite: bool,
|
||||
pub last_contacted: Option<String>,
|
||||
pub contact_count: i64,
|
||||
pub source: String,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
pub has_photo: bool,
|
||||
}
|
||||
|
||||
/// Map a rusqlite row to a Contact struct.
|
||||
fn row_to_contact(row: &rusqlite::Row<'_>) -> rusqlite::Result<Contact> {
|
||||
Ok(Contact {
|
||||
id: row.get(0)?,
|
||||
email: row.get(1)?,
|
||||
name: row.get(2)?,
|
||||
company: row.get(3)?,
|
||||
notes: row.get(4)?,
|
||||
phone: row.get(5)?,
|
||||
job_title: row.get(6)?,
|
||||
address: row.get(7)?,
|
||||
website: row.get(8)?,
|
||||
birthday: row.get(9)?,
|
||||
emails: serde_json::from_str::<Vec<String>>(&row.get::<_, String>(16)?).unwrap_or_default(),
|
||||
phones: serde_json::from_str::<Vec<String>>(&row.get::<_, String>(17)?).unwrap_or_default(),
|
||||
addresses: serde_json::from_str::<Vec<String>>(&row.get::<_, String>(18)?).unwrap_or_default(),
|
||||
is_favorite: row.get::<_, i32>(10)? != 0,
|
||||
last_contacted: row.get(11)?,
|
||||
contact_count: row.get(12)?,
|
||||
source: row.get(13)?,
|
||||
created_at: row.get(14)?,
|
||||
updated_at: row.get(15)?,
|
||||
has_photo: row.get::<_, i32>(19)? != 0,
|
||||
})
|
||||
}
|
||||
|
||||
/// The SELECT column list used by all queries that return `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";
|
||||
|
||||
/// Escape `%` and `_` in a search term to prevent LIKE wildcard injection.
|
||||
fn escape_like(term: &str) -> String {
|
||||
term.replace('\\', "\\\\")
|
||||
.replace('%', "\\%")
|
||||
.replace('_', "\\_")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Insert or replace a contact. All fields are written.
|
||||
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",
|
||||
params![
|
||||
contact.id,
|
||||
contact.email,
|
||||
contact.name,
|
||||
contact.company,
|
||||
contact.notes,
|
||||
contact.phone,
|
||||
contact.job_title,
|
||||
contact.address,
|
||||
contact.website,
|
||||
contact.birthday,
|
||||
contact.is_favorite as i32,
|
||||
contact.last_contacted,
|
||||
contact.contact_count,
|
||||
contact.source,
|
||||
contact.created_at,
|
||||
contact.updated_at,
|
||||
serde_json::to_string(&contact.emails).unwrap_or_else(|_| "[]".to_string()),
|
||||
serde_json::to_string(&contact.phones).unwrap_or_else(|_| "[]".to_string()),
|
||||
serde_json::to_string(&contact.addresses).unwrap_or_else(|_| "[]".to_string()),
|
||||
],
|
||||
)
|
||||
.map_err(|e| format!("Failed to upsert contact: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get a contact by its primary key `id`. Returns `None` if not found.
|
||||
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 result = conn.query_row(&sql, params![id], row_to_contact);
|
||||
|
||||
match result {
|
||||
Ok(c) => Ok(Some(c)),
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||||
Err(e) => Err(format!("Failed to get contact: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a contact by email address. Returns `None` if not found.
|
||||
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 result = conn.query_row(&sql, params![email], row_to_contact);
|
||||
|
||||
match result {
|
||||
Ok(c) => Ok(Some(c)),
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||||
Err(e) => Err(format!("Failed to get contact by email: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// List contacts with optional search filter on name/email.
|
||||
/// Results are ordered by name ascending, with LIMIT/OFFSET pagination.
|
||||
pub fn list_contacts(
|
||||
conn: &Connection,
|
||||
search: Option<&str>,
|
||||
limit: u32,
|
||||
offset: u32,
|
||||
) -> Result<Vec<Contact>, String> {
|
||||
let (sql, has_search) = match search {
|
||||
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"
|
||||
);
|
||||
// We need to pass the pattern, so we store it and handle below.
|
||||
// To keep things simple we branch on has_search.
|
||||
drop(pattern);
|
||||
(sql, Some(escaped))
|
||||
}
|
||||
_ => {
|
||||
let sql = format!(
|
||||
"SELECT {CONTACT_SELECT_COLS} FROM contacts
|
||||
ORDER BY name ASC
|
||||
LIMIT ?1 OFFSET ?2"
|
||||
);
|
||||
(sql, None)
|
||||
}
|
||||
};
|
||||
|
||||
match has_search {
|
||||
Some(escaped) => {
|
||||
let pattern = format!("%{escaped}%");
|
||||
let mut stmt = conn
|
||||
.prepare(&sql)
|
||||
.map_err(|e| format!("Failed to prepare list_contacts: {e}"))?;
|
||||
let rows = stmt
|
||||
.query_map(params![pattern, limit, offset], row_to_contact)
|
||||
.map_err(|e| format!("Failed to query contacts: {e}"))?;
|
||||
let mut contacts = Vec::new();
|
||||
for row in rows {
|
||||
contacts.push(row.map_err(|e| format!("Failed to read contact row: {e}"))?);
|
||||
}
|
||||
Ok(contacts)
|
||||
}
|
||||
None => {
|
||||
let mut stmt = conn
|
||||
.prepare(&sql)
|
||||
.map_err(|e| format!("Failed to prepare list_contacts: {e}"))?;
|
||||
let rows = stmt
|
||||
.query_map(params![limit, offset], row_to_contact)
|
||||
.map_err(|e| format!("Failed to query contacts: {e}"))?;
|
||||
let mut contacts = Vec::new();
|
||||
for row in rows {
|
||||
contacts.push(row.map_err(|e| format!("Failed to read contact row: {e}"))?);
|
||||
}
|
||||
Ok(contacts)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete a contact by id. Returns `true` if a row was deleted.
|
||||
pub fn delete_contact(conn: &Connection, id: &str) -> Result<bool, String> {
|
||||
let deleted = conn
|
||||
.execute("DELETE FROM contacts WHERE id = ?1", params![id])
|
||||
.map_err(|e| format!("Failed to delete contact: {e}"))?;
|
||||
Ok(deleted > 0)
|
||||
}
|
||||
|
||||
/// Search contacts by name or email using LIKE with escaped wildcards.
|
||||
/// Results are ordered by name ascending.
|
||||
pub fn search_contacts(conn: &Connection, query: &str, limit: u32) -> Result<Vec<Contact>, String> {
|
||||
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 mut stmt = conn
|
||||
.prepare(&sql)
|
||||
.map_err(|e| format!("Failed to prepare search_contacts: {e}"))?;
|
||||
let rows = stmt
|
||||
.query_map(params![pattern, limit], row_to_contact)
|
||||
.map_err(|e| format!("Failed to query search contacts: {e}"))?;
|
||||
|
||||
let mut contacts = Vec::new();
|
||||
for row in rows {
|
||||
contacts.push(row.map_err(|e| format!("Failed to read contact row: {e}"))?);
|
||||
}
|
||||
Ok(contacts)
|
||||
}
|
||||
|
||||
/// Search known addresses from the denormalized `known_addresses` table.
|
||||
/// Excludes any addresses already present in the contacts table.
|
||||
/// Returns distinct addresses ordered by email ascending.
|
||||
pub fn search_known_addresses(
|
||||
conn: &Connection,
|
||||
query: &str,
|
||||
limit: u32,
|
||||
) -> Result<Vec<KnownAddress>, String> {
|
||||
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 mut stmt = conn
|
||||
.prepare(sql)
|
||||
.map_err(|e| format!("Failed to prepare search_known_addresses: {e}"))?;
|
||||
|
||||
let rows = stmt
|
||||
.query_map(params![pattern, limit], |row| {
|
||||
Ok(KnownAddress {
|
||||
email: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
})
|
||||
})
|
||||
.map_err(|e| format!("Failed to query known addresses: {e}"))?;
|
||||
|
||||
let mut addresses = Vec::new();
|
||||
for row in rows {
|
||||
addresses.push(row.map_err(|e| format!("Failed to read known address row: {e}"))?);
|
||||
}
|
||||
Ok(addresses)
|
||||
}
|
||||
|
||||
/// Populate the `known_addresses` table from a single message's header fields.
|
||||
/// Uses INSERT OR IGNORE so existing rows are not overwritten.
|
||||
pub fn populate_known_addresses(
|
||||
conn: &Connection,
|
||||
from_address: &str,
|
||||
from_name: &str,
|
||||
to_json: &str,
|
||||
cc_json: &str,
|
||||
) -> Result<(), String> {
|
||||
// Insert from address.
|
||||
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 != ''",
|
||||
params![from_address, from_name],
|
||||
)
|
||||
.map_err(|e| format!("Failed to insert known address (from): {e}"))?;
|
||||
}
|
||||
|
||||
// Insert to addresses from JSON array.
|
||||
insert_addresses_from_json(conn, to_json)?;
|
||||
|
||||
// Insert cc addresses from JSON array.
|
||||
insert_addresses_from_json(conn, cc_json)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Parse a JSON array of `{"address": "...", "name": "..."}` objects and insert
|
||||
/// each into `known_addresses`.
|
||||
fn insert_addresses_from_json(conn: &Connection, json: &str) -> Result<(), String> {
|
||||
let entries: Vec<serde_json::Value> = match serde_json::from_str(json) {
|
||||
Ok(v) => v,
|
||||
Err(_) => return Ok(()), // Invalid JSON, skip silently.
|
||||
};
|
||||
|
||||
for entry in &entries {
|
||||
let email = entry
|
||||
.get("address")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
if email.is_empty() {
|
||||
continue;
|
||||
}
|
||||
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 != ''",
|
||||
params![email, name],
|
||||
)
|
||||
.map_err(|e| format!("Failed to insert known address (json): {e}"))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Increment the contact_count by 1 and set last_contacted to now for the
|
||||
/// contact with the given email address.
|
||||
#[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",
|
||||
params![email],
|
||||
)
|
||||
.map_err(|e| format!("Failed to increment contact count: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Auto-add a contact if one with the same email does not already exist.
|
||||
/// Uses INSERT OR IGNORE so existing contacts are not overwritten.
|
||||
/// Source is set to 'auto'.
|
||||
#[allow(dead_code)]
|
||||
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')",
|
||||
params![id, email, name],
|
||||
)
|
||||
.map_err(|e| format!("Failed to auto-add contact: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_contact_photo(conn: &Connection, contact_id: &str, content_type: &str, 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')",
|
||||
params![contact_id, content_type, data],
|
||||
).map_err(|e| format!("Failed to save contact photo: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_contact_photo(conn: &Connection, contact_id: &str) -> Result<Option<(String, Vec<u8>)>, String> {
|
||||
match conn.query_row(
|
||||
"SELECT content_type, data FROM contact_photos WHERE contact_id = ?1",
|
||||
params![contact_id],
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
) {
|
||||
Ok(value) => Ok(Some(value)),
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||||
Err(e) => Err(format!("Failed to load contact photo: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn delete_contact_photo(conn: &Connection, contact_id: &str) -> Result<bool, String> {
|
||||
conn.execute("DELETE FROM contact_photos WHERE contact_id = ?1", params![contact_id])
|
||||
.map(|n| n > 0)
|
||||
.map_err(|e| format!("Failed to delete contact photo: {e}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::pool::open_test_db;
|
||||
|
||||
/// Helper: create a sample Contact with sensible defaults.
|
||||
fn sample_contact(id: &str, email: &str, name: &str) -> Contact {
|
||||
Contact {
|
||||
id: id.to_string(),
|
||||
email: email.to_string(),
|
||||
name: name.to_string(),
|
||||
company: String::new(),
|
||||
notes: String::new(),
|
||||
phone: String::new(),
|
||||
job_title: String::new(),
|
||||
address: String::new(),
|
||||
website: String::new(),
|
||||
birthday: String::new(),
|
||||
emails: Vec::new(),
|
||||
phones: Vec::new(),
|
||||
addresses: Vec::new(),
|
||||
is_favorite: false,
|
||||
last_contacted: None,
|
||||
contact_count: 0,
|
||||
source: "manual".to_string(),
|
||||
created_at: "2024-01-01 00:00:00".to_string(),
|
||||
updated_at: "2024-01-01 00:00:00".to_string(),
|
||||
has_photo: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_upsert_and_get_contact() {
|
||||
let conn = open_test_db();
|
||||
let contact = sample_contact("c1", "alice@example.com", "Alice");
|
||||
|
||||
upsert_contact(&conn, &contact).unwrap();
|
||||
|
||||
let fetched = get_contact(&conn, "c1").unwrap();
|
||||
assert!(fetched.is_some());
|
||||
let fetched = fetched.unwrap();
|
||||
assert_eq!(fetched.id, "c1");
|
||||
assert_eq!(fetched.email, "alice@example.com");
|
||||
assert_eq!(fetched.name, "Alice");
|
||||
assert_eq!(fetched.source, "manual");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_contact_by_email() {
|
||||
let conn = open_test_db();
|
||||
let contact = sample_contact("c2", "bob@example.com", "Bob");
|
||||
upsert_contact(&conn, &contact).unwrap();
|
||||
|
||||
let fetched = get_contact_by_email(&conn, "bob@example.com").unwrap();
|
||||
assert!(fetched.is_some());
|
||||
let fetched = fetched.unwrap();
|
||||
assert_eq!(fetched.id, "c2");
|
||||
assert_eq!(fetched.name, "Bob");
|
||||
|
||||
// Non-existent email returns None.
|
||||
let missing = get_contact_by_email(&conn, "nobody@example.com").unwrap();
|
||||
assert!(missing.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_contacts() {
|
||||
let conn = open_test_db();
|
||||
upsert_contact(&conn, &sample_contact("c1", "alice@example.com", "Alice")).unwrap();
|
||||
upsert_contact(&conn, &sample_contact("c2", "bob@example.com", "Bob")).unwrap();
|
||||
|
||||
let all = list_contacts(&conn, None, 100, 0).unwrap();
|
||||
assert_eq!(all.len(), 2);
|
||||
|
||||
// Ordered by name ASC: Alice, Bob.
|
||||
assert_eq!(all[0].name, "Alice");
|
||||
assert_eq!(all[1].name, "Bob");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_search_contacts() {
|
||||
let conn = open_test_db();
|
||||
upsert_contact(
|
||||
&conn,
|
||||
&sample_contact("c1", "alice@example.com", "Alice Smith"),
|
||||
)
|
||||
.unwrap();
|
||||
upsert_contact(&conn, &sample_contact("c2", "bob@example.com", "Bob Jones")).unwrap();
|
||||
|
||||
// Search by name should find only Alice.
|
||||
let results = search_contacts(&conn, "Alice", 10).unwrap();
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0].name, "Alice Smith");
|
||||
|
||||
// Search by email should find only Bob.
|
||||
let results = search_contacts(&conn, "bob@", 10).unwrap();
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0].name, "Bob Jones");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_contact() {
|
||||
let conn = open_test_db();
|
||||
upsert_contact(&conn, &sample_contact("c1", "alice@example.com", "Alice")).unwrap();
|
||||
|
||||
let deleted = delete_contact(&conn, "c1").unwrap();
|
||||
assert!(deleted);
|
||||
|
||||
// Should be gone now.
|
||||
let fetched = get_contact(&conn, "c1").unwrap();
|
||||
assert!(fetched.is_none());
|
||||
|
||||
// Deleting again returns false.
|
||||
let deleted_again = delete_contact(&conn, "c1").unwrap();
|
||||
assert!(!deleted_again);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_auto_add_contact() {
|
||||
let conn = open_test_db();
|
||||
|
||||
auto_add_contact(&conn, "auto@example.com", "Auto User").unwrap();
|
||||
|
||||
let fetched = get_contact_by_email(&conn, "auto@example.com").unwrap();
|
||||
assert!(fetched.is_some());
|
||||
let fetched = fetched.unwrap();
|
||||
assert_eq!(fetched.source, "auto");
|
||||
assert_eq!(fetched.name, "Auto User");
|
||||
|
||||
// Auto-adding the same email again should be a no-op (INSERT OR IGNORE).
|
||||
auto_add_contact(&conn, "auto@example.com", "Different Name").unwrap();
|
||||
let fetched2 = get_contact_by_email(&conn, "auto@example.com")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(fetched2.name, "Auto User"); // Name should NOT change.
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_increment_contact_count() {
|
||||
let conn = open_test_db();
|
||||
upsert_contact(&conn, &sample_contact("c1", "alice@example.com", "Alice")).unwrap();
|
||||
|
||||
// Increment twice.
|
||||
increment_contact_count(&conn, "alice@example.com").unwrap();
|
||||
increment_contact_count(&conn, "alice@example.com").unwrap();
|
||||
|
||||
let fetched = get_contact_by_email(&conn, "alice@example.com")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(fetched.contact_count, 2);
|
||||
assert!(fetched.last_contacted.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_search_known_addresses_basic() {
|
||||
let conn = open_test_db();
|
||||
|
||||
// Populate known_addresses directly (simulating what happens at sync time).
|
||||
populate_known_addresses(&conn, "alice@test.com", "Alice From", "[]", "[]").unwrap();
|
||||
populate_known_addresses(
|
||||
&conn,
|
||||
"sender@other.com",
|
||||
"Sender",
|
||||
r#"[{"name":"Bob To","address":"bob@test.com"}]"#,
|
||||
"[]",
|
||||
)
|
||||
.unwrap();
|
||||
populate_known_addresses(
|
||||
&conn,
|
||||
"sender@other.com",
|
||||
"Sender",
|
||||
"[]",
|
||||
r#"[{"name":"Charlie Cc","address":"charlie@test.com"}]"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let results = search_known_addresses(&conn, "test.com", 10).unwrap();
|
||||
assert_eq!(results.len(), 3);
|
||||
|
||||
assert_eq!(results[0].email, "alice@test.com");
|
||||
assert_eq!(results[0].name, "Alice From");
|
||||
assert_eq!(results[1].email, "bob@test.com");
|
||||
assert_eq!(results[1].name, "Bob To");
|
||||
assert_eq!(results[2].email, "charlie@test.com");
|
||||
assert_eq!(results[2].name, "Charlie Cc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_search_known_addresses_excludes_contacts() {
|
||||
let conn = open_test_db();
|
||||
|
||||
// Add alice@test.com as a contact.
|
||||
upsert_contact(
|
||||
&conn,
|
||||
&sample_contact("c1", "alice@test.com", "Alice Contact"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Populate known_addresses (simulating sync).
|
||||
populate_known_addresses(&conn, "alice@test.com", "Alice From", "[]", "[]").unwrap();
|
||||
populate_known_addresses(
|
||||
&conn,
|
||||
"bob@test.com",
|
||||
"Bob From",
|
||||
r#"[{"name":"Alice To","address":"alice@test.com"}]"#,
|
||||
"[]",
|
||||
)
|
||||
.unwrap();
|
||||
populate_known_addresses(
|
||||
&conn,
|
||||
"charlie@test.com",
|
||||
"Charlie",
|
||||
"[]",
|
||||
r#"[{"name":"Alice Cc","address":"alice@test.com"}]"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Search for "test.com" should exclude alice@test.com (she's a contact).
|
||||
let results = search_known_addresses(&conn, "test.com", 10).unwrap();
|
||||
|
||||
// Should only have bob and charlie (alice excluded from all sources).
|
||||
assert_eq!(results.len(), 2);
|
||||
let emails: Vec<&str> = results.iter().map(|a| a.email.as_str()).collect();
|
||||
assert!(emails.contains(&"bob@test.com"));
|
||||
assert!(emails.contains(&"charlie@test.com"));
|
||||
assert!(!emails.contains(&"alice@test.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_search_known_addresses_handles_invalid_json() {
|
||||
let conn = open_test_db();
|
||||
|
||||
// Populate known_addresses (simulating sync).
|
||||
populate_known_addresses(&conn, "valid@test.com", "Valid User", "[]", "[]").unwrap();
|
||||
populate_known_addresses(
|
||||
&conn,
|
||||
"sender@other.com",
|
||||
"Sender",
|
||||
r#"[{"name":"","address":"emptyname@test.com"}]"#,
|
||||
r#"[{"name":"Cc Person","address":"ccperson@test.com"}]"#,
|
||||
)
|
||||
.unwrap();
|
||||
populate_known_addresses(
|
||||
&conn,
|
||||
"sender@other.com",
|
||||
"Sender",
|
||||
r#"[{"name":"Json Person","address":"json@test.com"}]"#,
|
||||
"[]",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let results = search_known_addresses(&conn, "test.com", 10).unwrap();
|
||||
|
||||
assert_eq!(results.len(), 4);
|
||||
let emails: Vec<&str> = results.iter().map(|a| a.email.as_str()).collect();
|
||||
assert!(emails.contains(&"valid@test.com"));
|
||||
assert!(emails.contains(&"emptyname@test.com"));
|
||||
assert!(emails.contains(&"ccperson@test.com"));
|
||||
assert!(emails.contains(&"json@test.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_populate_known_addresses_deduplicates() {
|
||||
let conn = open_test_db();
|
||||
|
||||
populate_known_addresses(&conn, "alice@test.com", "Alice", "[]", "[]").unwrap();
|
||||
// Same email again with non-empty name -- should update the name.
|
||||
populate_known_addresses(&conn, "alice@test.com", "Alice Different", "[]", "[]").unwrap();
|
||||
|
||||
let results = search_known_addresses(&conn, "alice@test.com", 10).unwrap();
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0].name, "Alice Different");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_populate_known_addresses_skips_invalid_json() {
|
||||
let conn = open_test_db();
|
||||
|
||||
// Invalid JSON in to_addresses should not cause an error.
|
||||
populate_known_addresses(&conn, "sender@test.com", "Sender", "not valid json", "[]")
|
||||
.unwrap();
|
||||
|
||||
let results = search_known_addresses(&conn, "sender@test.com", 10).unwrap();
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0].email, "sender@test.com");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
use rusqlite::Connection;
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct DisplayPreferences {
|
||||
pub density: String,
|
||||
pub theme: String,
|
||||
pub language: String,
|
||||
pub compose_format: String,
|
||||
pub compose_autosave_ms: i32,
|
||||
pub default_priority: String,
|
||||
pub request_mdn_default: bool,
|
||||
pub attachment_reminder: bool,
|
||||
pub deep_index: bool,
|
||||
pub animation_mode: Option<String>,
|
||||
pub preview_pane: String,
|
||||
pub ui_scale: String,
|
||||
pub color_preset: String,
|
||||
pub accent_color: String,
|
||||
pub background_color: String,
|
||||
pub panel_color: String,
|
||||
pub text_color: String,
|
||||
pub font_family: String,
|
||||
pub font_size: i32,
|
||||
pub row_height: i32,
|
||||
pub folder_pane_width: i32,
|
||||
pub message_pane_width: i32,
|
||||
pub border_radius: i32,
|
||||
pub contrast: String,
|
||||
pub show_avatars: bool,
|
||||
pub show_attachment_icons: bool,
|
||||
pub show_flags: bool,
|
||||
pub reduce_motion: bool,
|
||||
pub date_format: String,
|
||||
pub time_format: String,
|
||||
pub icon_size: i32,
|
||||
pub toolbar_density: String,
|
||||
pub sidebar_item_height: i32,
|
||||
pub reader_max_width: i32,
|
||||
pub composer_width: i32,
|
||||
pub selection_color: String,
|
||||
pub unread_font_weight: i32,
|
||||
pub unread_badge_style: String,
|
||||
pub compact_headers: bool,
|
||||
pub show_borders: bool,
|
||||
pub scrollbar_density: String,
|
||||
pub monochrome: bool,
|
||||
pub folder_background: String,
|
||||
pub message_background: String,
|
||||
pub reader_background: String,
|
||||
pub compose_font_family: String,
|
||||
pub reply_position: String,
|
||||
pub quote_style: String,
|
||||
pub forward_mode: String,
|
||||
pub spellcheck_enabled: bool,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
pub struct UpdateDisplayPreferences {
|
||||
pub density: Option<String>,
|
||||
pub theme: Option<String>,
|
||||
pub language: Option<String>,
|
||||
pub compose_format: Option<String>,
|
||||
pub compose_autosave_ms: Option<i32>,
|
||||
pub default_priority: Option<String>,
|
||||
pub request_mdn_default: Option<bool>,
|
||||
pub attachment_reminder: Option<bool>,
|
||||
pub deep_index: Option<bool>,
|
||||
#[serde(default, deserialize_with = "deserialize_animation_mode_field")]
|
||||
pub animation_mode: Option<Option<String>>,
|
||||
pub preview_pane: Option<String>,
|
||||
pub ui_scale: Option<String>,
|
||||
pub color_preset: Option<String>,
|
||||
pub accent_color: Option<String>,
|
||||
pub background_color: Option<String>,
|
||||
pub panel_color: Option<String>,
|
||||
pub text_color: Option<String>,
|
||||
pub font_family: Option<String>,
|
||||
pub font_size: Option<i32>,
|
||||
pub row_height: Option<i32>,
|
||||
pub folder_pane_width: Option<i32>,
|
||||
pub message_pane_width: Option<i32>,
|
||||
pub border_radius: Option<i32>,
|
||||
pub contrast: Option<String>,
|
||||
pub show_avatars: Option<bool>,
|
||||
pub show_attachment_icons: Option<bool>,
|
||||
pub show_flags: Option<bool>,
|
||||
pub reduce_motion: Option<bool>,
|
||||
pub date_format: Option<String>,
|
||||
pub time_format: Option<String>,
|
||||
pub icon_size: Option<i32>,
|
||||
pub toolbar_density: Option<String>,
|
||||
pub sidebar_item_height: Option<i32>,
|
||||
pub reader_max_width: Option<i32>,
|
||||
pub composer_width: Option<i32>,
|
||||
pub selection_color: Option<String>,
|
||||
pub unread_font_weight: Option<i32>,
|
||||
pub unread_badge_style: Option<String>,
|
||||
pub compact_headers: Option<bool>,
|
||||
pub show_borders: Option<bool>,
|
||||
pub scrollbar_density: Option<String>,
|
||||
pub monochrome: Option<bool>,
|
||||
pub folder_background: Option<String>,
|
||||
pub message_background: Option<String>,
|
||||
pub reader_background: Option<String>,
|
||||
pub compose_font_family: Option<String>,
|
||||
pub reply_position: Option<String>,
|
||||
pub quote_style: Option<String>,
|
||||
pub forward_mode: Option<String>,
|
||||
pub spellcheck_enabled: Option<bool>,
|
||||
}
|
||||
|
||||
fn deserialize_animation_mode_field<'de, D>(deserializer: D) -> Result<Option<Option<String>>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let value = Option::<String>::deserialize(deserializer)?;
|
||||
Ok(Some(value))
|
||||
}
|
||||
|
||||
fn defaults() -> DisplayPreferences {
|
||||
DisplayPreferences {
|
||||
density: "compact".into(),
|
||||
theme: "system".into(),
|
||||
language: "en".into(),
|
||||
compose_format: "html".into(),
|
||||
compose_autosave_ms: 3000,
|
||||
default_priority: "normal".into(),
|
||||
request_mdn_default: false,
|
||||
attachment_reminder: true,
|
||||
deep_index: false,
|
||||
animation_mode: None,
|
||||
preview_pane: "right".into(),
|
||||
ui_scale: "normal".into(),
|
||||
color_preset: "sage".into(),
|
||||
accent_color: "#6f9c86".into(),
|
||||
background_color: "#121513".into(),
|
||||
panel_color: "#181c19".into(),
|
||||
text_color: "#edf0ec".into(),
|
||||
font_family: "system".into(),
|
||||
font_size: 13,
|
||||
row_height: 58,
|
||||
folder_pane_width: 228,
|
||||
message_pane_width: 420,
|
||||
border_radius: 4,
|
||||
contrast: "normal".into(),
|
||||
show_avatars: true,
|
||||
show_attachment_icons: true,
|
||||
show_flags: true,
|
||||
reduce_motion: false,
|
||||
date_format: "locale".into(),
|
||||
time_format: "locale".into(),
|
||||
icon_size: 18,
|
||||
toolbar_density: "normal".into(),
|
||||
sidebar_item_height: 29,
|
||||
reader_max_width: 0,
|
||||
composer_width: 900,
|
||||
selection_color: "#28352e".into(),
|
||||
unread_font_weight: 730,
|
||||
unread_badge_style: "dot".into(),
|
||||
compact_headers: false,
|
||||
show_borders: true,
|
||||
scrollbar_density: "thin".into(),
|
||||
monochrome: false,
|
||||
folder_background: String::new(),
|
||||
message_background: String::new(),
|
||||
reader_background: String::new(),
|
||||
compose_font_family: "system".into(),
|
||||
reply_position: "above".into(),
|
||||
quote_style: "standard".into(),
|
||||
forward_mode: "inline".into(),
|
||||
spellcheck_enabled: true,
|
||||
updated_at: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
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, updated_at FROM display_preferences WHERE id = 1",
|
||||
[],
|
||||
|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, updated_at: row.get(50)?,
|
||||
}),
|
||||
);
|
||||
match result {
|
||||
Ok(value) => Ok(value),
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(defaults()),
|
||||
Err(error) => Err(format!("Failed to get display preferences: {error}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn enum_value(name: &str, value: &str, allowed: &[&str]) -> Result<(), String> {
|
||||
if allowed.contains(&value) { Ok(()) } else { Err(format!("Invalid {name}: {value}")) }
|
||||
}
|
||||
fn range(name: &str, value: i32, min: i32, max: i32) -> Result<(), String> {
|
||||
if (min..=max).contains(&value) { Ok(()) } else { Err(format!("Invalid {name}: {value}")) }
|
||||
}
|
||||
fn valid_hex(value: &str) -> bool {
|
||||
value.len() == 7 && value.starts_with('#') && value[1..].chars().all(|c| c.is_ascii_hexdigit())
|
||||
}
|
||||
|
||||
pub fn update_preferences(conn: &Connection, data: &UpdateDisplayPreferences) -> Result<DisplayPreferences, String> {
|
||||
conn.execute("INSERT OR IGNORE INTO display_preferences (id) VALUES (1)", [])
|
||||
.map_err(|e| format!("Failed to ensure preferences row: {e}"))?;
|
||||
|
||||
let mut sets = Vec::new();
|
||||
let mut values: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
|
||||
let mut idx = 1usize;
|
||||
macro_rules! set_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! set_int { ($field:ident, $column:literal) => { if let Some(value) = data.$field { sets.push(format!(concat!($column, " = ?{}"), idx)); values.push(Box::new(value)); idx += 1; } }; }
|
||||
macro_rules! set_bool { ($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; } }; }
|
||||
|
||||
if let Some(ref v) = data.density { enum_value("density", v, &["compact", "comfortable"])?; }
|
||||
if let Some(ref v) = data.theme { enum_value("theme", v, &["light", "dark", "system"])?; }
|
||||
if let Some(ref v) = data.compose_format { enum_value("compose_format", v, &["html", "text", "markdown"])?; }
|
||||
if let Some(v) = data.compose_autosave_ms { range("compose_autosave_ms", v, 1000, 60000)?; }
|
||||
if let Some(ref v) = data.default_priority { enum_value("default_priority", v, &["high", "normal", "low"])?; }
|
||||
if let Some(ref v) = data.preview_pane { enum_value("preview_pane", v, &["right", "bottom", "off"])?; }
|
||||
if let Some(ref v) = data.ui_scale { enum_value("ui_scale", v, &["small", "normal", "large"])?; }
|
||||
if let Some(ref v) = data.color_preset { enum_value("color_preset", v, &["sage", "blue", "teal", "cyan", "indigo", "slate", "graphite", "amber", "orange", "violet", "rose", "red", "custom"])?; }
|
||||
if let Some(ref v) = data.font_family { enum_value("font_family", v, &["system", "inter", "humanist", "mono"])?; }
|
||||
if let Some(ref v) = data.contrast { enum_value("contrast", v, &["soft", "normal", "high"])?; }
|
||||
if let Some(ref v) = data.language { enum_value("language", v, &["en", "pl"])?; }
|
||||
if let Some(ref v) = data.date_format { enum_value("date_format", v, &["locale", "iso", "eu", "us"])?; }
|
||||
if let Some(ref v) = data.time_format { enum_value("time_format", v, &["locale", "24h", "12h"])?; }
|
||||
if let Some(ref v) = data.toolbar_density { enum_value("toolbar_density", v, &["compact", "normal", "spacious"])?; }
|
||||
if let Some(ref v) = data.unread_badge_style { enum_value("unread_badge_style", v, &["dot", "outline", "solid"])?; }
|
||||
if let Some(ref v) = data.scrollbar_density { enum_value("scrollbar_density", v, &["hidden", "thin", "normal"])?; }
|
||||
if let Some(ref v) = data.compose_font_family { enum_value("compose_font_family", v, &["system", "humanist", "serif", "mono"])?; }
|
||||
if let Some(ref v) = data.reply_position { enum_value("reply_position", v, &["above", "below"])?; }
|
||||
if let Some(ref v) = data.quote_style { enum_value("quote_style", v, &["standard", "compact", "none"])?; }
|
||||
if let Some(ref v) = data.forward_mode { enum_value("forward_mode", v, &["inline", "attachment"])?; }
|
||||
if let Some(v) = data.font_size { range("font_size", v, 11, 18)?; }
|
||||
if let Some(v) = data.row_height { range("row_height", v, 42, 88)?; }
|
||||
if let Some(v) = data.folder_pane_width { range("folder_pane_width", v, 180, 360)?; }
|
||||
if let Some(v) = data.message_pane_width { range("message_pane_width", v, 300, 720)?; }
|
||||
if let Some(v) = data.border_radius { range("border_radius", v, 0, 12)?; }
|
||||
if let Some(v) = data.icon_size { range("icon_size", v, 14, 28)?; }
|
||||
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.composer_width { range("composer_width", v, 640, 1400)?; }
|
||||
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.background_color { if !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.text_color { if !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}")); } }
|
||||
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(ref mode) = data.animation_mode { if let Some(v) = mode { enum_value("animation_mode", v, &["rich", "medium", "subtle", "off"])?; } }
|
||||
|
||||
set_text!(density, "density"); set_text!(theme, "theme"); set_text!(language, "language"); set_text!(compose_format, "compose_format");
|
||||
set_int!(compose_autosave_ms, "compose_autosave_ms"); set_text!(default_priority, "default_priority"); set_bool!(request_mdn_default, "request_mdn_default"); set_bool!(attachment_reminder, "attachment_reminder");
|
||||
set_bool!(deep_index, "deep_index");
|
||||
if let Some(ref value) = data.animation_mode { sets.push(format!("animation_mode = ?{}", idx)); values.push(Box::new(value.clone())); idx += 1; }
|
||||
set_text!(preview_pane, "preview_pane"); set_text!(ui_scale, "ui_scale"); set_text!(color_preset, "color_preset"); set_text!(accent_color, "accent_color");
|
||||
set_text!(background_color, "background_color"); set_text!(panel_color, "panel_color"); set_text!(text_color, "text_color");
|
||||
set_text!(font_family, "font_family"); set_int!(font_size, "font_size"); set_int!(row_height, "row_height"); set_int!(folder_pane_width, "folder_pane_width");
|
||||
set_int!(message_pane_width, "message_pane_width"); set_int!(border_radius, "border_radius"); set_text!(contrast, "contrast");
|
||||
set_bool!(show_avatars, "show_avatars"); set_bool!(show_attachment_icons, "show_attachment_icons"); set_bool!(show_flags, "show_flags"); set_bool!(reduce_motion, "reduce_motion");
|
||||
set_text!(date_format, "date_format"); set_text!(time_format, "time_format");
|
||||
set_int!(icon_size, "icon_size"); set_text!(toolbar_density, "toolbar_density"); set_int!(sidebar_item_height, "sidebar_item_height");
|
||||
set_int!(reader_max_width, "reader_max_width"); set_int!(composer_width, "composer_width"); set_text!(selection_color, "selection_color");
|
||||
set_int!(unread_font_weight, "unread_font_weight"); set_text!(unread_badge_style, "unread_badge_style"); set_bool!(compact_headers, "compact_headers");
|
||||
set_bool!(show_borders, "show_borders"); set_text!(scrollbar_density, "scrollbar_density"); set_bool!(monochrome, "monochrome");
|
||||
set_text!(folder_background, "folder_background"); set_text!(message_background, "message_background"); set_text!(reader_background, "reader_background");
|
||||
set_text!(compose_font_family, "compose_font_family"); set_text!(reply_position, "reply_position"); set_text!(quote_style, "quote_style"); set_text!(forward_mode, "forward_mode"); set_bool!(spellcheck_enabled, "spellcheck_enabled");
|
||||
|
||||
if sets.is_empty() { return get_preferences(conn); }
|
||||
sets.push("updated_at = datetime('now')".to_string());
|
||||
let sql = format!("UPDATE display_preferences SET {} WHERE id = ?{}", 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()).map_err(|e| format!("Failed to update display preferences: {e}"))?;
|
||||
get_preferences(conn)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::pool::open_test_db;
|
||||
|
||||
#[test]
|
||||
fn defaults_and_personalization_roundtrip() {
|
||||
let conn = open_test_db();
|
||||
let defaults = get_preferences(&conn).unwrap();
|
||||
assert_eq!(defaults.color_preset, "sage");
|
||||
let update: UpdateDisplayPreferences = serde_json::from_value(serde_json::json!({
|
||||
"theme":"dark", "language":"pl", "color_preset":"blue", "accent_color":"#336699",
|
||||
"font_size":15, "row_height":64, "folder_pane_width":250, "message_pane_width":480,
|
||||
"show_avatars":false, "reduce_motion":true
|
||||
})).unwrap();
|
||||
let prefs = update_preferences(&conn, &update).unwrap();
|
||||
assert_eq!(prefs.language, "pl");
|
||||
assert_eq!(prefs.accent_color, "#336699");
|
||||
assert_eq!(prefs.font_size, 15);
|
||||
assert!(!prefs.show_avatars);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_customization_is_rejected() {
|
||||
let conn = open_test_db();
|
||||
let update: UpdateDisplayPreferences = serde_json::from_value(serde_json::json!({"accent_color":"red"})).unwrap();
|
||||
assert!(update_preferences(&conn, &update).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,469 @@
|
||||
use rusqlite::{Connection, params};
|
||||
use serde::Serialize;
|
||||
|
||||
/// A locally-saved draft message.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct Draft {
|
||||
pub id: String,
|
||||
pub to_addresses: String,
|
||||
pub cc_addresses: String,
|
||||
pub bcc_addresses: String,
|
||||
pub subject: String,
|
||||
pub text_body: String,
|
||||
pub html_body: Option<String>,
|
||||
pub in_reply_to: Option<String>,
|
||||
pub references_header: Option<String>,
|
||||
pub request_mdn: bool,
|
||||
pub priority: String,
|
||||
pub compose_format: String,
|
||||
pub pgp_mode: String,
|
||||
pub pgp_inline: bool,
|
||||
pub pgp_attach_public_key: bool,
|
||||
pub pgp_attach_recipient_keys: bool,
|
||||
pub remote_folder: Option<String>,
|
||||
pub remote_uid: Option<u32>,
|
||||
pub remote_uid_validity: Option<u32>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
/// An attachment associated with a draft.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct DraftAttachment {
|
||||
pub id: String,
|
||||
pub draft_id: String,
|
||||
pub filename: String,
|
||||
pub content_type: String,
|
||||
pub size: i64,
|
||||
pub file_path: String,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
|
||||
/// Validate draft IDs before they are used as database keys or path segments.
|
||||
pub fn validate_draft_id(id: &str) -> Result<(), String> {
|
||||
if id.is_empty() || id.len() > 128 {
|
||||
return Err("Draft ID must be between 1 and 128 characters".to_string());
|
||||
}
|
||||
if !id
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
|
||||
{
|
||||
return Err("Draft ID contains unsupported characters".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Insert or update a draft. Uses INSERT OR REPLACE to upsert.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn upsert_draft(
|
||||
conn: &Connection,
|
||||
id: &str,
|
||||
to_addresses: &str,
|
||||
cc_addresses: &str,
|
||||
bcc_addresses: &str,
|
||||
subject: &str,
|
||||
text_body: &str,
|
||||
html_body: Option<&str>,
|
||||
in_reply_to: Option<&str>,
|
||||
references_header: Option<&str>,
|
||||
request_mdn: bool,
|
||||
priority: &str,
|
||||
compose_format: &str,
|
||||
pgp_mode: &str,
|
||||
pgp_inline: bool,
|
||||
pgp_attach_public_key: bool,
|
||||
pgp_attach_recipient_keys: bool,
|
||||
) -> 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')",
|
||||
params![
|
||||
id,
|
||||
to_addresses,
|
||||
cc_addresses,
|
||||
bcc_addresses,
|
||||
subject,
|
||||
text_body,
|
||||
html_body,
|
||||
in_reply_to,
|
||||
references_header,
|
||||
if request_mdn { 1 } else { 0 },
|
||||
priority,
|
||||
compose_format,
|
||||
pgp_mode,
|
||||
if pgp_inline { 1 } else { 0 },
|
||||
if pgp_attach_public_key { 1 } else { 0 },
|
||||
if pgp_attach_recipient_keys { 1 } else { 0 },
|
||||
],
|
||||
)
|
||||
.map_err(|e| format!("Failed to upsert draft: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Retrieve a single draft by ID.
|
||||
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",
|
||||
params![id],
|
||||
|row| {
|
||||
Ok(Draft {
|
||||
id: row.get(0)?,
|
||||
to_addresses: row.get(1)?,
|
||||
cc_addresses: row.get(2)?,
|
||||
bcc_addresses: row.get(3)?,
|
||||
subject: row.get(4)?,
|
||||
text_body: row.get(5)?,
|
||||
html_body: row.get(6)?,
|
||||
in_reply_to: row.get(7)?,
|
||||
references_header: row.get(8)?,
|
||||
request_mdn: row.get::<_, i64>(9)? != 0,
|
||||
priority: row.get(10)?,
|
||||
compose_format: row.get(11)?,
|
||||
pgp_mode: row.get(12)?,
|
||||
pgp_inline: row.get::<_, i64>(13)? != 0,
|
||||
pgp_attach_public_key: row.get::<_, i64>(14)? != 0,
|
||||
pgp_attach_recipient_keys: row.get::<_, i64>(15)? != 0,
|
||||
remote_folder: row.get(16)?,
|
||||
remote_uid: row.get(17)?,
|
||||
remote_uid_validity: row.get(18)?,
|
||||
created_at: row.get(19)?,
|
||||
updated_at: row.get(20)?,
|
||||
})
|
||||
},
|
||||
);
|
||||
|
||||
match result {
|
||||
Ok(draft) => Ok(Some(draft)),
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||||
Err(e) => Err(format!("Failed to get draft: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// List all drafts, ordered by updated_at descending.
|
||||
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",
|
||||
)
|
||||
.map_err(|e| format!("Failed to prepare list_drafts: {e}"))?;
|
||||
|
||||
let rows = stmt
|
||||
.query_map([], |row| {
|
||||
Ok(Draft {
|
||||
id: row.get(0)?,
|
||||
to_addresses: row.get(1)?,
|
||||
cc_addresses: row.get(2)?,
|
||||
bcc_addresses: row.get(3)?,
|
||||
subject: row.get(4)?,
|
||||
text_body: row.get(5)?,
|
||||
html_body: row.get(6)?,
|
||||
in_reply_to: row.get(7)?,
|
||||
references_header: row.get(8)?,
|
||||
request_mdn: row.get::<_, i64>(9)? != 0,
|
||||
priority: row.get(10)?,
|
||||
compose_format: row.get(11)?,
|
||||
pgp_mode: row.get(12)?,
|
||||
pgp_inline: row.get::<_, i64>(13)? != 0,
|
||||
pgp_attach_public_key: row.get::<_, i64>(14)? != 0,
|
||||
pgp_attach_recipient_keys: row.get::<_, i64>(15)? != 0,
|
||||
remote_folder: row.get(16)?,
|
||||
remote_uid: row.get(17)?,
|
||||
remote_uid_validity: row.get(18)?,
|
||||
created_at: row.get(19)?,
|
||||
updated_at: row.get(20)?,
|
||||
})
|
||||
})
|
||||
.map_err(|e| format!("Failed to query drafts: {e}"))?;
|
||||
|
||||
let mut drafts = Vec::new();
|
||||
for row in rows {
|
||||
drafts.push(row.map_err(|e| format!("Failed to read draft row: {e}"))?);
|
||||
}
|
||||
Ok(drafts)
|
||||
}
|
||||
|
||||
|
||||
/// Update the IMAP location of the latest remote snapshot for a local draft.
|
||||
pub fn set_remote_location(
|
||||
conn: &Connection,
|
||||
id: &str,
|
||||
folder: &str,
|
||||
uid: u32,
|
||||
uid_validity: u32,
|
||||
) -> Result<(), String> {
|
||||
let updated = conn
|
||||
.execute(
|
||||
"UPDATE drafts SET remote_folder = ?2, remote_uid = ?3, remote_uid_validity = ?4 WHERE id = ?1",
|
||||
params![id, folder, uid, uid_validity],
|
||||
)
|
||||
.map_err(|e| format!("Failed to update remote draft location: {e}"))?;
|
||||
if updated == 0 {
|
||||
return Err("Draft not found while updating remote location".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete a draft by ID. Cascade will handle attachments in DB.
|
||||
pub fn delete_draft(conn: &Connection, id: &str) -> Result<bool, String> {
|
||||
let deleted = conn
|
||||
.execute("DELETE FROM drafts WHERE id = ?1", params![id])
|
||||
.map_err(|e| format!("Failed to delete draft: {e}"))?;
|
||||
Ok(deleted > 0)
|
||||
}
|
||||
|
||||
/// Add an attachment record for a draft.
|
||||
pub fn add_draft_attachment(
|
||||
conn: &Connection,
|
||||
id: &str,
|
||||
draft_id: &str,
|
||||
filename: &str,
|
||||
content_type: &str,
|
||||
size: i64,
|
||||
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)",
|
||||
params![id, draft_id, filename, content_type, size, file_path],
|
||||
)
|
||||
.map_err(|e| format!("Failed to add draft attachment: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get all attachments for a draft.
|
||||
pub fn get_draft_attachments(
|
||||
conn: &Connection,
|
||||
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",
|
||||
)
|
||||
.map_err(|e| format!("Failed to prepare get_draft_attachments: {e}"))?;
|
||||
|
||||
let rows = stmt
|
||||
.query_map(params![draft_id], |row| {
|
||||
Ok(DraftAttachment {
|
||||
id: row.get(0)?,
|
||||
draft_id: row.get(1)?,
|
||||
filename: row.get(2)?,
|
||||
content_type: row.get(3)?,
|
||||
size: row.get(4)?,
|
||||
file_path: row.get(5)?,
|
||||
created_at: row.get(6)?,
|
||||
})
|
||||
})
|
||||
.map_err(|e| format!("Failed to query draft attachments: {e}"))?;
|
||||
|
||||
let mut attachments = Vec::new();
|
||||
for row in rows {
|
||||
attachments.push(row.map_err(|e| format!("Failed to read attachment row: {e}"))?);
|
||||
}
|
||||
Ok(attachments)
|
||||
}
|
||||
|
||||
/// Delete a single attachment by ID.
|
||||
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])
|
||||
.map_err(|e| format!("Failed to delete draft attachment: {e}"))?;
|
||||
Ok(deleted > 0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::pool::open_test_db;
|
||||
|
||||
#[test]
|
||||
fn test_upsert_and_get_draft() {
|
||||
let conn = open_test_db();
|
||||
|
||||
upsert_draft(
|
||||
&conn,
|
||||
"draft-1",
|
||||
"bob@example.com",
|
||||
"",
|
||||
"",
|
||||
"Hello",
|
||||
"Hi Bob",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
true,
|
||||
"high",
|
||||
"html",
|
||||
"none",
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let draft = get_draft(&conn, "draft-1").unwrap().unwrap();
|
||||
assert_eq!(draft.id, "draft-1");
|
||||
assert_eq!(draft.to_addresses, "bob@example.com");
|
||||
assert_eq!(draft.subject, "Hello");
|
||||
assert_eq!(draft.text_body, "Hi Bob");
|
||||
assert!(draft.html_body.is_none());
|
||||
assert!(draft.request_mdn);
|
||||
assert_eq!(draft.priority, "high");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_upsert_updates_existing() {
|
||||
let conn = open_test_db();
|
||||
|
||||
upsert_draft(&conn, "draft-1", "bob@example.com", "", "", "v1", "body1", None, None, None, false, "normal", "html", "none", false, false, false).unwrap();
|
||||
upsert_draft(&conn, "draft-1", "carol@example.com", "", "", "v2", "body2", None, None, None, false, "normal", "html", "none", false, false, false).unwrap();
|
||||
|
||||
let draft = get_draft(&conn, "draft-1").unwrap().unwrap();
|
||||
assert_eq!(draft.to_addresses, "carol@example.com");
|
||||
assert_eq!(draft.subject, "v2");
|
||||
assert_eq!(draft.text_body, "body2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_drafts_returns_all() {
|
||||
let conn = open_test_db();
|
||||
|
||||
upsert_draft(&conn, "draft-a", "", "", "", "First", "", None, None, None, false, "normal", "html", "none", false, false, false).unwrap();
|
||||
upsert_draft(&conn, "draft-b", "", "", "", "Second", "", None, None, None, false, "normal", "html", "none", false, false, false).unwrap();
|
||||
|
||||
let drafts = list_drafts(&conn).unwrap();
|
||||
assert_eq!(drafts.len(), 2);
|
||||
let ids: Vec<&str> = drafts.iter().map(|d| d.id.as_str()).collect();
|
||||
assert!(ids.contains(&"draft-a"));
|
||||
assert!(ids.contains(&"draft-b"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_draft() {
|
||||
let conn = open_test_db();
|
||||
|
||||
upsert_draft(&conn, "draft-1", "", "", "", "Test", "", None, None, None, false, "normal", "html", "none", false, false, false).unwrap();
|
||||
assert!(get_draft(&conn, "draft-1").unwrap().is_some());
|
||||
|
||||
let deleted = delete_draft(&conn, "draft-1").unwrap();
|
||||
assert!(deleted);
|
||||
assert!(get_draft(&conn, "draft-1").unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_location_survives_local_draft_update() {
|
||||
let conn = open_test_db();
|
||||
upsert_draft(&conn, "draft-1", "", "", "", "v1", "body", None, None, None, false, "normal", "html", "none", false, false, false).unwrap();
|
||||
set_remote_location(&conn, "draft-1", "Drafts", 42, 7).unwrap();
|
||||
upsert_draft(&conn, "draft-1", "", "", "", "v2", "body2", None, None, None, false, "normal", "html", "none", false, false, false).unwrap();
|
||||
let draft = get_draft(&conn, "draft-1").unwrap().unwrap();
|
||||
assert_eq!(draft.remote_folder.as_deref(), Some("Drafts"));
|
||||
assert_eq!(draft.remote_uid, Some(42));
|
||||
assert_eq!(draft.remote_uid_validity, Some(7));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draft_id_validation_rejects_path_traversal() {
|
||||
assert!(validate_draft_id("normal-id_123").is_ok());
|
||||
assert!(validate_draft_id("../escape").is_err());
|
||||
assert!(validate_draft_id("folder/draft").is_err());
|
||||
assert!(validate_draft_id("").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_nonexistent_draft() {
|
||||
let conn = open_test_db();
|
||||
let deleted = delete_draft(&conn, "no-such-draft").unwrap();
|
||||
assert!(!deleted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_nonexistent_draft() {
|
||||
let conn = open_test_db();
|
||||
assert!(get_draft(&conn, "no-such-draft").unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_add_and_get_attachments() {
|
||||
let conn = open_test_db();
|
||||
|
||||
upsert_draft(&conn, "draft-1", "", "", "", "Test", "", None, None, None, false, "normal", "html", "none", false, false, false).unwrap();
|
||||
|
||||
add_draft_attachment(
|
||||
&conn,
|
||||
"att-1",
|
||||
"draft-1",
|
||||
"file.pdf",
|
||||
"application/pdf",
|
||||
1024,
|
||||
"/data/abc/attachments/draft-1/att-1",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
add_draft_attachment(
|
||||
&conn,
|
||||
"att-2",
|
||||
"draft-1",
|
||||
"photo.jpg",
|
||||
"image/jpeg",
|
||||
2048,
|
||||
"/data/abc/attachments/draft-1/att-2",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let atts = get_draft_attachments(&conn, "draft-1").unwrap();
|
||||
assert_eq!(atts.len(), 2);
|
||||
assert_eq!(atts[0].filename, "file.pdf");
|
||||
assert_eq!(atts[0].size, 1024);
|
||||
assert_eq!(atts[1].filename, "photo.jpg");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_attachment() {
|
||||
let conn = open_test_db();
|
||||
|
||||
upsert_draft(&conn, "draft-1", "", "", "", "Test", "", None, None, None, false, "normal", "html", "none", false, false, false).unwrap();
|
||||
add_draft_attachment(&conn, "att-1", "draft-1", "file.pdf", "application/pdf", 100, "/path").unwrap();
|
||||
|
||||
let deleted = delete_draft_attachment(&conn, "att-1").unwrap();
|
||||
assert!(deleted);
|
||||
|
||||
let atts = get_draft_attachments(&conn, "draft-1").unwrap();
|
||||
assert!(atts.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cascade_delete_attachments_on_draft_delete() {
|
||||
let conn = open_test_db();
|
||||
|
||||
upsert_draft(&conn, "draft-1", "", "", "", "Test", "", None, None, None, false, "normal", "html", "none", false, false, false).unwrap();
|
||||
add_draft_attachment(&conn, "att-1", "draft-1", "file.pdf", "application/pdf", 100, "/path").unwrap();
|
||||
add_draft_attachment(&conn, "att-2", "draft-1", "pic.jpg", "image/jpeg", 200, "/path2").unwrap();
|
||||
|
||||
delete_draft(&conn, "draft-1").unwrap();
|
||||
|
||||
let atts = get_draft_attachments(&conn, "draft-1").unwrap();
|
||||
assert!(atts.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
use rusqlite::{Connection, OptionalExtension, params};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct FolderPreferences {
|
||||
pub flagged_on_top: Option<bool>,
|
||||
pub effective_flagged_on_top: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateFolderPreferences {
|
||||
pub flagged_on_top: Option<bool>,
|
||||
}
|
||||
|
||||
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",
|
||||
[folder],
|
||||
|row| row.get::<_, Option<i32>>(0),
|
||||
)
|
||||
.optional()
|
||||
.map(|value| value.flatten().map(|value| value != 0))
|
||||
.map_err(|error| format!("Failed to read folder preferences: {error}"))
|
||||
}
|
||||
|
||||
pub fn effective_flagged_on_top(conn: &Connection, folder: &str) -> Result<bool, String> {
|
||||
if let Some(value) = get_override(conn, folder)? {
|
||||
return Ok(value);
|
||||
}
|
||||
let global = conn
|
||||
.query_row(
|
||||
"SELECT flagged_on_top FROM mail_preferences WHERE id = 1",
|
||||
[],
|
||||
|row| row.get::<_, i32>(0),
|
||||
)
|
||||
.unwrap_or(0);
|
||||
Ok(global != 0)
|
||||
}
|
||||
|
||||
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)",
|
||||
[folder],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(|error| format!("Failed to validate folder: {error}"))?;
|
||||
if !exists {
|
||||
return Err(format!("Folder does not exist: {folder}"));
|
||||
}
|
||||
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",
|
||||
[],
|
||||
|row| row.get::<_, i32>(0),
|
||||
)
|
||||
.unwrap_or(0)
|
||||
!= 0,
|
||||
);
|
||||
Ok(FolderPreferences {
|
||||
flagged_on_top,
|
||||
effective_flagged_on_top,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn update_preferences(
|
||||
conn: &Connection,
|
||||
folder: &str,
|
||||
data: &UpdateFolderPreferences,
|
||||
) -> Result<FolderPreferences, String> {
|
||||
let exists: bool = conn
|
||||
.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM folders WHERE name = ?1)",
|
||||
[folder],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(|error| format!("Failed to validate folder: {error}"))?;
|
||||
if !exists {
|
||||
return Err(format!("Folder does not exist: {folder}"));
|
||||
}
|
||||
|
||||
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')",
|
||||
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}"))?;
|
||||
}
|
||||
}
|
||||
|
||||
get_preferences(conn, folder)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::{folders, mail_preferences};
|
||||
use crate::db::pool::open_test_db;
|
||||
|
||||
#[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);
|
||||
|
||||
mail_preferences::update_preferences(
|
||||
&conn,
|
||||
&mail_preferences::UpdateMailPreferences {
|
||||
messages_per_page: None,
|
||||
mark_read_delay_ms: None,
|
||||
remote_images: None,
|
||||
preferred_body: None,
|
||||
thread_mode: None,
|
||||
collapse_quotes: None,
|
||||
show_snippets: None,
|
||||
default_sort: None,
|
||||
flagged_on_top: Some(true),
|
||||
sync_imap_keywords: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert!(get_preferences(&conn, "INBOX").unwrap().effective_flagged_on_top);
|
||||
|
||||
let overridden = update_preferences(
|
||||
&conn,
|
||||
"INBOX",
|
||||
&UpdateFolderPreferences { flagged_on_top: Some(false) },
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(overridden.flagged_on_top, Some(false));
|
||||
assert!(!overridden.effective_flagged_on_top);
|
||||
|
||||
let inherited = update_preferences(
|
||||
&conn,
|
||||
"INBOX",
|
||||
&UpdateFolderPreferences { flagged_on_top: None },
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(inherited.flagged_on_top, None);
|
||||
assert!(inherited.effective_flagged_on_top);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,465 @@
|
||||
use rusqlite::{Connection, OptionalExtension, params};
|
||||
use serde::Serialize;
|
||||
|
||||
/// A cached IMAP folder, mirroring the `folders` table schema.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct CachedFolder {
|
||||
pub name: String,
|
||||
pub delimiter: Option<String>,
|
||||
pub parent: Option<String>,
|
||||
pub flags: String,
|
||||
pub is_subscribed: bool,
|
||||
pub total_count: u32,
|
||||
pub unread_count: u32,
|
||||
pub uid_validity: u32,
|
||||
pub highest_modseq: u64,
|
||||
}
|
||||
|
||||
/// Insert or replace a folder row in the `folders` table.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn upsert_folder(
|
||||
conn: &Connection,
|
||||
folder_name: &str,
|
||||
delimiter: Option<&str>,
|
||||
parent: Option<&str>,
|
||||
flags_csv: &str,
|
||||
is_subscribed: bool,
|
||||
total_count: u32,
|
||||
unread_count: u32,
|
||||
uid_validity: u32,
|
||||
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'))",
|
||||
params![
|
||||
folder_name,
|
||||
delimiter,
|
||||
parent,
|
||||
flags_csv,
|
||||
is_subscribed as i32,
|
||||
total_count,
|
||||
unread_count,
|
||||
uid_validity,
|
||||
highest_modseq as i64,
|
||||
],
|
||||
)
|
||||
.map_err(|e| format!("Failed to upsert folder: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Insert a folder only if it doesn't already exist.
|
||||
///
|
||||
/// Uses `INSERT OR IGNORE` to avoid triggering `ON DELETE CASCADE` on the
|
||||
/// messages table (which `INSERT OR REPLACE` would do since it is internally
|
||||
/// a DELETE + INSERT).
|
||||
pub fn insert_folder_if_new(
|
||||
conn: &Connection,
|
||||
folder_name: &str,
|
||||
delimiter: Option<&str>,
|
||||
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'))",
|
||||
params![folder_name, delimiter, flags_csv],
|
||||
)
|
||||
.map_err(|e| format!("Failed to insert folder: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Return all cached folders, sorted alphabetically by name.
|
||||
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",
|
||||
)
|
||||
.map_err(|e| format!("Failed to prepare get_all_folders: {e}"))?;
|
||||
|
||||
let rows = stmt
|
||||
.query_map([], |row| {
|
||||
let is_subscribed_int: i32 = row.get(4)?;
|
||||
let highest_modseq_int: i64 = row.get(8)?;
|
||||
Ok(CachedFolder {
|
||||
name: row.get(0)?,
|
||||
delimiter: row.get(1)?,
|
||||
parent: row.get(2)?,
|
||||
flags: row.get(3)?,
|
||||
is_subscribed: is_subscribed_int != 0,
|
||||
total_count: row.get(5)?,
|
||||
unread_count: row.get(6)?,
|
||||
uid_validity: row.get(7)?,
|
||||
highest_modseq: highest_modseq_int as u64,
|
||||
})
|
||||
})
|
||||
.map_err(|e| format!("Failed to query folders: {e}"))?;
|
||||
|
||||
let mut folders = Vec::new();
|
||||
for row in rows {
|
||||
folders.push(row.map_err(|e| format!("Failed to read folder row: {e}"))?);
|
||||
}
|
||||
Ok(folders)
|
||||
}
|
||||
|
||||
/// Delete folders whose names are NOT in the provided `current_names` list.
|
||||
/// Returns the number of deleted rows.
|
||||
pub fn remove_stale_folders(
|
||||
conn: &Connection,
|
||||
current_names: &[String],
|
||||
) -> Result<usize, String> {
|
||||
if current_names.is_empty() {
|
||||
// Delete all folders when the current list is empty.
|
||||
let deleted = conn
|
||||
.execute("DELETE FROM folders", [])
|
||||
.map_err(|e| format!("Failed to delete all folders: {e}"))?;
|
||||
return Ok(deleted);
|
||||
}
|
||||
|
||||
// Build a parameterized WHERE NOT IN clause.
|
||||
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 params: Vec<&dyn rusqlite::types::ToSql> = current_names
|
||||
.iter()
|
||||
.map(|n| n as &dyn rusqlite::types::ToSql)
|
||||
.collect();
|
||||
|
||||
let deleted = conn
|
||||
.execute(&sql, params.as_slice())
|
||||
.map_err(|e| format!("Failed to remove stale folders: {e}"))?;
|
||||
|
||||
Ok(deleted)
|
||||
}
|
||||
|
||||
/// Touch `updated_at` on all folders so the folder-list cache TTL resets.
|
||||
pub fn touch_all_folders(conn: &Connection) -> Result<(), String> {
|
||||
conn.execute(
|
||||
"UPDATE folders SET updated_at = datetime('now')",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| format!("Failed to touch folders: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update only the uid_validity and total_count for a folder (without CASCADE side-effects).
|
||||
/// Also touches `messages_updated_at` to mark when this folder's messages were last synced.
|
||||
pub fn update_folder_status(
|
||||
conn: &Connection,
|
||||
name: &str,
|
||||
uid_validity: u32,
|
||||
total_count: u32,
|
||||
) -> Result<(), String> {
|
||||
conn.execute(
|
||||
"UPDATE folders SET uid_validity = ?1, total_count = ?2, messages_updated_at = datetime('now')
|
||||
WHERE name = ?3",
|
||||
params![uid_validity, total_count, name],
|
||||
)
|
||||
.map_err(|e| format!("Failed to update folder status: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update server-side folder metadata after a CONDSTORE or STATUS check.
|
||||
/// Does not touch `messages_updated_at`: metadata alone does not mean the
|
||||
/// local message cache has been synchronized.
|
||||
pub fn update_folder_sync_status(
|
||||
conn: &Connection,
|
||||
name: &str,
|
||||
uid_validity: u32,
|
||||
total_count: u32,
|
||||
highest_modseq: u64,
|
||||
) -> Result<(), String> {
|
||||
conn.execute(
|
||||
"UPDATE folders SET uid_validity = ?1, total_count = ?2, highest_modseq = ?3
|
||||
WHERE name = ?4",
|
||||
params![uid_validity, total_count, highest_modseq as i64, name],
|
||||
)
|
||||
.map_err(|e| format!("Failed to update folder sync status: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Recompute and store the unread count for a folder from the messages table.
|
||||
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%'",
|
||||
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",
|
||||
params![unread, folder_name],
|
||||
)
|
||||
.map_err(|e| format!("Failed to update unread_count: {e}"))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Adjust a folder's unread count by a signed delta (positive to increase, negative to decrease).
|
||||
/// Clamps to zero to avoid negative counts.
|
||||
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",
|
||||
params![delta, folder_name],
|
||||
)
|
||||
.map_err(|e| format!("Failed to adjust unread_count: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check whether any folder was updated within the last `max_age_secs` seconds.
|
||||
/// Returns `true` if the cache is still fresh, `false` if stale or empty.
|
||||
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)
|
||||
)",
|
||||
params![format!("-{max_age_secs} seconds")],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(|e| format!("Failed to check folder cache freshness: {e}"))?;
|
||||
Ok(fresh)
|
||||
}
|
||||
|
||||
/// Check whether a specific folder's messages were synced within the last `max_age_secs` seconds.
|
||||
/// Uses `messages_updated_at` which is set by `update_folder_status` (message sync),
|
||||
/// independent of `updated_at` which tracks folder-list sync.
|
||||
pub fn is_folder_fresh(conn: &Connection, folder_name: &str, max_age_secs: u32) -> 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)
|
||||
)",
|
||||
params![folder_name, format!("-{max_age_secs} seconds")],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(|e| format!("Failed to check folder freshness: {e}"))?;
|
||||
Ok(fresh)
|
||||
}
|
||||
|
||||
/// Check whether a folder's messages cache has been invalidated
|
||||
/// (i.e., `messages_updated_at IS NULL`), meaning its unread count was
|
||||
/// manually adjusted and should not be recomputed from the messages table.
|
||||
pub fn is_folder_messages_invalidated(conn: &Connection, folder_name: &str) -> Result<bool, String> {
|
||||
let invalidated: bool = conn
|
||||
.query_row(
|
||||
"SELECT messages_updated_at IS NULL FROM folders WHERE name = ?1",
|
||||
params![folder_name],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap_or(false);
|
||||
Ok(invalidated)
|
||||
}
|
||||
|
||||
/// Clear a folder's `messages_updated_at` so the next `is_folder_fresh` check
|
||||
/// returns `false` and forces an IMAP resync.
|
||||
pub fn invalidate_folder_freshness(conn: &Connection, folder_name: &str) -> Result<(), String> {
|
||||
conn.execute(
|
||||
"UPDATE folders SET messages_updated_at = NULL WHERE name = ?1",
|
||||
params![folder_name],
|
||||
)
|
||||
.map_err(|e| format!("Failed to invalidate folder freshness: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Return a single folder by name, or `None` if not found.
|
||||
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",
|
||||
params![name],
|
||||
|row| {
|
||||
let is_subscribed_int: i32 = row.get(4)?;
|
||||
let highest_modseq_int: i64 = row.get(8)?;
|
||||
Ok(CachedFolder {
|
||||
name: row.get(0)?,
|
||||
delimiter: row.get(1)?,
|
||||
parent: row.get(2)?,
|
||||
flags: row.get(3)?,
|
||||
is_subscribed: is_subscribed_int != 0,
|
||||
total_count: row.get(5)?,
|
||||
unread_count: row.get(6)?,
|
||||
uid_validity: row.get(7)?,
|
||||
highest_modseq: highest_modseq_int as u64,
|
||||
})
|
||||
},
|
||||
);
|
||||
|
||||
match result {
|
||||
Ok(folder) => Ok(Some(folder)),
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||||
Err(e) => Err(format!("Failed to get folder: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete a folder and all its cached messages.
|
||||
pub fn delete_folder_and_messages(conn: &Connection, folder_name: &str) -> Result<(), String> {
|
||||
conn.execute("DELETE FROM messages WHERE folder = ?1", 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}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Rename a folder in the cache, updating both folders and messages tables.
|
||||
pub fn rename_folder_in_cache(conn: &Connection, old_name: &str, new_name: &str) -> Result<(), String> {
|
||||
let tx = conn.unchecked_transaction().map_err(|e| format!("Failed to start folder rename transaction: {e}"))?;
|
||||
tx.execute_batch("PRAGMA defer_foreign_keys = ON;")
|
||||
.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}"))?;
|
||||
if inserted == 0 {
|
||||
return Err(format!("Folder not found in cache: {old_name}"));
|
||||
}
|
||||
|
||||
tx.execute("UPDATE messages SET folder = ?1 WHERE folder = ?2", 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", 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", params![new_name, old_name])
|
||||
.ok();
|
||||
tx.execute("UPDATE folders SET parent = ?1 WHERE parent = ?2", 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.commit().map_err(|e| format!("Failed to commit folder rename: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Find a folder whose `flags` column contains the given attribute (e.g. `\Drafts`, `\Sent`).
|
||||
/// Returns the folder name if found.
|
||||
pub fn find_folder_by_attribute(conn: &Connection, attribute: &str) -> Result<Option<String>, String> {
|
||||
conn.query_row(
|
||||
"SELECT name FROM folders WHERE flags LIKE '%' || ?1 || '%' LIMIT 1",
|
||||
[attribute],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::pool::open_test_db;
|
||||
|
||||
#[test]
|
||||
fn test_upsert_and_get_folders() {
|
||||
let conn = open_test_db();
|
||||
|
||||
upsert_folder(&conn, "INBOX", Some("/"), None, "\\HasNoChildren", true, 42, 5, 100, 200)
|
||||
.unwrap();
|
||||
upsert_folder(&conn, "Sent", Some("/"), None, "\\Sent", true, 10, 0, 101, 300)
|
||||
.unwrap();
|
||||
|
||||
let folders = get_all_folders(&conn).unwrap();
|
||||
assert_eq!(folders.len(), 2);
|
||||
|
||||
// Sorted alphabetically: INBOX < Sent
|
||||
assert_eq!(folders[0].name, "INBOX");
|
||||
assert_eq!(folders[0].total_count, 42);
|
||||
assert_eq!(folders[0].unread_count, 5);
|
||||
assert!(folders[0].is_subscribed);
|
||||
|
||||
assert_eq!(folders[1].name, "Sent");
|
||||
assert_eq!(folders[1].highest_modseq, 300);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_upsert_updates_existing_folder() {
|
||||
let conn = open_test_db();
|
||||
|
||||
upsert_folder(&conn, "INBOX", Some("/"), None, "\\HasNoChildren", true, 10, 2, 100, 50)
|
||||
.unwrap();
|
||||
|
||||
// Upsert again with different counts.
|
||||
upsert_folder(&conn, "INBOX", Some("/"), None, "\\HasNoChildren", true, 99, 33, 100, 75)
|
||||
.unwrap();
|
||||
|
||||
let folders = get_all_folders(&conn).unwrap();
|
||||
assert_eq!(folders.len(), 1);
|
||||
assert_eq!(folders[0].total_count, 99);
|
||||
assert_eq!(folders[0].unread_count, 33);
|
||||
assert_eq!(folders[0].highest_modseq, 75);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_metadata_does_not_revalidate_invalidated_messages() {
|
||||
let conn = open_test_db();
|
||||
insert_folder_if_new(&conn, "Sent", None, "\\Sent").unwrap();
|
||||
update_folder_status(&conn, "Sent", 1, 0).unwrap();
|
||||
assert!(is_folder_fresh(&conn, "Sent", 30).unwrap());
|
||||
|
||||
invalidate_folder_freshness(&conn, "Sent").unwrap();
|
||||
update_folder_sync_status(&conn, "Sent", 2, 1, 42).unwrap();
|
||||
|
||||
assert!(is_folder_messages_invalidated(&conn, "Sent").unwrap());
|
||||
assert!(!is_folder_fresh(&conn, "Sent", 30).unwrap());
|
||||
assert_eq!(crate::db::messages::count_messages(&conn, "Sent").unwrap(), 0);
|
||||
let folder = get_folder(&conn, "Sent").unwrap().unwrap();
|
||||
assert_eq!(folder.uid_validity, 2);
|
||||
assert_eq!(folder.total_count, 1);
|
||||
assert_eq!(folder.highest_modseq, 42);
|
||||
}
|
||||
|
||||
#[test]
|
||||
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();
|
||||
|
||||
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'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
).unwrap();
|
||||
assert_eq!(timestamp, "2000-01-01 00:00:00");
|
||||
assert!(!is_folder_fresh(&conn, "Sent", 30).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_remove_stale_folders() {
|
||||
let conn = open_test_db();
|
||||
|
||||
upsert_folder(&conn, "INBOX", None, None, "", true, 0, 0, 0, 0).unwrap();
|
||||
upsert_folder(&conn, "Sent", None, None, "", true, 0, 0, 0, 0).unwrap();
|
||||
upsert_folder(&conn, "Trash", None, None, "", true, 0, 0, 0, 0).unwrap();
|
||||
|
||||
// Keep only INBOX and Sent; Trash should be removed.
|
||||
let current = vec!["INBOX".to_string(), "Sent".to_string()];
|
||||
let deleted = remove_stale_folders(&conn, ¤t).unwrap();
|
||||
assert_eq!(deleted, 1);
|
||||
|
||||
let folders = get_all_folders(&conn).unwrap();
|
||||
let names: Vec<&str> = folders.iter().map(|f| f.name.as_str()).collect();
|
||||
assert!(names.contains(&"INBOX"));
|
||||
assert!(names.contains(&"Sent"));
|
||||
assert!(!names.contains(&"Trash"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
//! Reject header batches whose IMAP reads overlap a completed cache removal.
|
||||
//!
|
||||
//! The generation is per user database, not per UID: a later sync may discover
|
||||
//! new destination UIDs or reuse UIDs after UIDVALIDITY changes. No tombstones
|
||||
//! or locks held over network calls are needed.
|
||||
|
||||
use rusqlite::{Connection, OptionalExtension, Transaction, TransactionBehavior};
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) struct HeaderSync {
|
||||
generation: u64,
|
||||
}
|
||||
|
||||
impl HeaderSync {
|
||||
/// Capture before reading IMAP state, and retain for the whole sync pass.
|
||||
pub(crate) fn capture(conn: &Connection) -> Result<Self, String> {
|
||||
let generation = conn
|
||||
.query_row(
|
||||
"SELECT value FROM user_meta WHERE key = 'header_sync_generation'",
|
||||
[],
|
||||
|row| row.get::<_, String>(0),
|
||||
)
|
||||
.optional()
|
||||
.map_err(|e| e.to_string())?
|
||||
.map(|value| value.parse::<u64>().map_err(|e| e.to_string()))
|
||||
.transpose()?
|
||||
.unwrap_or(0);
|
||||
Ok(Self { generation })
|
||||
}
|
||||
|
||||
/// Serialize the check and all header/index/status writes with removals.
|
||||
pub(crate) fn begin_write(self, conn: &Connection) -> Result<Transaction<'_>, String> {
|
||||
let transaction = Transaction::new_unchecked(conn, TransactionBehavior::Immediate)
|
||||
.map_err(|e| e.to_string())?;
|
||||
if Self::capture(conn)?.generation != self.generation {
|
||||
return Err("Header sync superseded by cache removal; retry on next sync".into());
|
||||
}
|
||||
Ok(transaction)
|
||||
}
|
||||
}
|
||||
|
||||
/// Must run in the same write transaction as the removal or UIDVALIDITY reset.
|
||||
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())?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
use rusqlite::{params, Connection};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct Identity {
|
||||
pub id: i64,
|
||||
pub display_name: String,
|
||||
pub email: String,
|
||||
pub signature_html: String,
|
||||
pub is_default: bool,
|
||||
pub reply_to: String,
|
||||
pub bcc: String,
|
||||
pub organization: String,
|
||||
pub signature_text: String,
|
||||
pub signature_new: bool,
|
||||
pub signature_reply: bool,
|
||||
pub signature_forward: bool,
|
||||
pub signature_position: String,
|
||||
pub pgp_fingerprint: Option<String>,
|
||||
pub pgp_sign_default: bool,
|
||||
pub pgp_encrypt_default: bool,
|
||||
pub pgp_inline_default: bool,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateIdentity {
|
||||
#[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,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateIdentity {
|
||||
pub display_name: Option<String>,
|
||||
pub email: Option<String>,
|
||||
pub signature_html: Option<String>,
|
||||
pub is_default: Option<bool>,
|
||||
pub reply_to: Option<String>,
|
||||
pub bcc: Option<String>,
|
||||
pub organization: Option<String>,
|
||||
pub signature_text: Option<String>,
|
||||
pub signature_new: Option<bool>,
|
||||
pub signature_reply: Option<bool>,
|
||||
pub signature_forward: Option<bool>,
|
||||
pub signature_position: Option<String>,
|
||||
pub pgp_fingerprint: Option<Option<String>>,
|
||||
pub pgp_sign_default: Option<bool>,
|
||||
pub pgp_encrypt_default: Option<bool>,
|
||||
pub pgp_inline_default: Option<bool>,
|
||||
}
|
||||
|
||||
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}")) }
|
||||
}
|
||||
|
||||
fn row_to_identity(row: &rusqlite::Row<'_>) -> rusqlite::Result<Identity> {
|
||||
Ok(Identity {
|
||||
id: row.get(0)?,
|
||||
display_name: row.get(1)?,
|
||||
email: row.get(2)?,
|
||||
signature_html: row.get(3)?,
|
||||
is_default: row.get::<_, i32>(4)? != 0,
|
||||
created_at: row.get(5)?,
|
||||
updated_at: row.get(6)?,
|
||||
reply_to: row.get(7)?,
|
||||
bcc: row.get(8)?,
|
||||
organization: row.get(9)?,
|
||||
signature_text: row.get(10)?,
|
||||
signature_new: row.get::<_, i32>(11)? != 0,
|
||||
signature_reply: row.get::<_, i32>(12)? != 0,
|
||||
signature_forward: row.get::<_, i32>(13)? != 0,
|
||||
signature_position: row.get(14)?,
|
||||
pgp_fingerprint: row.get(15)?,
|
||||
pgp_sign_default: row.get::<_, i32>(16)? != 0,
|
||||
pgp_encrypt_default: row.get::<_, i32>(17)? != 0,
|
||||
pgp_inline_default: row.get::<_, i32>(18)? != 0,
|
||||
})
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
pub fn get_identity(conn: &Connection, id: i64) -> Result<Option<Identity>, String> {
|
||||
let sql = format!("SELECT {IDENTITY_SELECT_COLS} FROM identities WHERE id = ?1");
|
||||
match conn.query_row(&sql, params![id], row_to_identity) {
|
||||
Ok(identity) => Ok(Some(identity)),
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||||
Err(e) => Err(format!("Failed to get identity: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
#[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");
|
||||
match conn.query_row(&sql, [], row_to_identity) {
|
||||
Ok(identity) => Ok(Some(identity)),
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||||
Err(e) => Err(format!("Failed to get default identity: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn create_identity(conn: &Connection, data: &CreateIdentity) -> Result<Identity, String> {
|
||||
validate_signature_position(&data.signature_position)?;
|
||||
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}"))?;
|
||||
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)?; }
|
||||
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); }
|
||||
sets.push("updated_at = datetime('now')".to_string());
|
||||
let sql = format!("UPDATE identities SET {} WHERE id = ?{}", 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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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}"))?;
|
||||
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}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
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 }
|
||||
}
|
||||
|
||||
#[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");
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
|
||||
#[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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
use rusqlite::Connection;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct MailPreferences {
|
||||
pub messages_per_page: u32,
|
||||
pub mark_read_delay_ms: u64,
|
||||
pub remote_images: String,
|
||||
pub preferred_body: String,
|
||||
pub thread_mode: String,
|
||||
pub collapse_quotes: bool,
|
||||
pub show_snippets: bool,
|
||||
pub default_sort: String,
|
||||
pub flagged_on_top: bool,
|
||||
pub sync_imap_keywords: bool,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateMailPreferences {
|
||||
pub messages_per_page: Option<u32>,
|
||||
pub mark_read_delay_ms: Option<u64>,
|
||||
pub remote_images: Option<String>,
|
||||
pub preferred_body: Option<String>,
|
||||
pub thread_mode: Option<String>,
|
||||
pub collapse_quotes: Option<bool>,
|
||||
pub show_snippets: Option<bool>,
|
||||
pub default_sort: Option<String>,
|
||||
pub flagged_on_top: Option<bool>,
|
||||
pub sync_imap_keywords: Option<bool>,
|
||||
}
|
||||
|
||||
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",
|
||||
[],
|
||||
|row| {
|
||||
Ok(MailPreferences {
|
||||
messages_per_page: row.get::<_, u32>(0)?,
|
||||
mark_read_delay_ms: row.get::<_, u64>(1)?,
|
||||
remote_images: row.get(2)?,
|
||||
preferred_body: row.get(3)?,
|
||||
thread_mode: row.get(4)?,
|
||||
collapse_quotes: row.get::<_, i32>(5)? != 0,
|
||||
show_snippets: row.get::<_, i32>(6)? != 0,
|
||||
default_sort: row.get(7)?,
|
||||
flagged_on_top: row.get::<_, i32>(8)? != 0,
|
||||
sync_imap_keywords: row.get::<_, i32>(9)? != 0,
|
||||
updated_at: row.get(10)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.map_err(|error| format!("Failed to read mail preferences: {error}"))
|
||||
}
|
||||
|
||||
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}"))?;
|
||||
|
||||
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.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(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.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.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.default_sort {
|
||||
if !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());
|
||||
}
|
||||
}
|
||||
|
||||
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 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 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);
|
||||
|
||||
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],
|
||||
)
|
||||
.map_err(|error| format!("Failed to update mail preferences: {error}"))?;
|
||||
|
||||
get_preferences(conn)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::pool::open_test_db;
|
||||
|
||||
#[test]
|
||||
fn defaults_and_update_work() {
|
||||
let conn = open_test_db();
|
||||
let defaults = get_preferences(&conn).unwrap();
|
||||
assert_eq!(defaults.messages_per_page, 50);
|
||||
assert_eq!(defaults.remote_images, "ask");
|
||||
|
||||
let updated = update_preferences(
|
||||
&conn,
|
||||
&UpdateMailPreferences {
|
||||
messages_per_page: Some(100),
|
||||
mark_read_delay_ms: Some(1500),
|
||||
remote_images: Some("never".to_string()),
|
||||
preferred_body: Some("text".to_string()),
|
||||
thread_mode: Some("flat".to_string()),
|
||||
collapse_quotes: Some(false),
|
||||
show_snippets: Some(false),
|
||||
default_sort: Some("date_asc".to_string()),
|
||||
flagged_on_top: Some(true),
|
||||
sync_imap_keywords: Some(true),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(updated.messages_per_page, 100);
|
||||
assert_eq!(updated.mark_read_delay_ms, 1500);
|
||||
assert_eq!(updated.thread_mode, "flat");
|
||||
assert!(updated.flagged_on_top);
|
||||
assert!(updated.sync_imap_keywords);
|
||||
}
|
||||
}
|
||||
+1180
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,18 @@
|
||||
pub mod calendar;
|
||||
pub mod contact_groups;
|
||||
pub mod contacts;
|
||||
pub mod display_preferences;
|
||||
pub mod drafts;
|
||||
pub mod folders;
|
||||
pub mod folder_preferences;
|
||||
pub(crate) mod header_sync;
|
||||
pub mod identities;
|
||||
pub mod mail_preferences;
|
||||
pub mod messages;
|
||||
pub mod notification_preferences;
|
||||
pub mod pgp;
|
||||
pub mod pool;
|
||||
pub mod special_folders;
|
||||
pub mod tags;
|
||||
|
||||
pub mod saved_searches;
|
||||
@@ -0,0 +1,227 @@
|
||||
use rusqlite::Connection;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct NotificationPreferences {
|
||||
pub enabled: bool,
|
||||
pub sound: bool,
|
||||
pub folders: Vec<String>,
|
||||
pub dnd_enabled: bool,
|
||||
pub dnd_start: String,
|
||||
pub dnd_end: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateNotificationPreferences {
|
||||
pub enabled: Option<bool>,
|
||||
pub sound: Option<bool>,
|
||||
pub folders: Option<Vec<String>>,
|
||||
pub dnd_enabled: Option<bool>,
|
||||
pub dnd_start: Option<String>,
|
||||
pub dnd_end: Option<String>,
|
||||
}
|
||||
|
||||
/// Retrieve the singleton notification preferences row.
|
||||
/// Returns sensible defaults if the row does not yet exist.
|
||||
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",
|
||||
[],
|
||||
|row| {
|
||||
let enabled_int: i32 = row.get(0)?;
|
||||
let sound_int: i32 = row.get(1)?;
|
||||
let folders_json: String = row.get(2)?;
|
||||
let dnd_enabled_int: i32 = row.get(3)?;
|
||||
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))
|
||||
},
|
||||
);
|
||||
|
||||
match result {
|
||||
Ok((enabled_int, sound_int, folders_json, dnd_enabled_int, dnd_start, dnd_end, updated_at)) => {
|
||||
let folders: Vec<String> = serde_json::from_str(&folders_json)
|
||||
.map_err(|e| format!("Failed to parse folders JSON: {e}"))?;
|
||||
Ok(NotificationPreferences {
|
||||
enabled: enabled_int != 0,
|
||||
sound: sound_int != 0,
|
||||
folders,
|
||||
dnd_enabled: dnd_enabled_int != 0,
|
||||
dnd_start,
|
||||
dnd_end,
|
||||
updated_at,
|
||||
})
|
||||
}
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(NotificationPreferences {
|
||||
enabled: true,
|
||||
sound: false,
|
||||
folders: vec!["INBOX".to_string()],
|
||||
dnd_enabled: false,
|
||||
dnd_start: "22:00".to_string(),
|
||||
dnd_end: "07:00".to_string(),
|
||||
updated_at: String::new(),
|
||||
}),
|
||||
Err(e) => Err(format!("Failed to get notification preferences: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Update the singleton notification preferences row.
|
||||
/// Only provided fields are changed. Returns the updated preferences.
|
||||
pub fn update_preferences(
|
||||
conn: &Connection,
|
||||
data: &UpdateNotificationPreferences,
|
||||
) -> Result<NotificationPreferences, String> {
|
||||
// Ensure the row exists.
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO notification_preferences (id) VALUES (1)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| format!("Failed to ensure preferences row: {e}"))?;
|
||||
|
||||
let mut sets = Vec::new();
|
||||
let mut values: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
|
||||
let mut idx = 1;
|
||||
|
||||
if let Some(enabled) = data.enabled {
|
||||
sets.push(format!("enabled = ?{idx}"));
|
||||
values.push(Box::new(enabled as i32));
|
||||
idx += 1;
|
||||
}
|
||||
if let Some(sound) = data.sound {
|
||||
sets.push(format!("sound = ?{idx}"));
|
||||
values.push(Box::new(sound as i32));
|
||||
idx += 1;
|
||||
}
|
||||
if let Some(ref folders) = data.folders {
|
||||
let folders_json =
|
||||
serde_json::to_string(folders).map_err(|e| format!("Failed to serialize folders: {e}"))?;
|
||||
sets.push(format!("folders = ?{idx}"));
|
||||
values.push(Box::new(folders_json));
|
||||
idx += 1;
|
||||
}
|
||||
if let Some(dnd_enabled) = data.dnd_enabled {
|
||||
sets.push(format!("dnd_enabled = ?{idx}"));
|
||||
values.push(Box::new(dnd_enabled as i32));
|
||||
idx += 1;
|
||||
}
|
||||
if let Some(ref dnd_start) = data.dnd_start {
|
||||
if !valid_clock(dnd_start) { return Err("Invalid DND start time".to_string()); }
|
||||
sets.push(format!("dnd_start = ?{idx}"));
|
||||
values.push(Box::new(dnd_start.clone()));
|
||||
idx += 1;
|
||||
}
|
||||
if let Some(ref dnd_end) = data.dnd_end {
|
||||
if !valid_clock(dnd_end) { return Err("Invalid DND end time".to_string()); }
|
||||
sets.push(format!("dnd_end = ?{idx}"));
|
||||
values.push(Box::new(dnd_end.clone()));
|
||||
idx += 1;
|
||||
}
|
||||
|
||||
if sets.is_empty() {
|
||||
return get_preferences(conn);
|
||||
}
|
||||
|
||||
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}");
|
||||
values.push(Box::new(1_i32));
|
||||
|
||||
let params_refs: Vec<&dyn rusqlite::types::ToSql> =
|
||||
values.iter().map(|v| v.as_ref()).collect();
|
||||
|
||||
conn.execute(&sql, params_refs.as_slice())
|
||||
.map_err(|e| format!("Failed to update notification preferences: {e}"))?;
|
||||
|
||||
get_preferences(conn)
|
||||
}
|
||||
|
||||
fn valid_clock(value: &str) -> bool {
|
||||
let Some((hour, minute)) = value.split_once(':') else { return false; };
|
||||
matches!((hour.parse::<u8>(), minute.parse::<u8>()), (Ok(h), Ok(m)) if h < 24 && m < 60 && hour.len() == 2 && minute.len() == 2)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::pool::open_test_db;
|
||||
|
||||
#[test]
|
||||
fn test_get_default_preferences() {
|
||||
let conn = open_test_db();
|
||||
let prefs = get_preferences(&conn).unwrap();
|
||||
|
||||
assert!(prefs.enabled);
|
||||
assert!(!prefs.sound);
|
||||
assert_eq!(prefs.folders, vec!["INBOX".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_enabled() {
|
||||
let conn = open_test_db();
|
||||
|
||||
let prefs = update_preferences(
|
||||
&conn,
|
||||
&UpdateNotificationPreferences {
|
||||
enabled: Some(false),
|
||||
sound: None,
|
||||
folders: None,
|
||||
dnd_enabled: None,
|
||||
dnd_start: None,
|
||||
dnd_end: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(!prefs.enabled);
|
||||
assert!(!prefs.sound);
|
||||
assert_eq!(prefs.folders, vec!["INBOX".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_folders() {
|
||||
let conn = open_test_db();
|
||||
|
||||
let prefs = update_preferences(
|
||||
&conn,
|
||||
&UpdateNotificationPreferences {
|
||||
enabled: None,
|
||||
sound: None,
|
||||
folders: Some(vec!["INBOX".to_string(), "Sent".to_string()]),
|
||||
dnd_enabled: None,
|
||||
dnd_start: None,
|
||||
dnd_end: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(prefs.enabled);
|
||||
assert_eq!(
|
||||
prefs.folders,
|
||||
vec!["INBOX".to_string(), "Sent".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_all_fields() {
|
||||
let conn = open_test_db();
|
||||
|
||||
let prefs = update_preferences(
|
||||
&conn,
|
||||
&UpdateNotificationPreferences {
|
||||
enabled: Some(false),
|
||||
sound: Some(true),
|
||||
folders: Some(vec!["Drafts".to_string()]),
|
||||
dnd_enabled: None,
|
||||
dnd_start: None,
|
||||
dnd_end: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(!prefs.enabled);
|
||||
assert!(prefs.sound);
|
||||
assert_eq!(prefs.folders, vec!["Drafts".to_string()]);
|
||||
}
|
||||
}
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
use rusqlite::{params, Connection};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PgpKeyRecord {
|
||||
pub fingerprint: String,
|
||||
pub key_id: String,
|
||||
pub email: String,
|
||||
pub user_id: String,
|
||||
pub public_key_armor: String,
|
||||
pub has_secret: bool,
|
||||
pub trust: String,
|
||||
pub source: String,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PgpKeyInternal {
|
||||
pub record: PgpKeyRecord,
|
||||
pub secret_key_path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PgpPreferences {
|
||||
pub wkd_enabled: bool,
|
||||
pub hkp_enabled: bool,
|
||||
pub require_trusted_encryption: bool,
|
||||
}
|
||||
|
||||
fn row_to_key(row: &rusqlite::Row<'_>) -> rusqlite::Result<PgpKeyInternal> {
|
||||
Ok(PgpKeyInternal {
|
||||
record: PgpKeyRecord {
|
||||
fingerprint: row.get(0)?,
|
||||
key_id: row.get(1)?,
|
||||
email: row.get(2)?,
|
||||
user_id: row.get(3)?,
|
||||
public_key_armor: row.get(4)?,
|
||||
has_secret: row.get::<_, i64>(6)? != 0,
|
||||
trust: row.get(7)?,
|
||||
source: row.get(8)?,
|
||||
created_at: row.get(9)?,
|
||||
updated_at: row.get(10)?,
|
||||
},
|
||||
secret_key_path: row.get(5)?,
|
||||
})
|
||||
}
|
||||
|
||||
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"))
|
||||
.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()
|
||||
}
|
||||
|
||||
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");
|
||||
match conn.query_row(&sql, params![normalized], row_to_key) {
|
||||
Ok(v) => Ok(Some(v)),
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||||
Err(e) => Err(format!("Failed to get PGP key: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
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"))
|
||||
.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()
|
||||
}
|
||||
|
||||
pub fn upsert_key(
|
||||
conn: &Connection,
|
||||
record: &PgpKeyRecord,
|
||||
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')",
|
||||
params![
|
||||
record.fingerprint,
|
||||
record.key_id,
|
||||
record.email,
|
||||
record.user_id,
|
||||
record.public_key_armor,
|
||||
secret_key_path,
|
||||
record.has_secret as i32,
|
||||
record.trust,
|
||||
record.source
|
||||
],
|
||||
)
|
||||
.map_err(|e| format!("Failed to store PGP key: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete_key(conn: &Connection, fingerprint: &str) -> Result<bool, String> {
|
||||
Ok(conn
|
||||
.execute(
|
||||
"DELETE FROM pgp_keys WHERE replace(upper(fingerprint),' ','')=?1",
|
||||
params![normalize_fingerprint(fingerprint)],
|
||||
)
|
||||
.map_err(|e| format!("Failed to delete PGP key: {e}"))?
|
||||
> 0)
|
||||
}
|
||||
|
||||
pub fn set_trust(conn: &Connection, fingerprint: &str, trust: &str) -> Result<bool, String> {
|
||||
if !matches!(trust, "unknown" | "untrusted" | "trusted" | "verified") {
|
||||
return Err("Trust must be unknown, untrusted, trusted, or verified".to_string());
|
||||
}
|
||||
Ok(conn
|
||||
.execute(
|
||||
"UPDATE pgp_keys SET trust=?2,updated_at=datetime('now') WHERE replace(upper(fingerprint),' ','')=?1",
|
||||
params![normalize_fingerprint(fingerprint), trust],
|
||||
)
|
||||
.map_err(|e| format!("Failed to update PGP trust: {e}"))?
|
||||
> 0)
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
},
|
||||
)
|
||||
.map_err(|e| format!("Failed to read PGP preferences: {e}"))
|
||||
}
|
||||
|
||||
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],
|
||||
)
|
||||
.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()
|
||||
}
|
||||
|
||||
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");
|
||||
match conn.query_row(&sql, params![email.trim()], row_to_key) {
|
||||
Ok(value) => Ok(Some(value)),
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||||
Err(e) => Err(format!("Failed to query PGP key by email: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
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"))
|
||||
.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()
|
||||
}
|
||||
+231
@@ -0,0 +1,231 @@
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use rusqlite::Connection;
|
||||
|
||||
/// Owned database context for short blocking jobs. Never hold SQLite on a Tokio worker.
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct UserDb {
|
||||
data_dir: String,
|
||||
user_hash: String,
|
||||
}
|
||||
|
||||
impl UserDb {
|
||||
pub(crate) fn new(data_dir: &str, user_hash: &str) -> Self {
|
||||
Self {
|
||||
data_dir: data_dir.into(),
|
||||
user_hash: user_hash.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn run<T: Send + 'static>(
|
||||
&self,
|
||||
work: impl FnOnce(&Connection) -> Result<T, String> + Send + 'static,
|
||||
) -> Result<T, String> {
|
||||
let context = self.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let conn = open_user_db(&context.data_dir, &context.user_hash)?;
|
||||
work(&conn)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Database task failed: {e}"))?
|
||||
}
|
||||
}
|
||||
|
||||
/// All migration scripts in order. Each entry is (version, sql).
|
||||
const MIGRATIONS: &[(u32, &str)] = &[
|
||||
(1, include_str!("../../migrations/V001__initial_schema.sql")),
|
||||
(2, include_str!("../../migrations/V002__folders_and_messages.sql")),
|
||||
(3, include_str!("../../migrations/V003__add_date_epoch.sql")),
|
||||
(4, include_str!("../../migrations/V004__folder_messages_updated_at.sql")),
|
||||
(5, include_str!("../../migrations/V005__drafts_and_attachments.sql")),
|
||||
(6, include_str!("../../migrations/V006__cache_attachments_and_headers.sql")),
|
||||
(7, include_str!("../../migrations/V007__contacts.sql")),
|
||||
(8, include_str!("../../migrations/V008__identities.sql")),
|
||||
(9, include_str!("../../migrations/V009__notification_preferences.sql")),
|
||||
(10, include_str!("../../migrations/V010__contact_groups.sql")),
|
||||
(11, include_str!("../../migrations/V011__message_reaction.sql")),
|
||||
(12, include_str!("../../migrations/V012__tags.sql")),
|
||||
(13, include_str!("../../migrations/V013__display_preferences.sql")),
|
||||
(14, include_str!("../../migrations/V014__compose_format.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")),
|
||||
(18, include_str!("../../migrations/V018__animation_mode.sql")),
|
||||
(19, include_str!("../../migrations/V019__known_addresses.sql")),
|
||||
(20, include_str!("../../migrations/V020__appearance_personalization.sql")),
|
||||
(21, include_str!("../../migrations/V021__special_folders.sql")),
|
||||
(22, include_str!("../../migrations/V022__identity_options.sql")),
|
||||
(23, include_str!("../../migrations/V023__saved_searches.sql")),
|
||||
(24, include_str!("../../migrations/V024__notification_dnd.sql")),
|
||||
(25, include_str!("../../migrations/V025__custom_theme_colors.sql")),
|
||||
(26, include_str!("../../migrations/V026__mail_preferences.sql")),
|
||||
(27, include_str!("../../migrations/V027__contact_profile.sql")),
|
||||
(28, include_str!("../../migrations/V028__contact_multi_values.sql")),
|
||||
(29, include_str!("../../migrations/V029__draft_send_options.sql")),
|
||||
(30, include_str!("../../migrations/V030__folder_list_preferences.sql")),
|
||||
(31, include_str!("../../migrations/V031__compose_preferences.sql")),
|
||||
(32, include_str!("../../migrations/V032__advanced_appearance.sql")),
|
||||
(33, include_str!("../../migrations/V033__draft_compose_format.sql")),
|
||||
(34, include_str!("../../migrations/V034__imap_keyword_sync.sql")),
|
||||
(35, include_str!("../../migrations/V035__compose_behavior_preferences.sql")),
|
||||
(36, include_str!("../../migrations/V036__remote_draft_tracking.sql")),
|
||||
(37, include_str!("../../migrations/V037__openpgp_keyring.sql")),
|
||||
(38, include_str!("../../migrations/V038__identity_openpgp.sql")),
|
||||
(39, include_str!("../../migrations/V039__draft_openpgp_options.sql")),
|
||||
(40, include_str!("../../migrations/V040__draft_openpgp_attach_key.sql")),
|
||||
(41, include_str!("../../migrations/V041__draft_openpgp_recipient_keys.sql")),
|
||||
];
|
||||
|
||||
/// Run any pending migrations based on SQLite's `user_version` PRAGMA.
|
||||
///
|
||||
/// If refinery has previously run migrations (indicated by the presence of
|
||||
/// `refinery_schema_history`), we sync `user_version` to match the highest
|
||||
/// refinery version before checking for new migrations. This avoids re-running
|
||||
/// migrations that refinery already applied.
|
||||
fn run_migrations(conn: &Connection) -> Result<(), String> {
|
||||
let mut current: u32 = conn
|
||||
.query_row("PRAGMA user_version", [], |row| row.get(0))
|
||||
.map_err(|e| format!("Failed to read user_version: {e}"))?;
|
||||
|
||||
// Sync with refinery's migration state so we never re-apply migrations
|
||||
// that refinery already applied (refinery updates its own history table
|
||||
// but doesn't touch the user_version pragma).
|
||||
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}"))?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Opens the per-user SQLite database at `{data_dir}/{user_hash}/db.sqlite`.
|
||||
///
|
||||
/// Creates the directory tree if it doesn't exist, enables WAL journal mode
|
||||
/// and foreign key enforcement, then runs any pending migrations.
|
||||
pub fn open_user_db(data_dir: &str, user_hash: &str) -> Result<Connection, String> {
|
||||
let dir = Path::new(data_dir).join(user_hash);
|
||||
fs::create_dir_all(&dir).map_err(|e| format!("Failed to create db dir: {e}"))?;
|
||||
|
||||
let db_path = dir.join("db.sqlite");
|
||||
let conn =
|
||||
Connection::open(&db_path).map_err(|e| format!("Failed to open SQLite: {e}"))?;
|
||||
|
||||
// Enable WAL mode for better concurrent read performance.
|
||||
conn.execute_batch("PRAGMA journal_mode=WAL;")
|
||||
.map_err(|e| format!("Failed to set WAL mode: {e}"))?;
|
||||
|
||||
// Enable foreign key constraint enforcement.
|
||||
conn.execute_batch("PRAGMA foreign_keys=ON;")
|
||||
.map_err(|e| format!("Failed to enable foreign keys: {e}"))?;
|
||||
|
||||
// Run any pending schema migrations.
|
||||
run_migrations(&conn)?;
|
||||
|
||||
Ok(conn)
|
||||
}
|
||||
|
||||
/// Opens an in-memory SQLite database with all migration scripts applied.
|
||||
/// Used exclusively by tests so every test starts with a clean, fully-migrated
|
||||
/// schema without touching the filesystem.
|
||||
#[cfg(test)]
|
||||
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;")
|
||||
.expect("Failed to enable foreign keys");
|
||||
|
||||
run_migrations(&conn).expect("Test migrations failed");
|
||||
|
||||
conn
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_open_test_db_has_tables() {
|
||||
let conn = open_test_db();
|
||||
|
||||
// Verify the three expected tables exist.
|
||||
let tables: Vec<String> = conn
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
|
||||
.unwrap()
|
||||
.query_map([], |row| row.get(0))
|
||||
.unwrap()
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
|
||||
assert!(tables.contains(&"user_meta".to_string()));
|
||||
assert!(tables.contains(&"folders".to_string()));
|
||||
assert!(tables.contains(&"messages".to_string()));
|
||||
assert!(tables.contains(&"drafts".to_string()));
|
||||
assert!(tables.contains(&"draft_attachments".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_open_user_db_creates_file() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let data_dir = tmp.path().to_str().unwrap();
|
||||
|
||||
let conn = open_user_db(data_dir, "abc123").unwrap();
|
||||
|
||||
// The file should exist on disk.
|
||||
let db_file = tmp.path().join("abc123").join("db.sqlite");
|
||||
assert!(db_file.exists());
|
||||
|
||||
// Foreign keys should be enabled.
|
||||
let fk: i32 = conn
|
||||
.query_row("PRAGMA foreign_keys", [], |row| row.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(fk, 1);
|
||||
|
||||
drop(conn);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod async_tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn database_jobs_leave_runtime_free() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let database = UserDb::new(directory.path().to_str().unwrap(), "test");
|
||||
let (started, ready) = tokio::sync::oneshot::channel();
|
||||
let (release, wait) = std::sync::mpsc::channel();
|
||||
let job = tokio::spawn(async move {
|
||||
database.run(move |_| {
|
||||
started.send(()).unwrap();
|
||||
wait.recv_timeout(std::time::Duration::from_secs(2))
|
||||
.map_err(|e| format!("runtime could not release database job: {e}"))?;
|
||||
Ok(())
|
||||
}).await
|
||||
});
|
||||
ready.await.unwrap();
|
||||
// On a current-thread runtime, an inline DB job prevents this send.
|
||||
release.send(()).unwrap();
|
||||
job.await.unwrap().unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
use rusqlite::{params, Connection};
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct SavedSearch {
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
pub query_string: String,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
fn row_to_saved(row: &rusqlite::Row<'_>) -> rusqlite::Result<SavedSearch> {
|
||||
Ok(SavedSearch {
|
||||
id: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
query_string: row.get(2)?,
|
||||
created_at: row.get(3)?,
|
||||
updated_at: row.get(4)?,
|
||||
})
|
||||
}
|
||||
|
||||
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")
|
||||
.map_err(|e| format!("Failed to prepare saved searches: {e}"))?;
|
||||
let rows = stmt
|
||||
.query_map([], row_to_saved)
|
||||
.map_err(|e| format!("Failed to query saved searches: {e}"))?;
|
||||
rows.map(|row| row.map_err(|e| format!("Failed to read saved search: {e}")))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn create(conn: &Connection, name: &str, query_string: &str) -> Result<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());
|
||||
}
|
||||
conn.execute(
|
||||
"INSERT INTO saved_searches(name,query_string) VALUES (?1,?2)",
|
||||
params![name, query_string],
|
||||
)
|
||||
.map_err(|e| format!("Failed to save search: {e}"))?;
|
||||
let id = conn.last_insert_rowid();
|
||||
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> {
|
||||
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); }
|
||||
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",
|
||||
params![id],
|
||||
row_to_saved,
|
||||
) {
|
||||
Ok(value) => Ok(Some(value)),
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||||
Err(e) => Err(format!("Failed to get saved search: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
use rusqlite::{params, Connection, OptionalExtension};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const ROLES: &[&str] = &["sent", "drafts", "trash", "junk", "archive"];
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct SpecialFolders {
|
||||
pub sent: Option<String>,
|
||||
pub drafts: Option<String>,
|
||||
pub trash: Option<String>,
|
||||
pub junk: Option<String>,
|
||||
pub archive: Option<String>,
|
||||
}
|
||||
|
||||
fn valid_role(role: &str) -> bool {
|
||||
ROLES.contains(&role)
|
||||
}
|
||||
|
||||
pub fn get_role(conn: &Connection, role: &str) -> Result<Option<String>, String> {
|
||||
if !valid_role(role) {
|
||||
return Err(format!("Invalid special folder role: {role}"));
|
||||
}
|
||||
conn.query_row(
|
||||
"SELECT folder_name FROM special_folders WHERE role = ?1",
|
||||
[role],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()
|
||||
.map_err(|e| format!("Failed to read special folder {role}: {e}"))
|
||||
}
|
||||
|
||||
pub fn get_all(conn: &Connection) -> Result<SpecialFolders, String> {
|
||||
Ok(SpecialFolders {
|
||||
sent: get_role(conn, "sent")?,
|
||||
drafts: get_role(conn, "drafts")?,
|
||||
trash: get_role(conn, "trash")?,
|
||||
junk: get_role(conn, "junk")?,
|
||||
archive: get_role(conn, "archive")?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_role(conn: &Connection, role: &str, folder_name: Option<&str>) -> Result<(), String> {
|
||||
if !valid_role(role) {
|
||||
return Err(format!("Invalid special folder role: {role}"));
|
||||
}
|
||||
match folder_name.map(str::trim).filter(|value| !value.is_empty()) {
|
||||
Some(folder) => {
|
||||
let exists: bool = conn
|
||||
.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM folders WHERE name = ?1)",
|
||||
[folder],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(|e| format!("Failed to validate folder {folder}: {e}"))?;
|
||||
if !exists {
|
||||
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')",
|
||||
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}"))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn update_all(conn: &Connection, folders: &SpecialFolders) -> Result<SpecialFolders, String> {
|
||||
set_role(conn, "sent", folders.sent.as_deref())?;
|
||||
set_role(conn, "drafts", folders.drafts.as_deref())?;
|
||||
set_role(conn, "trash", folders.trash.as_deref())?;
|
||||
set_role(conn, "junk", folders.junk.as_deref())?;
|
||||
set_role(conn, "archive", folders.archive.as_deref())?;
|
||||
get_all(conn)
|
||||
}
|
||||
|
||||
pub fn rename_folder_reference(conn: &Connection, old_name: &str, new_name: &str) -> Result<(), String> {
|
||||
conn.execute(
|
||||
"UPDATE special_folders SET folder_name = ?1, updated_at = datetime('now') WHERE folder_name = ?2",
|
||||
params![new_name, old_name],
|
||||
)
|
||||
.map_err(|e| format!("Failed to update special folder reference: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn clear_folder_reference(conn: &Connection, folder_name: &str) -> Result<(), String> {
|
||||
conn.execute("DELETE FROM special_folders WHERE folder_name = ?1", [folder_name])
|
||||
.map_err(|e| format!("Failed to clear special folder reference: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::folders;
|
||||
use crate::db::pool::open_test_db;
|
||||
|
||||
#[test]
|
||||
fn special_folders_roundtrip() {
|
||||
let conn = open_test_db();
|
||||
for name in ["Sent Items", "Drafts", "Deleted", "Junk", "Archive"] {
|
||||
folders::insert_folder_if_new(&conn, name, Some("/"), "").unwrap();
|
||||
}
|
||||
let value = SpecialFolders {
|
||||
sent: Some("Sent Items".into()),
|
||||
drafts: Some("Drafts".into()),
|
||||
trash: Some("Deleted".into()),
|
||||
junk: Some("Junk".into()),
|
||||
archive: Some("Archive".into()),
|
||||
};
|
||||
let saved = update_all(&conn, &value).unwrap();
|
||||
assert_eq!(saved.sent.as_deref(), Some("Sent Items"));
|
||||
assert_eq!(saved.trash.as_deref(), Some("Deleted"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unknown_folder() {
|
||||
let conn = open_test_db();
|
||||
assert!(set_role(&conn, "sent", Some("Missing")).is_err());
|
||||
}
|
||||
}
|
||||
+501
@@ -0,0 +1,501 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use rusqlite::{params, Connection};
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::db::messages::CachedMessage;
|
||||
|
||||
/// A tag record, mirroring the `tags` table.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct Tag {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub color: String,
|
||||
pub imap_keyword: String,
|
||||
pub message_count: i64,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
/// Lightweight tag info returned alongside messages.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct MessageTag {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub color: String,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Create a new tag.
|
||||
pub fn imap_keyword_for_tag_id(id: &str) -> String {
|
||||
format!("RustBox-{}", id.chars().filter(|ch| *ch != '-').collect::<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)",
|
||||
params![id, name, color, imap_keyword],
|
||||
)
|
||||
.map_err(|e| format!("Failed to create tag: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// List all tags with their message counts.
|
||||
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",
|
||||
)
|
||||
.map_err(|e| format!("Failed to prepare list_tags: {e}"))?;
|
||||
|
||||
let rows = stmt
|
||||
.query_map([], |row| {
|
||||
Ok(Tag {
|
||||
id: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
color: row.get(2)?,
|
||||
imap_keyword: row.get(3)?,
|
||||
created_at: row.get(4)?,
|
||||
updated_at: row.get(5)?,
|
||||
message_count: row.get(6)?,
|
||||
})
|
||||
})
|
||||
.map_err(|e| format!("Failed to query tags: {e}"))?;
|
||||
|
||||
let mut tags = Vec::new();
|
||||
for row in rows {
|
||||
tags.push(row.map_err(|e| format!("Failed to read tag row: {e}"))?);
|
||||
}
|
||||
Ok(tags)
|
||||
}
|
||||
|
||||
pub fn get_tag(conn: &Connection, id: &str) -> Result<Option<Tag>, String> {
|
||||
let mut tags = list_tags(conn)?;
|
||||
Ok(tags.drain(..).find(|tag| tag.id == id))
|
||||
}
|
||||
|
||||
pub fn get_message_refs_by_tag(conn: &Connection, 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")
|
||||
.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)?)))
|
||||
.map_err(|e| format!("Failed to query tag message refs: {e}"))?;
|
||||
let mut refs = Vec::new();
|
||||
for row in rows { refs.push(row.map_err(|e| format!("Failed to read tag message ref: {e}"))?); }
|
||||
Ok(refs)
|
||||
}
|
||||
|
||||
/// Import RustBox-owned IMAP keyword flags into local message/tag relations.
|
||||
/// This intentionally only adds remotely-present labels; absence is not treated as
|
||||
/// deletion because some servers/folders do not expose persistent custom flags.
|
||||
pub fn import_imap_keywords(conn: &Connection, uid: u32, folder: &str, flags: &[String]) -> Result<usize, String> {
|
||||
let keyword_set: std::collections::HashSet<&str> = flags.iter().map(String::as_str).collect();
|
||||
if !keyword_set.iter().any(|flag| flag.starts_with("RustBox-")) { return Ok(0); }
|
||||
let tags = list_tags(conn)?;
|
||||
let mut imported = 0usize;
|
||||
for tag in tags {
|
||||
if !tag.imap_keyword.is_empty() && keyword_set.contains(tag.imap_keyword.as_str()) {
|
||||
add_tag_to_message(conn, &tag.id, uid, folder)?;
|
||||
imported += 1;
|
||||
}
|
||||
}
|
||||
Ok(imported)
|
||||
}
|
||||
|
||||
|
||||
/// Reconcile RustBox-owned IMAP keyword flags for one message.
|
||||
///
|
||||
/// This is intentionally stronger than `import_imap_keywords`: callers must
|
||||
/// use it only after confirming that the selected mailbox supports persistent
|
||||
/// custom keywords and that `flags` is the complete authoritative FLAGS set.
|
||||
/// Remotely present RustBox keywords are added locally and remotely absent
|
||||
/// RustBox keywords are removed locally.
|
||||
pub fn reconcile_imap_keywords(
|
||||
conn: &Connection,
|
||||
uid: u32,
|
||||
folder: &str,
|
||||
flags: &[String],
|
||||
) -> Result<(usize, usize), String> {
|
||||
let keyword_set: std::collections::HashSet<&str> = flags.iter().map(String::as_str).collect();
|
||||
let tags = list_tags(conn)?;
|
||||
let existing: std::collections::HashSet<String> = get_message_tags(conn, uid, folder)?
|
||||
.into_iter()
|
||||
.map(|tag| tag.id)
|
||||
.collect();
|
||||
|
||||
let mut added = 0usize;
|
||||
let mut removed = 0usize;
|
||||
for tag in tags {
|
||||
if tag.imap_keyword.is_empty() || !tag.imap_keyword.starts_with("RustBox-") {
|
||||
continue;
|
||||
}
|
||||
let remote_has = keyword_set.contains(tag.imap_keyword.as_str());
|
||||
let local_has = existing.contains(&tag.id);
|
||||
match (remote_has, local_has) {
|
||||
(true, false) => {
|
||||
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;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok((added, removed))
|
||||
}
|
||||
|
||||
/// Update a tag's name and/or color.
|
||||
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",
|
||||
params![name, color, id],
|
||||
)
|
||||
.map_err(|e| format!("Failed to update tag: {e}"))?;
|
||||
Ok(updated > 0)
|
||||
}
|
||||
|
||||
/// Delete a tag. CASCADE handles junction rows.
|
||||
pub fn delete_tag(conn: &Connection, id: &str) -> Result<bool, String> {
|
||||
let deleted = conn
|
||||
.execute("DELETE FROM tags WHERE id = ?1", params![id])
|
||||
.map_err(|e| format!("Failed to delete tag: {e}"))?;
|
||||
Ok(deleted > 0)
|
||||
}
|
||||
|
||||
/// Add a tag to a message (INSERT OR IGNORE to avoid duplicates).
|
||||
pub fn add_tag_to_message(
|
||||
conn: &Connection,
|
||||
tag_id: &str,
|
||||
uid: u32,
|
||||
folder: &str,
|
||||
) -> Result<(), String> {
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO message_tags (tag_id, message_uid, message_folder) VALUES (?1, ?2, ?3)",
|
||||
params![tag_id, uid, folder],
|
||||
)
|
||||
.map_err(|e| format!("Failed to add tag to message: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove a tag from a message.
|
||||
pub fn remove_tag_from_message(
|
||||
conn: &Connection,
|
||||
tag_id: &str,
|
||||
uid: u32,
|
||||
folder: &str,
|
||||
) -> Result<bool, String> {
|
||||
let deleted = conn
|
||||
.execute(
|
||||
"DELETE FROM message_tags WHERE tag_id = ?1 AND message_uid = ?2 AND message_folder = ?3",
|
||||
params![tag_id, uid, folder],
|
||||
)
|
||||
.map_err(|e| format!("Failed to remove tag from message: {e}"))?;
|
||||
Ok(deleted > 0)
|
||||
}
|
||||
|
||||
/// Get all tags for a specific message.
|
||||
pub fn get_message_tags(conn: &Connection, uid: u32, 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",
|
||||
)
|
||||
.map_err(|e| format!("Failed to prepare get_message_tags: {e}"))?;
|
||||
|
||||
let rows = stmt
|
||||
.query_map(params![uid, folder], |row| {
|
||||
Ok(MessageTag {
|
||||
id: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
color: row.get(2)?,
|
||||
})
|
||||
})
|
||||
.map_err(|e| format!("Failed to query message tags: {e}"))?;
|
||||
|
||||
let mut tags = Vec::new();
|
||||
for row in rows {
|
||||
tags.push(row.map_err(|e| format!("Failed to read message tag row: {e}"))?);
|
||||
}
|
||||
Ok(tags)
|
||||
}
|
||||
|
||||
/// Batch-fetch tags for multiple messages (efficient for list view).
|
||||
pub fn get_tags_for_messages(
|
||||
conn: &Connection,
|
||||
messages: &[(u32, &str)],
|
||||
) -> Result<HashMap<(u32, String), Vec<MessageTag>>, String> {
|
||||
if messages.is_empty() {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
|
||||
// Build a query with placeholders for all message refs.
|
||||
// Using a single query with OR conditions for reasonable batch sizes.
|
||||
let conditions: Vec<String> = messages
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, _)| format!("(mt.message_uid = ?{} AND mt.message_folder = ?{})", i * 2 + 1, i * 2 + 2))
|
||||
.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 mut stmt = conn
|
||||
.prepare(&sql)
|
||||
.map_err(|e| format!("Failed to prepare get_tags_for_messages: {e}"))?;
|
||||
|
||||
let mut param_values: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
|
||||
for &(uid, folder) in messages {
|
||||
param_values.push(Box::new(uid));
|
||||
param_values.push(Box::new(folder.to_string()));
|
||||
}
|
||||
let params_ref: Vec<&dyn rusqlite::types::ToSql> = param_values.iter().map(|p| p.as_ref()).collect();
|
||||
|
||||
let rows = stmt
|
||||
.query_map(params_ref.as_slice(), |row| {
|
||||
let uid: u32 = row.get(0)?;
|
||||
let folder: String = row.get(1)?;
|
||||
let tag = MessageTag {
|
||||
id: row.get(2)?,
|
||||
name: row.get(3)?,
|
||||
color: row.get(4)?,
|
||||
};
|
||||
Ok((uid, folder, tag))
|
||||
})
|
||||
.map_err(|e| format!("Failed to query tags for messages: {e}"))?;
|
||||
|
||||
let mut map: HashMap<(u32, String), Vec<MessageTag>> = HashMap::new();
|
||||
for row in rows {
|
||||
let (uid, folder, tag) = row.map_err(|e| format!("Failed to read tags row: {e}"))?;
|
||||
map.entry((uid, folder)).or_default().push(tag);
|
||||
}
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
/// Get messages by tag (paginated), ordered by date descending.
|
||||
pub fn get_messages_by_tag(
|
||||
conn: &Connection,
|
||||
tag_id: &str,
|
||||
page: u32,
|
||||
per_page: u32,
|
||||
) -> 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",
|
||||
)
|
||||
.map_err(|e| format!("Failed to prepare get_messages_by_tag: {e}"))?;
|
||||
|
||||
let rows = stmt
|
||||
.query_map(params![tag_id, per_page, offset], |row| {
|
||||
let has_attachments_int: i32 = row.get(13)?;
|
||||
Ok(CachedMessage {
|
||||
uid: row.get(0)?,
|
||||
folder: row.get(1)?,
|
||||
message_id: row.get(2)?,
|
||||
in_reply_to: row.get(3)?,
|
||||
references_header: row.get(4)?,
|
||||
subject: row.get(5)?,
|
||||
from_address: row.get(6)?,
|
||||
from_name: row.get(7)?,
|
||||
to_addresses: row.get(8)?,
|
||||
cc_addresses: row.get(9)?,
|
||||
date: row.get(10)?,
|
||||
flags: row.get(11)?,
|
||||
size: row.get(12)?,
|
||||
has_attachments: has_attachments_int != 0,
|
||||
snippet: row.get(14)?,
|
||||
reaction: row.get(15)?,
|
||||
})
|
||||
})
|
||||
.map_err(|e| format!("Failed to query messages by tag: {e}"))?;
|
||||
|
||||
let mut messages = Vec::new();
|
||||
for row in rows {
|
||||
messages.push(row.map_err(|e| format!("Failed to read message row: {e}"))?);
|
||||
}
|
||||
Ok(messages)
|
||||
}
|
||||
|
||||
/// Count messages with a given 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",
|
||||
params![tag_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(|e| format!("Failed to count messages by tag: {e}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::pool::open_test_db;
|
||||
|
||||
fn insert_test_message(conn: &Connection, folder: &str, uid: u32) {
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO folders (name) VALUES (?1)",
|
||||
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, '')",
|
||||
params![uid, folder],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_and_list_tags() {
|
||||
let conn = open_test_db();
|
||||
create_tag(&conn, "t1", "Urgent", "#ef4444").unwrap();
|
||||
create_tag(&conn, "t2", "Project X", "#3b82f6").unwrap();
|
||||
|
||||
let tags = list_tags(&conn).unwrap();
|
||||
assert_eq!(tags.len(), 2);
|
||||
assert_eq!(tags[0].name, "Project X");
|
||||
assert_eq!(tags[1].name, "Urgent");
|
||||
assert_eq!(tags[0].message_count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_tag() {
|
||||
let conn = open_test_db();
|
||||
create_tag(&conn, "t1", "Urgent", "#ef4444").unwrap();
|
||||
|
||||
let updated = update_tag(&conn, "t1", "Very Urgent", "#dc2626").unwrap();
|
||||
assert!(updated);
|
||||
|
||||
let tags = list_tags(&conn).unwrap();
|
||||
assert_eq!(tags[0].name, "Very Urgent");
|
||||
assert_eq!(tags[0].color, "#dc2626");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_tag_cascades() {
|
||||
let conn = open_test_db();
|
||||
create_tag(&conn, "t1", "Urgent", "#ef4444").unwrap();
|
||||
insert_test_message(&conn, "INBOX", 1);
|
||||
add_tag_to_message(&conn, "t1", 1, "INBOX").unwrap();
|
||||
|
||||
let deleted = delete_tag(&conn, "t1").unwrap();
|
||||
assert!(deleted);
|
||||
|
||||
let tags = list_tags(&conn).unwrap();
|
||||
assert_eq!(tags.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_add_and_get_message_tags() {
|
||||
let conn = open_test_db();
|
||||
create_tag(&conn, "t1", "Urgent", "#ef4444").unwrap();
|
||||
create_tag(&conn, "t2", "Work", "#3b82f6").unwrap();
|
||||
insert_test_message(&conn, "INBOX", 1);
|
||||
|
||||
add_tag_to_message(&conn, "t1", 1, "INBOX").unwrap();
|
||||
add_tag_to_message(&conn, "t2", 1, "INBOX").unwrap();
|
||||
// Duplicate should be ignored
|
||||
add_tag_to_message(&conn, "t1", 1, "INBOX").unwrap();
|
||||
|
||||
let tags = get_message_tags(&conn, 1, "INBOX").unwrap();
|
||||
assert_eq!(tags.len(), 2);
|
||||
|
||||
let tag_list = list_tags(&conn).unwrap();
|
||||
assert_eq!(tag_list.iter().find(|t| t.name == "Urgent").unwrap().message_count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_remove_tag_from_message() {
|
||||
let conn = open_test_db();
|
||||
create_tag(&conn, "t1", "Urgent", "#ef4444").unwrap();
|
||||
insert_test_message(&conn, "INBOX", 1);
|
||||
add_tag_to_message(&conn, "t1", 1, "INBOX").unwrap();
|
||||
|
||||
let removed = remove_tag_from_message(&conn, "t1", 1, "INBOX").unwrap();
|
||||
assert!(removed);
|
||||
|
||||
let tags = get_message_tags(&conn, 1, "INBOX").unwrap();
|
||||
assert_eq!(tags.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_tags_for_messages_batch() {
|
||||
let conn = open_test_db();
|
||||
create_tag(&conn, "t1", "Urgent", "#ef4444").unwrap();
|
||||
insert_test_message(&conn, "INBOX", 1);
|
||||
insert_test_message(&conn, "INBOX", 2);
|
||||
add_tag_to_message(&conn, "t1", 1, "INBOX").unwrap();
|
||||
|
||||
let refs: Vec<(u32, &str)> = vec![(1, "INBOX"), (2, "INBOX")];
|
||||
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());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_messages_by_tag() {
|
||||
let conn = open_test_db();
|
||||
create_tag(&conn, "t1", "Urgent", "#ef4444").unwrap();
|
||||
insert_test_message(&conn, "INBOX", 1);
|
||||
insert_test_message(&conn, "Sent", 2);
|
||||
add_tag_to_message(&conn, "t1", 1, "INBOX").unwrap();
|
||||
add_tag_to_message(&conn, "t1", 2, "Sent").unwrap();
|
||||
|
||||
let messages = get_messages_by_tag(&conn, "t1", 0, 50).unwrap();
|
||||
assert_eq!(messages.len(), 2);
|
||||
|
||||
let count = count_messages_by_tag(&conn, "t1").unwrap();
|
||||
assert_eq!(count, 2);
|
||||
}
|
||||
#[test]
|
||||
fn test_reconcile_imap_keywords_adds_and_removes() {
|
||||
let conn = open_test_db();
|
||||
create_tag(&conn, "t1", "One", "#ef4444").unwrap();
|
||||
create_tag(&conn, "t2", "Two", "#3b82f6").unwrap();
|
||||
insert_test_message(&conn, "INBOX", 7);
|
||||
add_tag_to_message(&conn, "t1", 7, "INBOX").unwrap();
|
||||
|
||||
let keyword_two = imap_keyword_for_tag_id("t2");
|
||||
let (added, removed) = reconcile_imap_keywords(
|
||||
&conn,
|
||||
7,
|
||||
"INBOX",
|
||||
&["\\Seen".to_string(), keyword_two],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(added, 1);
|
||||
assert_eq!(removed, 1);
|
||||
let tags = get_message_tags(&conn, 7, "INBOX").unwrap();
|
||||
assert_eq!(tags.len(), 1);
|
||||
assert_eq!(tags[0].id, "t2");
|
||||
}
|
||||
|
||||
}
|
||||
Reference in new issue
Block a user