first commit
RustBox CI / rustbox (push) Canceled after 0s

This commit is contained in:
Mateusz Gruszczyński committed 2026-10-29 15:24:59 +01:00
commit bc5fbd6ba9
180 files changed
+63750

No files matched your search

+800
View File
@@ -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,
}
}
}
}
+300
View File
@@ -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");
}
}
+686
View File
@@ -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");
}
}
+318
View File
@@ -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());
}
}
+469
View File
@@ -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());
}
}
+148
View File
@@ -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);
}
}
+465
View File
@@ -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, &current).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"));
}
}
+51
View File
@@ -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(())
}
+204
View File
@@ -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());
}
}
+152
View File
@@ -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(&current.remote_images);
let preferred_body = data.preferred_body.as_deref().unwrap_or(&current.preferred_body);
let thread_mode = data.thread_mode.as_deref().unwrap_or(&current.thread_mode);
let collapse_quotes = data.collapse_quotes.unwrap_or(current.collapse_quotes);
let show_snippets = data.show_snippets.unwrap_or(current.show_snippets);
let default_sort = data.default_sort.as_deref().unwrap_or(&current.default_sort);
let 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
View File
File diff suppressed because it is too large. Load diff
+18
View File
@@ -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;
+227
View File
@@ -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
View File
@@ -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
View File
@@ -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();
}
}
+78
View File
@@ -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)
}
+125
View File
@@ -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
View File
@@ -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");
}
}