1 parent
96a32ea8fb
commit
a643e7e3ab
106 files changed
+2223
-4669
No files matched your search
@@ -2,11 +2,6 @@ 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,
|
||||
@@ -31,7 +26,6 @@ pub struct CalendarEvent {
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
/// Fields for creating a new calendar event.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateEvent {
|
||||
pub title: String,
|
||||
@@ -72,7 +66,6 @@ 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>,
|
||||
@@ -90,7 +83,6 @@ pub struct UpdateEvent {
|
||||
pub reminder_minutes: Option<i64>,
|
||||
}
|
||||
|
||||
/// Calendar settings record, mirroring the `calendar_settings` table.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct CalendarSettings {
|
||||
pub default_view: String,
|
||||
@@ -107,7 +99,6 @@ pub struct CalendarSettings {
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
/// Fields for updating calendar settings. All optional.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateCalendarSettings {
|
||||
pub default_view: Option<String>,
|
||||
@@ -124,7 +115,6 @@ pub struct UpdateCalendarSettings {
|
||||
pub caldav_enabled: Option<bool>,
|
||||
}
|
||||
|
||||
/// A meeting template record.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MeetingTemplate {
|
||||
pub id: i64,
|
||||
@@ -135,10 +125,6 @@ pub struct MeetingTemplate {
|
||||
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, \
|
||||
@@ -200,11 +186,6 @@ fn row_to_template(row: &rusqlite::Row<'_>) -> rusqlite::Result<MeetingTemplate>
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API — Events
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// List events in the given time range (inclusive start, inclusive end).
|
||||
pub fn list_events(
|
||||
conn: &Connection,
|
||||
start: &str,
|
||||
@@ -229,7 +210,6 @@ pub fn list_events(
|
||||
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"
|
||||
@@ -248,7 +228,6 @@ pub fn list_all_events(conn: &Connection) -> Result<Vec<CalendarEvent>, String>
|
||||
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);
|
||||
@@ -260,7 +239,6 @@ pub fn get_event(conn: &Connection, id: &str) -> Result<Option<CalendarEvent>, S
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -302,13 +280,11 @@ pub fn create_event(conn: &Connection, data: &CreateEvent) -> Result<CalendarEve
|
||||
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;
|
||||
@@ -371,7 +347,6 @@ pub fn update_event(
|
||||
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])
|
||||
@@ -379,7 +354,6 @@ pub fn delete_event(conn: &Connection, id: &str) -> Result<bool, String> {
|
||||
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,
|
||||
@@ -394,17 +368,11 @@ pub fn find_event_by_source_uid(
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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)",
|
||||
[],
|
||||
@@ -416,12 +384,10 @@ pub fn get_calendar_settings(conn: &Connection) -> Result<CalendarSettings, Stri
|
||||
.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)",
|
||||
[],
|
||||
@@ -483,11 +449,6 @@ pub fn update_calendar_settings(
|
||||
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(
|
||||
@@ -506,7 +467,6 @@ pub fn list_meeting_templates(conn: &Connection) -> Result<Vec<MeetingTemplate>,
|
||||
Ok(templates)
|
||||
}
|
||||
|
||||
/// Create a new meeting template.
|
||||
pub fn create_meeting_template(
|
||||
conn: &Connection,
|
||||
name: &str,
|
||||
@@ -526,7 +486,6 @@ pub fn create_meeting_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])
|
||||
@@ -534,10 +493,6 @@ pub fn delete_meeting_template(conn: &Connection, id: i64) -> Result<bool, Strin
|
||||
Ok(deleted > 0)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -773,7 +728,6 @@ mod tests {
|
||||
assert_eq!(all.len(), 0);
|
||||
}
|
||||
|
||||
// Helper to create CreateEvent variants without full Clone impl
|
||||
impl CreateEvent {
|
||||
fn clone_fields(&self) -> Self {
|
||||
CreateEvent {
|
||||
|
||||
@@ -3,7 +3,6 @@ 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,
|
||||
@@ -13,11 +12,6 @@ pub struct ContactGroup {
|
||||
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)",
|
||||
@@ -27,7 +21,6 @@ pub fn create_group(conn: &Connection, id: &str, name: &str) -> Result<(), Strin
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// List all contact groups with their member counts.
|
||||
pub fn list_groups(conn: &Connection) -> Result<Vec<ContactGroup>, String> {
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
@@ -57,7 +50,6 @@ pub fn list_groups(conn: &Connection) -> Result<Vec<ContactGroup>, String> {
|
||||
Ok(groups)
|
||||
}
|
||||
|
||||
/// Update a group's name.
|
||||
pub fn update_group(conn: &Connection, id: &str, name: &str) -> Result<bool, String> {
|
||||
let updated = conn
|
||||
.execute(
|
||||
@@ -68,7 +60,6 @@ pub fn update_group(conn: &Connection, id: &str, name: &str) -> Result<bool, Str
|
||||
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])
|
||||
@@ -76,7 +67,6 @@ pub fn delete_group(conn: &Connection, id: &str) -> Result<bool, String> {
|
||||
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)",
|
||||
@@ -86,7 +76,6 @@ pub fn add_member(conn: &Connection, group_id: &str, contact_id: &str) -> Result
|
||||
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(
|
||||
@@ -97,7 +86,6 @@ pub fn remove_member(conn: &Connection, group_id: &str, contact_id: &str) -> Res
|
||||
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(
|
||||
@@ -147,7 +135,6 @@ pub fn list_group_members(conn: &Connection, group_id: &str) -> Result<Vec<Conta
|
||||
Ok(contacts)
|
||||
}
|
||||
|
||||
/// List all groups a given contact belongs to.
|
||||
pub fn list_contact_groups(
|
||||
conn: &Connection,
|
||||
contact_id: &str,
|
||||
@@ -235,7 +222,7 @@ mod tests {
|
||||
|
||||
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();
|
||||
|
||||
+25
-66
@@ -1,15 +1,13 @@
|
||||
use rusqlite::{params, Connection};
|
||||
use rusqlite::{Connection, params};
|
||||
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,
|
||||
@@ -34,7 +32,6 @@ pub struct Contact {
|
||||
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)?,
|
||||
@@ -49,7 +46,8 @@ fn row_to_contact(row: &rusqlite::Row<'_>) -> rusqlite::Result<Contact> {
|
||||
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(),
|
||||
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)?,
|
||||
@@ -60,22 +58,14 @@ fn row_to_contact(row: &rusqlite::Row<'_>) -> rusqlite::Result<Contact> {
|
||||
})
|
||||
}
|
||||
|
||||
/// 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";
|
||||
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
|
||||
@@ -114,7 +104,6 @@ pub fn upsert_contact(conn: &Connection, contact: &Contact) -> Result<(), String
|
||||
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);
|
||||
@@ -126,7 +115,6 @@ pub fn get_contact(conn: &Connection, id: &str) -> Result<Option<Contact>, Strin
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 \
|
||||
@@ -142,8 +130,6 @@ pub fn get_contact_by_email(conn: &Connection, email: &str) -> Result<Option<Con
|
||||
}
|
||||
}
|
||||
|
||||
/// 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>,
|
||||
@@ -160,8 +146,7 @@ pub fn list_contacts(
|
||||
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))
|
||||
}
|
||||
@@ -206,7 +191,6 @@ pub fn list_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])
|
||||
@@ -214,8 +198,6 @@ pub fn delete_contact(conn: &Connection, id: &str) -> Result<bool, String> {
|
||||
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}%");
|
||||
@@ -241,9 +223,6 @@ pub fn search_contacts(conn: &Connection, query: &str, limit: u32) -> Result<Vec
|
||||
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,
|
||||
@@ -285,8 +264,6 @@ pub fn search_known_addresses(
|
||||
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,
|
||||
@@ -294,7 +271,6 @@ pub fn populate_known_addresses(
|
||||
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)
|
||||
@@ -304,28 +280,21 @@ pub fn populate_known_addresses(
|
||||
.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.
|
||||
Err(_) => return Ok(()),
|
||||
};
|
||||
|
||||
for entry in &entries {
|
||||
let email = entry
|
||||
.get("address")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
let email = entry.get("address").and_then(|v| v.as_str()).unwrap_or("");
|
||||
if email.is_empty() {
|
||||
continue;
|
||||
}
|
||||
@@ -341,8 +310,6 @@ fn insert_addresses_from_json(conn: &Connection, json: &str) -> Result<(), Strin
|
||||
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(
|
||||
@@ -357,9 +324,6 @@ pub fn increment_contact_count(conn: &Connection, email: &str) -> Result<(), Str
|
||||
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();
|
||||
@@ -372,7 +336,12 @@ pub fn auto_add_contact(conn: &Connection, email: &str, name: &str) -> Result<()
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_contact_photo(conn: &Connection, contact_id: &str, content_type: &str, data: &[u8]) -> Result<(), String> {
|
||||
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],
|
||||
@@ -380,7 +349,10 @@ pub fn set_contact_photo(conn: &Connection, contact_id: &str, content_type: &str
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_contact_photo(conn: &Connection, contact_id: &str) -> Result<Option<(String, Vec<u8>)>, String> {
|
||||
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],
|
||||
@@ -393,9 +365,12 @@ pub fn get_contact_photo(conn: &Connection, contact_id: &str) -> Result<Option<(
|
||||
}
|
||||
|
||||
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}"))
|
||||
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)]
|
||||
@@ -403,7 +378,6 @@ 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(),
|
||||
@@ -457,7 +431,6 @@ mod tests {
|
||||
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());
|
||||
}
|
||||
@@ -471,7 +444,6 @@ mod tests {
|
||||
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");
|
||||
}
|
||||
@@ -486,12 +458,10 @@ mod tests {
|
||||
.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");
|
||||
@@ -505,11 +475,9 @@ mod tests {
|
||||
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);
|
||||
}
|
||||
@@ -526,12 +494,11 @@ mod tests {
|
||||
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.
|
||||
assert_eq!(fetched2.name, "Auto User");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -539,7 +506,6 @@ mod tests {
|
||||
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();
|
||||
|
||||
@@ -554,7 +520,6 @@ mod tests {
|
||||
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,
|
||||
@@ -588,14 +553,12 @@ mod tests {
|
||||
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,
|
||||
@@ -614,10 +577,8 @@ mod tests {
|
||||
)
|
||||
.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"));
|
||||
@@ -629,7 +590,6 @@ mod tests {
|
||||
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,
|
||||
@@ -663,7 +623,7 @@ mod tests {
|
||||
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();
|
||||
@@ -675,7 +635,6 @@ mod tests {
|
||||
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();
|
||||
|
||||
|
||||
+110
-26
@@ -1,7 +1,6 @@
|
||||
use rusqlite::{Connection, params};
|
||||
use serde::Serialize;
|
||||
|
||||
/// A locally-saved draft message.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct Draft {
|
||||
pub id: String,
|
||||
@@ -27,7 +26,6 @@ pub struct Draft {
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
/// An attachment associated with a draft.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct DraftAttachment {
|
||||
pub id: String,
|
||||
@@ -39,8 +37,6 @@ pub struct DraftAttachment {
|
||||
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());
|
||||
@@ -54,7 +50,6 @@ pub fn validate_draft_id(id: &str) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Insert or update a draft. Uses INSERT OR REPLACE to upsert.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn upsert_draft(
|
||||
conn: &Connection,
|
||||
@@ -119,7 +114,6 @@ pub fn upsert_draft(
|
||||
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
|
||||
@@ -159,7 +153,6 @@ pub fn get_draft(conn: &Connection, id: &str) -> Result<Option<Draft>, String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// List all drafts, ordered by updated_at descending.
|
||||
pub fn list_drafts(conn: &Connection) -> Result<Vec<Draft>, String> {
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
@@ -203,8 +196,6 @@ pub fn list_drafts(conn: &Connection) -> Result<Vec<Draft>, String> {
|
||||
Ok(drafts)
|
||||
}
|
||||
|
||||
|
||||
/// Update the IMAP location of the latest remote snapshot for a local draft.
|
||||
pub fn set_remote_location(
|
||||
conn: &Connection,
|
||||
id: &str,
|
||||
@@ -224,7 +215,6 @@ pub fn set_remote_location(
|
||||
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])
|
||||
@@ -232,7 +222,6 @@ pub fn delete_draft(conn: &Connection, id: &str) -> Result<bool, String> {
|
||||
Ok(deleted > 0)
|
||||
}
|
||||
|
||||
/// Add an attachment record for a draft.
|
||||
pub fn add_draft_attachment(
|
||||
conn: &Connection,
|
||||
id: &str,
|
||||
@@ -251,7 +240,6 @@ pub fn add_draft_attachment(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get all attachments for a draft.
|
||||
pub fn get_draft_attachments(
|
||||
conn: &Connection,
|
||||
draft_id: &str,
|
||||
@@ -284,7 +272,6 @@ pub fn get_draft_attachments(
|
||||
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])
|
||||
@@ -336,8 +323,46 @@ mod tests {
|
||||
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();
|
||||
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");
|
||||
@@ -349,8 +374,16 @@ mod tests {
|
||||
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();
|
||||
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);
|
||||
@@ -363,7 +396,11 @@ mod tests {
|
||||
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();
|
||||
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();
|
||||
@@ -374,9 +411,17 @@ mod tests {
|
||||
#[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();
|
||||
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();
|
||||
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));
|
||||
@@ -408,7 +453,11 @@ mod tests {
|
||||
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();
|
||||
upsert_draft(
|
||||
&conn, "draft-1", "", "", "", "Test", "", None, None, None, false, "normal", "html",
|
||||
"none", false, false, false,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
add_draft_attachment(
|
||||
&conn,
|
||||
@@ -443,8 +492,21 @@ mod tests {
|
||||
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();
|
||||
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);
|
||||
@@ -457,9 +519,31 @@ mod tests {
|
||||
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();
|
||||
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();
|
||||
|
||||
|
||||
+117
-82
@@ -1,7 +1,6 @@
|
||||
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,
|
||||
@@ -15,7 +14,6 @@ pub struct CachedFolder {
|
||||
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,
|
||||
@@ -50,11 +48,6 @@ pub fn upsert_folder(
|
||||
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,
|
||||
@@ -71,7 +64,6 @@ pub fn insert_folder_if_new(
|
||||
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(
|
||||
@@ -107,24 +99,15 @@ pub fn get_all_folders(conn: &Connection) -> Result<Vec<CachedFolder>, String> {
|
||||
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> {
|
||||
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 placeholders: Vec<String> = (1..=current_names.len()).map(|i| format!("?{i}")).collect();
|
||||
let sql = format!(
|
||||
"DELETE FROM folders WHERE name NOT IN ({})",
|
||||
placeholders.join(", ")
|
||||
@@ -142,18 +125,12 @@ pub fn remove_stale_folders(
|
||||
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}"))?;
|
||||
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,
|
||||
@@ -169,9 +146,6 @@ pub fn update_folder_status(
|
||||
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,
|
||||
@@ -188,7 +162,6 @@ pub fn update_folder_sync_status(
|
||||
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(
|
||||
@@ -207,8 +180,6 @@ pub fn refresh_unread_count(conn: &Connection, folder_name: &str) -> Result<(),
|
||||
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",
|
||||
@@ -218,8 +189,6 @@ pub fn adjust_unread_count(conn: &Connection, folder_name: &str, delta: i32) ->
|
||||
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(
|
||||
@@ -234,10 +203,11 @@ pub fn is_folder_cache_fresh(conn: &Connection, max_age_secs: u32) -> Result<boo
|
||||
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> {
|
||||
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(
|
||||
@@ -251,10 +221,10 @@ pub fn is_folder_fresh(conn: &Connection, folder_name: &str, max_age_secs: u32)
|
||||
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> {
|
||||
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",
|
||||
@@ -265,8 +235,6 @@ pub fn is_folder_messages_invalidated(conn: &Connection, folder_name: &str) -> R
|
||||
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",
|
||||
@@ -276,7 +244,6 @@ pub fn invalidate_folder_freshness(conn: &Connection, folder_name: &str) -> Resu
|
||||
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,
|
||||
@@ -308,18 +275,25 @@ pub fn get_folder(conn: &Connection, name: &str) -> Result<Option<CachedFolder>,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 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}"))?;
|
||||
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}"))?;
|
||||
|
||||
@@ -333,23 +307,37 @@ pub fn rename_folder_in_cache(conn: &Connection, old_name: &str, new_name: &str)
|
||||
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(
|
||||
"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}"))?;
|
||||
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> {
|
||||
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],
|
||||
@@ -368,15 +356,36 @@ mod tests {
|
||||
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();
|
||||
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);
|
||||
@@ -390,12 +399,33 @@ mod tests {
|
||||
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_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();
|
||||
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);
|
||||
@@ -416,7 +446,10 @@ mod tests {
|
||||
|
||||
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);
|
||||
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);
|
||||
@@ -430,15 +463,18 @@ mod tests {
|
||||
conn.execute(
|
||||
"UPDATE folders SET messages_updated_at = '2000-01-01 00:00:00' WHERE name = 'Sent'",
|
||||
[],
|
||||
).unwrap();
|
||||
)
|
||||
.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();
|
||||
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());
|
||||
}
|
||||
@@ -451,7 +487,6 @@ mod tests {
|
||||
upsert_folder(&conn, "Sent", None, None, "", true, 0, 0, 0, 0).unwrap();
|
||||
upsert_folder(&conn, "Trash", None, None, "", true, 0, 0, 0, 0).unwrap();
|
||||
|
||||
// Keep only INBOX and Sent; Trash should be removed.
|
||||
let current = vec!["INBOX".to_string(), "Sent".to_string()];
|
||||
let deleted = remove_stale_folders(&conn, ¤t).unwrap();
|
||||
assert_eq!(deleted, 1);
|
||||
|
||||
@@ -1,9 +1,3 @@
|
||||
//! 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)]
|
||||
@@ -12,7 +6,6 @@ pub(crate) struct HeaderSync {
|
||||
}
|
||||
|
||||
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(
|
||||
@@ -28,7 +21,6 @@ impl HeaderSync {
|
||||
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())?;
|
||||
@@ -39,7 +31,6 @@ impl HeaderSync {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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')
|
||||
|
||||
+311
-170
@@ -1,9 +1,6 @@
|
||||
use rusqlite::{Connection, params};
|
||||
use serde::Serialize;
|
||||
|
||||
// mail_parser is used for RFC2822 date parsing in parse_date_to_epoch.
|
||||
|
||||
/// Message data needed to rebuild the full-text search index from SQLite.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct IndexSourceMessage {
|
||||
pub uid: u32,
|
||||
@@ -17,8 +14,6 @@ pub struct IndexSourceMessage {
|
||||
pub has_attachments: bool,
|
||||
}
|
||||
|
||||
/// A cached email message header, mirroring the query-visible columns of the
|
||||
/// `messages` table.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct CachedMessage {
|
||||
pub uid: u32,
|
||||
@@ -39,10 +34,6 @@ pub struct CachedMessage {
|
||||
pub reaction: Option<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper to map a row to CachedMessage (used in multiple queries)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn row_to_cached_message(row: &rusqlite::Row<'_>) -> rusqlite::Result<CachedMessage> {
|
||||
let has_attachments_int: i32 = row.get(12)?;
|
||||
Ok(CachedMessage {
|
||||
@@ -65,17 +56,10 @@ fn row_to_cached_message(row: &rusqlite::Row<'_>) -> rusqlite::Result<CachedMess
|
||||
})
|
||||
}
|
||||
|
||||
/// The SELECT column list used by all queries that return `CachedMessage`.
|
||||
const MSG_SELECT_COLS: &str =
|
||||
"uid, folder, message_id, in_reply_to, references_header,
|
||||
const MSG_SELECT_COLS: &str = "uid, folder, message_id, in_reply_to, references_header,
|
||||
subject, from_address, from_name, to_addresses, cc_addresses,
|
||||
date, flags, has_attachments, size, snippet, reaction";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Insert or replace a message header row.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn upsert_message(
|
||||
conn: &Connection,
|
||||
@@ -127,8 +111,6 @@ pub fn upsert_message(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove a successfully moved/deleted source UID and reject older header reads.
|
||||
/// Invalidate freshness so a rejected batch is retried by the next list request.
|
||||
pub fn remove_after_mutation(conn: &Connection, folder: &str, uid: u32) -> Result<(), String> {
|
||||
let transaction =
|
||||
rusqlite::Transaction::new_unchecked(conn, rusqlite::TransactionBehavior::Immediate)
|
||||
@@ -139,7 +121,6 @@ pub fn remove_after_mutation(conn: &Connection, folder: &str, uid: u32) -> Resul
|
||||
transaction.commit().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Delete all messages in a folder (used when UIDVALIDITY changes).
|
||||
pub fn delete_folder_messages(conn: &Connection, folder: &str) -> Result<usize, String> {
|
||||
let deleted = conn
|
||||
.execute("DELETE FROM messages WHERE folder = ?1", params![folder])
|
||||
@@ -147,7 +128,6 @@ pub fn delete_folder_messages(conn: &Connection, folder: &str) -> Result<usize,
|
||||
Ok(deleted)
|
||||
}
|
||||
|
||||
/// Return the highest cached UID for a folder, or 0 if no messages are cached.
|
||||
pub fn max_uid(conn: &Connection, folder: &str) -> Result<u32, String> {
|
||||
conn.query_row(
|
||||
"SELECT COALESCE(MAX(uid), 0) FROM messages WHERE folder = ?1",
|
||||
@@ -157,9 +137,6 @@ pub fn max_uid(conn: &Connection, folder: &str) -> Result<u32, String> {
|
||||
.map_err(|e| format!("Failed to get max uid: {e}"))
|
||||
}
|
||||
|
||||
/// Return all cached message data needed for a full Tantivy rebuild.
|
||||
/// Body text falls back to the message snippet when the full body has not yet
|
||||
/// been cached, so a rebuild remains useful before every message is opened.
|
||||
pub fn list_index_source_messages(conn: &Connection) -> Result<Vec<IndexSourceMessage>, String> {
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
@@ -193,27 +170,22 @@ pub fn list_index_source_messages(conn: &Connection) -> Result<Vec<IndexSourceMe
|
||||
Ok(messages)
|
||||
}
|
||||
|
||||
/// Public wrapper to parse a date string to a Unix epoch timestamp.
|
||||
/// Useful when other modules need to compute `date_epoch` from a raw date string.
|
||||
pub fn parse_date_to_epoch_public(date: &str) -> i64 {
|
||||
parse_date_to_epoch(date)
|
||||
}
|
||||
|
||||
/// Parse a date string to a Unix epoch timestamp (seconds).
|
||||
/// Tries RFC2822 first (IMAP dates), then ISO 8601.
|
||||
/// Returns 0 on parse failure.
|
||||
fn parse_date_to_epoch(date: &str) -> i64 {
|
||||
if date.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
// Try ISO 8601 format first (used in test fixtures and some IMAP servers).
|
||||
|
||||
if let Ok(epoch) = parse_iso8601_to_epoch(date) {
|
||||
return epoch;
|
||||
}
|
||||
// Try mail-parser's RFC2822 date parsing (standard IMAP date format).
|
||||
|
||||
if let Some(dt) = mail_parser::DateTime::parse_rfc822(date) {
|
||||
let epoch = datetime_to_epoch(&dt);
|
||||
// Sanity check: mail_parser can misparse non-RFC2822 dates.
|
||||
|
||||
if dt.year >= 1970 && dt.month >= 1 && dt.month <= 12 && epoch > 0 {
|
||||
return epoch;
|
||||
}
|
||||
@@ -221,9 +193,7 @@ fn parse_date_to_epoch(date: &str) -> i64 {
|
||||
0
|
||||
}
|
||||
|
||||
/// Convert a mail_parser::DateTime to a Unix epoch timestamp.
|
||||
fn datetime_to_epoch(dt: &mail_parser::DateTime) -> i64 {
|
||||
// Days from year 1970 to the start of the given year
|
||||
let year = dt.year as i64;
|
||||
let mut days: i64 = 0;
|
||||
for y in 1970..year {
|
||||
@@ -232,7 +202,7 @@ fn datetime_to_epoch(dt: &mail_parser::DateTime) -> i64 {
|
||||
for y in (year..1970).rev() {
|
||||
days -= if is_leap_year(y) { 366 } else { 365 };
|
||||
}
|
||||
// Days from start of year to start of month
|
||||
|
||||
let month_days: [i64; 12] = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
||||
let month = (dt.month as usize).saturating_sub(1).min(11);
|
||||
for (m, &d) in month_days.iter().enumerate().take(month) {
|
||||
@@ -243,8 +213,9 @@ fn datetime_to_epoch(dt: &mail_parser::DateTime) -> i64 {
|
||||
}
|
||||
days += (dt.day as i64) - 1;
|
||||
|
||||
let secs = days * 86400 + (dt.hour as i64) * 3600 + (dt.minute as i64) * 60 + (dt.second as i64);
|
||||
// tz_before_gmt=true means the offset is negative (behind UTC)
|
||||
let secs =
|
||||
days * 86400 + (dt.hour as i64) * 3600 + (dt.minute as i64) * 60 + (dt.second as i64);
|
||||
|
||||
let tz_offset_secs = (dt.tz_hour as i64) * 3600 + (dt.tz_minute as i64) * 60;
|
||||
if dt.tz_before_gmt {
|
||||
secs + tz_offset_secs
|
||||
@@ -257,9 +228,7 @@ fn is_leap_year(y: i64) -> bool {
|
||||
(y % 4 == 0 && y % 100 != 0) || y % 400 == 0
|
||||
}
|
||||
|
||||
/// Parse ISO 8601 dates like "2024-01-01T10:00:00Z"
|
||||
fn parse_iso8601_to_epoch(s: &str) -> Result<i64, ()> {
|
||||
// Simple parser for YYYY-MM-DDTHH:MM:SSZ
|
||||
let s = s.trim();
|
||||
if s.len() < 19 {
|
||||
return Err(());
|
||||
@@ -285,8 +254,6 @@ fn parse_iso8601_to_epoch(s: &str) -> Result<i64, ()> {
|
||||
Ok(datetime_to_epoch(&dt))
|
||||
}
|
||||
|
||||
/// Return a page of messages for a folder, ordered by date descending.
|
||||
/// `page` is 0-indexed.
|
||||
pub fn get_messages(
|
||||
conn: &Connection,
|
||||
folder: &str,
|
||||
@@ -319,10 +286,6 @@ pub fn get_messages(
|
||||
Ok(messages)
|
||||
}
|
||||
|
||||
/// Return a page of messages with a validated sort order.
|
||||
///
|
||||
/// `sort` is mapped to a static SQL fragment; user input is never interpolated
|
||||
/// into the ORDER BY clause.
|
||||
pub fn get_messages_sorted(
|
||||
conn: &Connection,
|
||||
folder: &str,
|
||||
@@ -339,7 +302,9 @@ pub fn get_messages_sorted(
|
||||
"date_desc" => "date_epoch DESC, uid DESC",
|
||||
"date_asc" => "date_epoch ASC, uid ASC",
|
||||
"sender_asc" => "LOWER(COALESCE(NULLIF(from_name, ''), from_address)) ASC, date_epoch DESC",
|
||||
"sender_desc" => "LOWER(COALESCE(NULLIF(from_name, ''), from_address)) DESC, date_epoch DESC",
|
||||
"sender_desc" => {
|
||||
"LOWER(COALESCE(NULLIF(from_name, ''), from_address)) DESC, date_epoch DESC"
|
||||
}
|
||||
"subject_asc" => "LOWER(subject) ASC, date_epoch DESC",
|
||||
"subject_desc" => "LOWER(subject) DESC, date_epoch DESC",
|
||||
"size_asc" => "size ASC, date_epoch DESC",
|
||||
@@ -370,7 +335,6 @@ pub fn get_messages_sorted(
|
||||
.map_err(|e| format!("Failed to read sorted message row: {e}"))
|
||||
}
|
||||
|
||||
/// Return the total number of cached messages for a folder.
|
||||
pub fn count_messages(conn: &Connection, folder: &str) -> Result<u32, String> {
|
||||
conn.query_row(
|
||||
"SELECT COUNT(*) FROM messages WHERE folder = ?1",
|
||||
@@ -380,9 +344,6 @@ pub fn count_messages(conn: &Connection, folder: &str) -> Result<u32, String> {
|
||||
.map_err(|e| format!("Failed to count messages: {e}"))
|
||||
}
|
||||
|
||||
/// Return the total number of cached messages across all folders.
|
||||
/// Used as a safe upper bound when the UI explicitly asks to materialize
|
||||
/// every result of a search for a bulk operation.
|
||||
pub fn count_all_messages(conn: &Connection) -> Result<usize, String> {
|
||||
let count: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM messages", [], |row| row.get(0))
|
||||
@@ -390,7 +351,6 @@ pub fn count_all_messages(conn: &Connection) -> Result<usize, String> {
|
||||
usize::try_from(count).map_err(|_| "Cached message count is out of range".to_string())
|
||||
}
|
||||
|
||||
/// Update only the flags column for a specific message.
|
||||
pub fn update_message_flags(
|
||||
conn: &Connection,
|
||||
folder: &str,
|
||||
@@ -405,8 +365,6 @@ pub fn update_message_flags(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Cache a message body (HTML and/or plain text) along with attachment
|
||||
/// metadata (as a JSON string) and the raw RFC-822 headers.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn cache_message_body(
|
||||
conn: &Connection,
|
||||
@@ -423,7 +381,15 @@ pub fn cache_message_body(
|
||||
SET body_html = ?1, body_text = ?2, body_cached = 1,
|
||||
attachments_json = ?3, raw_headers = ?4, email_theme = ?5
|
||||
WHERE folder = ?6 AND uid = ?7",
|
||||
params![html, text, attachments_json, raw_headers, email_theme, folder, uid],
|
||||
params![
|
||||
html,
|
||||
text,
|
||||
attachments_json,
|
||||
raw_headers,
|
||||
email_theme,
|
||||
folder,
|
||||
uid
|
||||
],
|
||||
)
|
||||
.map_err(|e| format!("Failed to cache message body: {e}"))?;
|
||||
Ok(())
|
||||
@@ -443,7 +409,6 @@ pub fn update_email_theme(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Cached body data including attachment metadata and raw headers.
|
||||
pub struct CachedBody {
|
||||
pub html: Option<String>,
|
||||
pub text: Option<String>,
|
||||
@@ -452,7 +417,6 @@ pub struct CachedBody {
|
||||
pub email_theme: Option<i32>,
|
||||
}
|
||||
|
||||
/// Return the cached body if `body_cached = 1`, otherwise `None`.
|
||||
pub fn get_cached_body(
|
||||
conn: &Connection,
|
||||
folder: &str,
|
||||
@@ -486,17 +450,18 @@ pub fn get_cached_body(
|
||||
}
|
||||
}
|
||||
|
||||
/// Return a single message by folder and UID, or None if not found.
|
||||
pub fn get_single_message(conn: &Connection, folder: &str, uid: u32) -> Result<Option<CachedMessage>, String> {
|
||||
pub fn get_single_message(
|
||||
conn: &Connection,
|
||||
folder: &str,
|
||||
uid: u32,
|
||||
) -> Result<Option<CachedMessage>, String> {
|
||||
let sql = format!(
|
||||
"SELECT {MSG_SELECT_COLS}
|
||||
FROM messages
|
||||
WHERE folder = ?1 AND uid = ?2"
|
||||
);
|
||||
|
||||
let result = conn.query_row(&sql, params![folder, uid], |row| {
|
||||
row_to_cached_message(row)
|
||||
});
|
||||
let result = conn.query_row(&sql, params![folder, uid], |row| row_to_cached_message(row));
|
||||
|
||||
match result {
|
||||
Ok(msg) => Ok(Some(msg)),
|
||||
@@ -505,7 +470,6 @@ pub fn get_single_message(conn: &Connection, folder: &str, uid: u32) -> Result<O
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete a single message by folder and UID.
|
||||
pub fn delete_message(conn: &Connection, folder: &str, uid: u32) -> Result<(), String> {
|
||||
conn.execute(
|
||||
"DELETE FROM messages WHERE folder = ?1 AND uid = ?2",
|
||||
@@ -515,9 +479,6 @@ pub fn delete_message(conn: &Connection, folder: &str, uid: u32) -> Result<(), S
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Return `(folder, uid)` pairs for messages that don't have a cached body,
|
||||
/// ordered by date_epoch DESC (most recent first), limited to `limit`.
|
||||
/// Used by the deep-index background task to know which bodies to fetch.
|
||||
pub fn get_unindexed_messages(conn: &Connection, limit: u32) -> Result<Vec<(String, u32)>, String> {
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
@@ -541,8 +502,10 @@ pub fn get_unindexed_messages(conn: &Connection, limit: u32) -> Result<Vec<(Stri
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Return all (uid, flags_csv) pairs for a folder, for reconciliation.
|
||||
pub fn get_all_uids_and_flags(conn: &Connection, folder: &str) -> Result<Vec<(u32, String)>, String> {
|
||||
pub fn get_all_uids_and_flags(
|
||||
conn: &Connection,
|
||||
folder: &str,
|
||||
) -> Result<Vec<(u32, String)>, String> {
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT uid, flags FROM messages WHERE folder = ?1")
|
||||
.map_err(|e| format!("Failed to prepare get_all_uids_and_flags: {e}"))?;
|
||||
@@ -559,17 +522,10 @@ pub fn get_all_uids_and_flags(conn: &Connection, folder: &str) -> Result<Vec<(u3
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Find messages related to the given `target_message_id` for threading.
|
||||
///
|
||||
/// Returns messages where:
|
||||
/// - `message_id` equals `target_message_id`, OR
|
||||
/// - `in_reply_to` equals `target_message_id`, OR
|
||||
/// - `references_header` contains `target_message_id`
|
||||
pub fn get_thread_messages(
|
||||
conn: &Connection,
|
||||
target_message_id: &str,
|
||||
) -> Result<Vec<CachedMessage>, String> {
|
||||
// Escape % and _ characters in the message_id to prevent LIKE injection.
|
||||
let escaped_target = target_message_id.replace("%", "\\%").replace("_", "\\_");
|
||||
let like_pattern = format!("%{}%", escaped_target);
|
||||
let sql = format!(
|
||||
@@ -594,8 +550,7 @@ pub fn get_thread_messages(
|
||||
let mut messages = Vec::new();
|
||||
for row in rows {
|
||||
let message = row.map_err(|e| format!("Failed to read thread message row: {e}"))?;
|
||||
// The SQL LIKE narrows candidates but a Message-ID must match a complete
|
||||
// token in References, never just a substring of another ID.
|
||||
|
||||
let exact_reference = message
|
||||
.references_header
|
||||
.as_deref()
|
||||
@@ -610,14 +565,6 @@ pub fn get_thread_messages(
|
||||
Ok(messages)
|
||||
}
|
||||
|
||||
/// Build a complete email thread by transitively walking the References chain.
|
||||
///
|
||||
/// Starting from `message_id` and the optional space-separated `references` string,
|
||||
/// this function repeatedly queries related messages (via `get_thread_messages`) and
|
||||
/// collects every message_id, in_reply_to, and references_header ID it encounters.
|
||||
/// The process repeats until no new IDs are discovered (transitive closure).
|
||||
///
|
||||
/// The result is deduplicated by `(folder, uid)` and sorted by `date_epoch` ascending.
|
||||
pub fn get_full_thread(
|
||||
conn: &Connection,
|
||||
message_id: &str,
|
||||
@@ -625,7 +572,6 @@ pub fn get_full_thread(
|
||||
) -> Result<Vec<CachedMessage>, String> {
|
||||
use std::collections::HashSet;
|
||||
|
||||
// Step 1: Seed the set of known message IDs.
|
||||
let mut known_ids: HashSet<String> = HashSet::new();
|
||||
known_ids.insert(message_id.to_string());
|
||||
if let Some(refs) = references {
|
||||
@@ -636,16 +582,11 @@ pub fn get_full_thread(
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2-4: Iteratively query until no new IDs are found.
|
||||
let mut processed_ids: HashSet<String> = HashSet::new();
|
||||
let mut all_messages: Vec<CachedMessage> = Vec::new();
|
||||
|
||||
loop {
|
||||
// Find IDs we haven't queried yet.
|
||||
let to_query: Vec<String> = known_ids
|
||||
.difference(&processed_ids)
|
||||
.cloned()
|
||||
.collect();
|
||||
let to_query: Vec<String> = known_ids.difference(&processed_ids).cloned().collect();
|
||||
|
||||
if to_query.is_empty() {
|
||||
break;
|
||||
@@ -656,7 +597,6 @@ pub fn get_full_thread(
|
||||
|
||||
let found = get_thread_messages(conn, id)?;
|
||||
for msg in found {
|
||||
// Extract IDs from each found message and add to known set.
|
||||
if let Some(ref mid) = msg.message_id
|
||||
&& !mid.is_empty()
|
||||
{
|
||||
@@ -679,19 +619,14 @@ pub fn get_full_thread(
|
||||
}
|
||||
}
|
||||
|
||||
// Step 5: Deduplicate by (folder, uid) pair.
|
||||
let mut seen: HashSet<(String, u32)> = HashSet::new();
|
||||
all_messages.retain(|msg| seen.insert((msg.folder.clone(), msg.uid)));
|
||||
|
||||
// Step 6: Sort by date_epoch ascending.
|
||||
all_messages.sort_by_key(|msg| parse_date_to_epoch(&msg.date));
|
||||
|
||||
Ok(all_messages)
|
||||
}
|
||||
|
||||
/// Search the SQLite message cache using LIKE for text matches across
|
||||
/// subject, from_name, from_address, and to_addresses.
|
||||
/// This provides comprehensive results independent of the tantivy index state.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn search_messages_sqlite(
|
||||
conn: &Connection,
|
||||
@@ -711,7 +646,6 @@ pub fn search_messages_sqlite(
|
||||
let mut param_values: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
|
||||
let mut idx = 1u32;
|
||||
|
||||
// Exclude Trash/Spam/Junk from search results (same as tantivy).
|
||||
conditions.push("LOWER(folder) NOT IN ('trash', 'spam', 'junk')".to_string());
|
||||
|
||||
if !text.is_empty() {
|
||||
@@ -741,7 +675,11 @@ pub fn search_messages_sqlite(
|
||||
} else {
|
||||
conditions.push(format!("from_name LIKE ?{idx}"));
|
||||
}
|
||||
let val = if f.contains('@') { f.to_string() } else { format!("%{f}%") };
|
||||
let val = if f.contains('@') {
|
||||
f.to_string()
|
||||
} else {
|
||||
format!("%{f}%")
|
||||
};
|
||||
param_values.push(Box::new(val));
|
||||
idx += 1;
|
||||
}
|
||||
@@ -786,7 +724,7 @@ pub fn search_messages_sqlite(
|
||||
});
|
||||
}
|
||||
|
||||
let _ = idx; // suppress unused warning
|
||||
let _ = idx;
|
||||
|
||||
let where_clause = if conditions.is_empty() {
|
||||
String::new()
|
||||
@@ -799,12 +737,14 @@ pub fn search_messages_sqlite(
|
||||
FROM messages
|
||||
{where_clause}
|
||||
ORDER BY date_epoch DESC
|
||||
LIMIT ?{}", param_values.len() + 1
|
||||
LIMIT ?{}",
|
||||
param_values.len() + 1
|
||||
);
|
||||
|
||||
param_values.push(Box::new(limit as i64));
|
||||
|
||||
let params_refs: Vec<&dyn rusqlite::types::ToSql> = param_values.iter().map(|b| b.as_ref()).collect();
|
||||
let params_refs: Vec<&dyn rusqlite::types::ToSql> =
|
||||
param_values.iter().map(|b| b.as_ref()).collect();
|
||||
|
||||
let mut stmt = conn
|
||||
.prepare(&sql)
|
||||
@@ -829,12 +769,10 @@ mod tests {
|
||||
use crate::db::folders::upsert_folder;
|
||||
use crate::db::pool::open_test_db;
|
||||
|
||||
/// Helper: insert a folder so that the foreign key constraint is satisfied.
|
||||
fn ensure_folder(conn: &Connection, name: &str) {
|
||||
upsert_folder(conn, name, Some("/"), None, "", true, 0, 0, 1, 0).unwrap();
|
||||
}
|
||||
|
||||
/// Helper: insert a sample message.
|
||||
fn insert_sample(conn: &Connection, folder: &str, uid: u32, date: &str) {
|
||||
upsert_message(
|
||||
conn,
|
||||
@@ -880,12 +818,10 @@ mod tests {
|
||||
assert!(e2 > e1, "date 2 ({e2}) should be after date 1 ({e1})");
|
||||
assert!(e3 > e2, "date 3 ({e3}) should be after date 2 ({e2})");
|
||||
|
||||
// RFC2822 format.
|
||||
let e4 = parse_date_to_epoch("Mon, 1 Jan 2024 10:00:00 +0000");
|
||||
assert!(e4 > 0, "epoch for rfc2822 date should be > 0, got {e4}");
|
||||
assert_eq!(e1, e4, "ISO and RFC2822 should produce same epoch");
|
||||
|
||||
// Empty returns 0.
|
||||
assert_eq!(parse_date_to_epoch(""), 0);
|
||||
}
|
||||
|
||||
@@ -901,7 +837,6 @@ mod tests {
|
||||
let msgs = get_messages(&conn, "INBOX", 0, 10).unwrap();
|
||||
assert_eq!(msgs.len(), 3);
|
||||
|
||||
// Should be date DESC: uid 3, 2, 1.
|
||||
assert_eq!(msgs[0].uid, 3);
|
||||
assert_eq!(msgs[1].uid, 2);
|
||||
assert_eq!(msgs[2].uid, 1);
|
||||
@@ -913,7 +848,12 @@ mod tests {
|
||||
ensure_folder(&conn, "INBOX");
|
||||
|
||||
for uid in 1..=5 {
|
||||
insert_sample(&conn, "INBOX", uid, &format!("2024-01-{:02}T10:00:00Z", uid));
|
||||
insert_sample(
|
||||
&conn,
|
||||
"INBOX",
|
||||
uid,
|
||||
&format!("2024-01-{:02}T10:00:00Z", uid),
|
||||
);
|
||||
}
|
||||
|
||||
let page0 = get_messages(&conn, "INBOX", 0, 2).unwrap();
|
||||
@@ -924,7 +864,6 @@ mod tests {
|
||||
assert_eq!(page1.len(), 2);
|
||||
assert_eq!(page2.len(), 1);
|
||||
|
||||
// Verify no UIDs overlap between pages.
|
||||
let uids0: Vec<u32> = page0.iter().map(|m| m.uid).collect();
|
||||
let uids1: Vec<u32> = page1.iter().map(|m| m.uid).collect();
|
||||
let uids2: Vec<u32> = page2.iter().map(|m| m.uid).collect();
|
||||
@@ -969,7 +908,6 @@ mod tests {
|
||||
ensure_folder(&conn, "INBOX");
|
||||
insert_sample(&conn, "INBOX", 1, "2024-01-01T10:00:00Z");
|
||||
|
||||
// Body should not be cached yet.
|
||||
let body = get_cached_body(&conn, "INBOX", 1).unwrap();
|
||||
assert!(body.is_none());
|
||||
}
|
||||
@@ -1011,10 +949,16 @@ mod tests {
|
||||
assert!(body.is_none());
|
||||
|
||||
cache_message_body(
|
||||
&conn, "INBOX", 1,
|
||||
Some("<h1>Test</h1>"), Some("Test"),
|
||||
None, None, Some(0),
|
||||
).unwrap();
|
||||
&conn,
|
||||
"INBOX",
|
||||
1,
|
||||
Some("<h1>Test</h1>"),
|
||||
Some("Test"),
|
||||
None,
|
||||
None,
|
||||
Some(0),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
update_email_theme(&conn, "INBOX", 1, 1).unwrap();
|
||||
|
||||
@@ -1041,27 +985,51 @@ mod tests {
|
||||
let conn = open_test_db();
|
||||
ensure_folder(&conn, "INBOX");
|
||||
|
||||
// Original message.
|
||||
upsert_message(
|
||||
&conn, "INBOX", 1,
|
||||
Some("<thread-1@example.com>"), None, None,
|
||||
"Hello", "alice@example.com", "Alice", "[]", "[]",
|
||||
"2024-01-01T10:00:00Z", "", 100, false, "", None,
|
||||
).unwrap();
|
||||
&conn,
|
||||
"INBOX",
|
||||
1,
|
||||
Some("<thread-1@example.com>"),
|
||||
None,
|
||||
None,
|
||||
"Hello",
|
||||
"alice@example.com",
|
||||
"Alice",
|
||||
"[]",
|
||||
"[]",
|
||||
"2024-01-01T10:00:00Z",
|
||||
"",
|
||||
100,
|
||||
false,
|
||||
"",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Reply referencing original via in_reply_to.
|
||||
upsert_message(
|
||||
&conn, "INBOX", 2,
|
||||
&conn,
|
||||
"INBOX",
|
||||
2,
|
||||
Some("<reply-1@example.com>"),
|
||||
Some("<thread-1@example.com>"),
|
||||
None,
|
||||
"Re: Hello", "bob@example.com", "Bob", "[]", "[]",
|
||||
"2024-01-02T10:00:00Z", "", 200, false, "", None,
|
||||
).unwrap();
|
||||
"Re: Hello",
|
||||
"bob@example.com",
|
||||
"Bob",
|
||||
"[]",
|
||||
"[]",
|
||||
"2024-01-02T10:00:00Z",
|
||||
"",
|
||||
200,
|
||||
false,
|
||||
"",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let thread = get_thread_messages(&conn, "<thread-1@example.com>").unwrap();
|
||||
assert_eq!(thread.len(), 2);
|
||||
// ASC order: uid 1 first, uid 2 second.
|
||||
|
||||
assert_eq!(thread[0].uid, 1);
|
||||
assert_eq!(thread[1].uid, 2);
|
||||
}
|
||||
@@ -1071,28 +1039,52 @@ mod tests {
|
||||
let conn = open_test_db();
|
||||
ensure_folder(&conn, "INBOX");
|
||||
|
||||
// Original message.
|
||||
upsert_message(
|
||||
&conn, "INBOX", 1,
|
||||
Some("<orig@example.com>"), None, None,
|
||||
"Hello", "alice@example.com", "Alice", "[]", "[]",
|
||||
"2024-01-01T10:00:00Z", "", 100, false, "", None,
|
||||
).unwrap();
|
||||
&conn,
|
||||
"INBOX",
|
||||
1,
|
||||
Some("<orig@example.com>"),
|
||||
None,
|
||||
None,
|
||||
"Hello",
|
||||
"alice@example.com",
|
||||
"Alice",
|
||||
"[]",
|
||||
"[]",
|
||||
"2024-01-01T10:00:00Z",
|
||||
"",
|
||||
100,
|
||||
false,
|
||||
"",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// A message that references the original only via references_header.
|
||||
upsert_message(
|
||||
&conn, "INBOX", 3,
|
||||
&conn,
|
||||
"INBOX",
|
||||
3,
|
||||
Some("<deep-reply@example.com>"),
|
||||
Some("<mid@example.com>"),
|
||||
Some("<orig@example.com> <mid@example.com>"),
|
||||
"Re: Re: Hello", "carol@example.com", "Carol", "[]", "[]",
|
||||
"2024-01-03T10:00:00Z", "", 300, false, "", None,
|
||||
).unwrap();
|
||||
"Re: Re: Hello",
|
||||
"carol@example.com",
|
||||
"Carol",
|
||||
"[]",
|
||||
"[]",
|
||||
"2024-01-03T10:00:00Z",
|
||||
"",
|
||||
300,
|
||||
false,
|
||||
"",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let thread = get_thread_messages(&conn, "<orig@example.com>").unwrap();
|
||||
assert_eq!(thread.len(), 2);
|
||||
assert_eq!(thread[0].uid, 1); // matched by message_id
|
||||
assert_eq!(thread[1].uid, 3); // matched by references_header LIKE
|
||||
assert_eq!(thread[0].uid, 1);
|
||||
assert_eq!(thread[1].uid, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1101,17 +1093,45 @@ mod tests {
|
||||
ensure_folder(&conn, "INBOX");
|
||||
|
||||
upsert_message(
|
||||
&conn, "INBOX", 1,
|
||||
Some("<a@example.com>"), None, None,
|
||||
"Original", "alice@example.com", "Alice", "[]", "[]",
|
||||
"2024-01-01T10:00:00Z", "", 100, false, "", None,
|
||||
).unwrap();
|
||||
&conn,
|
||||
"INBOX",
|
||||
1,
|
||||
Some("<a@example.com>"),
|
||||
None,
|
||||
None,
|
||||
"Original",
|
||||
"alice@example.com",
|
||||
"Alice",
|
||||
"[]",
|
||||
"[]",
|
||||
"2024-01-01T10:00:00Z",
|
||||
"",
|
||||
100,
|
||||
false,
|
||||
"",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
upsert_message(
|
||||
&conn, "INBOX", 2,
|
||||
Some("<reply@example.com>"), None, Some("<aa@example.com>"),
|
||||
"Unrelated", "bob@example.com", "Bob", "[]", "[]",
|
||||
"2024-01-02T10:00:00Z", "", 100, false, "", None,
|
||||
).unwrap();
|
||||
&conn,
|
||||
"INBOX",
|
||||
2,
|
||||
Some("<reply@example.com>"),
|
||||
None,
|
||||
Some("<aa@example.com>"),
|
||||
"Unrelated",
|
||||
"bob@example.com",
|
||||
"Bob",
|
||||
"[]",
|
||||
"[]",
|
||||
"2024-01-02T10:00:00Z",
|
||||
"",
|
||||
100,
|
||||
false,
|
||||
"",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let thread = get_thread_messages(&conn, "<a@example.com>").unwrap();
|
||||
assert_eq!(thread.len(), 1);
|
||||
@@ -1123,17 +1143,69 @@ mod tests {
|
||||
let conn = open_test_db();
|
||||
ensure_folder(&conn, "INBOX");
|
||||
|
||||
// Message 1: original
|
||||
upsert_message(&conn, "INBOX", 1, Some("<a@ex>"), None, None,
|
||||
"Hello", "alice@ex", "Alice", "[]", "[]", "2024-01-01T10:00:00Z", "", 100, false, "", None).unwrap();
|
||||
// Message 2: reply to 1
|
||||
upsert_message(&conn, "INBOX", 2, Some("<b@ex>"), Some("<a@ex>"), Some("<a@ex>"),
|
||||
"Re: Hello", "bob@ex", "Bob", "[]", "[]", "2024-01-02T10:00:00Z", "", 100, false, "", None).unwrap();
|
||||
// Message 3: reply to 2, references both
|
||||
upsert_message(&conn, "INBOX", 3, Some("<c@ex>"), Some("<b@ex>"), Some("<a@ex> <b@ex>"),
|
||||
"Re: Re: Hello", "carol@ex", "Carol", "[]", "[]", "2024-01-03T10:00:00Z", "", 100, false, "", None).unwrap();
|
||||
upsert_message(
|
||||
&conn,
|
||||
"INBOX",
|
||||
1,
|
||||
Some("<a@ex>"),
|
||||
None,
|
||||
None,
|
||||
"Hello",
|
||||
"alice@ex",
|
||||
"Alice",
|
||||
"[]",
|
||||
"[]",
|
||||
"2024-01-01T10:00:00Z",
|
||||
"",
|
||||
100,
|
||||
false,
|
||||
"",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
upsert_message(
|
||||
&conn,
|
||||
"INBOX",
|
||||
2,
|
||||
Some("<b@ex>"),
|
||||
Some("<a@ex>"),
|
||||
Some("<a@ex>"),
|
||||
"Re: Hello",
|
||||
"bob@ex",
|
||||
"Bob",
|
||||
"[]",
|
||||
"[]",
|
||||
"2024-01-02T10:00:00Z",
|
||||
"",
|
||||
100,
|
||||
false,
|
||||
"",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
upsert_message(
|
||||
&conn,
|
||||
"INBOX",
|
||||
3,
|
||||
Some("<c@ex>"),
|
||||
Some("<b@ex>"),
|
||||
Some("<a@ex> <b@ex>"),
|
||||
"Re: Re: Hello",
|
||||
"carol@ex",
|
||||
"Carol",
|
||||
"[]",
|
||||
"[]",
|
||||
"2024-01-03T10:00:00Z",
|
||||
"",
|
||||
100,
|
||||
false,
|
||||
"",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Starting from message 2, should find the entire thread (1, 2, 3).
|
||||
let thread = get_full_thread(&conn, "<b@ex>", Some("<a@ex>")).unwrap();
|
||||
assert_eq!(thread.len(), 3);
|
||||
assert_eq!(thread[0].uid, 1);
|
||||
@@ -1146,14 +1218,67 @@ mod tests {
|
||||
let conn = open_test_db();
|
||||
ensure_folder(&conn, "INBOX");
|
||||
|
||||
upsert_message(&conn, "INBOX", 1, Some("<root@ex>"), None, None,
|
||||
"Start", "alice@ex", "Alice", "[]", "[]", "2024-01-01T10:00:00Z", "", 100, false, "", None).unwrap();
|
||||
upsert_message(&conn, "INBOX", 2, Some("<mid@ex>"), Some("<root@ex>"), Some("<root@ex>"),
|
||||
"Re: Start", "bob@ex", "Bob", "[]", "[]", "2024-01-02T10:00:00Z", "", 100, false, "", None).unwrap();
|
||||
upsert_message(&conn, "INBOX", 3, Some("<leaf@ex>"), Some("<mid@ex>"), Some("<root@ex> <mid@ex>"),
|
||||
"Re: Re: Start", "carol@ex", "Carol", "[]", "[]", "2024-01-03T10:00:00Z", "", 100, false, "", None).unwrap();
|
||||
upsert_message(
|
||||
&conn,
|
||||
"INBOX",
|
||||
1,
|
||||
Some("<root@ex>"),
|
||||
None,
|
||||
None,
|
||||
"Start",
|
||||
"alice@ex",
|
||||
"Alice",
|
||||
"[]",
|
||||
"[]",
|
||||
"2024-01-01T10:00:00Z",
|
||||
"",
|
||||
100,
|
||||
false,
|
||||
"",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
upsert_message(
|
||||
&conn,
|
||||
"INBOX",
|
||||
2,
|
||||
Some("<mid@ex>"),
|
||||
Some("<root@ex>"),
|
||||
Some("<root@ex>"),
|
||||
"Re: Start",
|
||||
"bob@ex",
|
||||
"Bob",
|
||||
"[]",
|
||||
"[]",
|
||||
"2024-01-02T10:00:00Z",
|
||||
"",
|
||||
100,
|
||||
false,
|
||||
"",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
upsert_message(
|
||||
&conn,
|
||||
"INBOX",
|
||||
3,
|
||||
Some("<leaf@ex>"),
|
||||
Some("<mid@ex>"),
|
||||
Some("<root@ex> <mid@ex>"),
|
||||
"Re: Re: Start",
|
||||
"carol@ex",
|
||||
"Carol",
|
||||
"[]",
|
||||
"[]",
|
||||
"2024-01-03T10:00:00Z",
|
||||
"",
|
||||
100,
|
||||
false,
|
||||
"",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Starting from the leaf message, should still find entire thread.
|
||||
let thread = get_full_thread(&conn, "<leaf@ex>", Some("<root@ex> <mid@ex>")).unwrap();
|
||||
assert_eq!(thread.len(), 3);
|
||||
}
|
||||
@@ -1163,8 +1288,26 @@ mod tests {
|
||||
let conn = open_test_db();
|
||||
ensure_folder(&conn, "INBOX");
|
||||
|
||||
upsert_message(&conn, "INBOX", 1, Some("<solo@ex>"), None, None,
|
||||
"Solo", "alice@ex", "Alice", "[]", "[]", "2024-01-01T10:00:00Z", "", 100, false, "", None).unwrap();
|
||||
upsert_message(
|
||||
&conn,
|
||||
"INBOX",
|
||||
1,
|
||||
Some("<solo@ex>"),
|
||||
None,
|
||||
None,
|
||||
"Solo",
|
||||
"alice@ex",
|
||||
"Alice",
|
||||
"[]",
|
||||
"[]",
|
||||
"2024-01-01T10:00:00Z",
|
||||
"",
|
||||
100,
|
||||
false,
|
||||
"",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let thread = get_full_thread(&conn, "<solo@ex>", None).unwrap();
|
||||
assert_eq!(thread.len(), 1);
|
||||
@@ -1172,8 +1315,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Cached storage is an estimate while folders are still being synchronized.
|
||||
/// None distinguishes an empty cache from cached zero-byte messages.
|
||||
pub fn cached_storage_bytes(conn: &Connection) -> Result<Option<u64>, String> {
|
||||
conn.query_row("SELECT SUM(size) FROM messages", [], |row| row.get(0))
|
||||
.map_err(|e| e.to_string())
|
||||
|
||||
@@ -22,8 +22,6 @@ pub struct UpdateNotificationPreferences {
|
||||
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",
|
||||
@@ -41,7 +39,15 @@ pub fn get_preferences(conn: &Connection) -> Result<NotificationPreferences, Str
|
||||
);
|
||||
|
||||
match result {
|
||||
Ok((enabled_int, sound_int, folders_json, dnd_enabled_int, dnd_start, dnd_end, updated_at)) => {
|
||||
Ok((
|
||||
enabled_int,
|
||||
sound_int,
|
||||
folders_json,
|
||||
dnd_enabled_int,
|
||||
dnd_start,
|
||||
dnd_end,
|
||||
updated_at,
|
||||
)) => {
|
||||
let folders: Vec<String> = serde_json::from_str(&folders_json)
|
||||
.map_err(|e| format!("Failed to parse folders JSON: {e}"))?;
|
||||
Ok(NotificationPreferences {
|
||||
@@ -67,13 +73,10 @@ pub fn get_preferences(conn: &Connection) -> Result<NotificationPreferences, Str
|
||||
}
|
||||
}
|
||||
|
||||
/// 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)",
|
||||
[],
|
||||
@@ -95,8 +98,8 @@ pub fn update_preferences(
|
||||
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}"))?;
|
||||
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;
|
||||
@@ -107,13 +110,17 @@ pub fn update_preferences(
|
||||
idx += 1;
|
||||
}
|
||||
if let Some(ref dnd_start) = data.dnd_start {
|
||||
if !valid_clock(dnd_start) { return Err("Invalid DND start time".to_string()); }
|
||||
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()); }
|
||||
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;
|
||||
@@ -128,8 +135,7 @@ pub fn update_preferences(
|
||||
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();
|
||||
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}"))?;
|
||||
@@ -138,7 +144,9 @@ pub fn update_preferences(
|
||||
}
|
||||
|
||||
fn valid_clock(value: &str) -> bool {
|
||||
let Some((hour, minute)) = value.split_once(':') else { return false; };
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -197,10 +205,7 @@ mod tests {
|
||||
.unwrap();
|
||||
|
||||
assert!(prefs.enabled);
|
||||
assert_eq!(
|
||||
prefs.folders,
|
||||
vec!["INBOX".to_string(), "Sent".to_string()]
|
||||
);
|
||||
assert_eq!(prefs.folders, vec!["INBOX".to_string(), "Sent".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+1
-25
@@ -3,7 +3,6 @@ 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,
|
||||
@@ -32,7 +31,6 @@ impl UserDb {
|
||||
}
|
||||
}
|
||||
|
||||
/// All migration scripts in order. Each entry is (version, sql).
|
||||
const MIGRATIONS: &[(u32, &str)] = &[
|
||||
(1, include_str!("../../migrations/V001__initial_schema.sql")),
|
||||
(
|
||||
@@ -199,20 +197,11 @@ const MIGRATIONS: &[(u32, &str)] = &[
|
||||
),
|
||||
];
|
||||
|
||||
/// 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",
|
||||
@@ -241,10 +230,6 @@ fn run_migrations(conn: &Connection) -> Result<(), String> {
|
||||
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}"))?;
|
||||
@@ -252,23 +237,17 @@ pub fn open_user_db(data_dir: &str, user_hash: &str) -> Result<Connection, Strin
|
||||
let db_path = dir.join("db.sqlite");
|
||||
let conn = Connection::open(&db_path).map_err(|e| format!("Failed to open SQLite: {e}"))?;
|
||||
|
||||
// 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");
|
||||
@@ -289,7 +268,6 @@ mod tests {
|
||||
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()
|
||||
@@ -312,11 +290,9 @@ mod tests {
|
||||
|
||||
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();
|
||||
@@ -347,7 +323,7 @@ mod async_tests {
|
||||
.await
|
||||
});
|
||||
ready.await.unwrap();
|
||||
// On a current-thread runtime, an inline DB job prevents this send.
|
||||
|
||||
release.send(()).unwrap();
|
||||
job.await.unwrap().unwrap();
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use rusqlite::{params, Connection, OptionalExtension};
|
||||
use rusqlite::{Connection, OptionalExtension, params};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const ROLES: &[&str] = &["sent", "drafts", "trash", "junk", "archive"];
|
||||
@@ -78,7 +78,11 @@ pub fn update_all(conn: &Connection, folders: &SpecialFolders) -> Result<Special
|
||||
get_all(conn)
|
||||
}
|
||||
|
||||
pub fn rename_folder_reference(conn: &Connection, old_name: &str, new_name: &str) -> Result<(), String> {
|
||||
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],
|
||||
@@ -88,8 +92,11 @@ pub fn rename_folder_reference(conn: &Connection, old_name: &str, new_name: &str
|
||||
}
|
||||
|
||||
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}"))?;
|
||||
conn.execute(
|
||||
"DELETE FROM special_folders WHERE folder_name = ?1",
|
||||
[folder_name],
|
||||
)
|
||||
.map_err(|e| format!("Failed to clear special folder reference: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
+51
-50
@@ -1,11 +1,10 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use rusqlite::{params, Connection};
|
||||
use rusqlite::{Connection, params};
|
||||
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,
|
||||
@@ -17,7 +16,6 @@ pub struct Tag {
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
/// Lightweight tag info returned alongside messages.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct MessageTag {
|
||||
pub id: String,
|
||||
@@ -25,13 +23,11 @@ pub struct MessageTag {
|
||||
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>())
|
||||
format!(
|
||||
"RustBox-{}",
|
||||
id.chars().filter(|ch| *ch != '-').collect::<String>()
|
||||
)
|
||||
}
|
||||
|
||||
pub fn create_tag(conn: &Connection, id: &str, name: &str, color: &str) -> Result<(), String> {
|
||||
@@ -44,7 +40,6 @@ pub fn create_tag(conn: &Connection, id: &str, name: &str, color: &str) -> Resul
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// List all tags with their message counts.
|
||||
pub fn list_tags(conn: &Connection) -> Result<Vec<Tag>, String> {
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
@@ -81,22 +76,33 @@ pub fn get_tag(conn: &Connection, id: &str) -> Result<Option<Tag>, String> {
|
||||
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")
|
||||
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)?)))
|
||||
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}"))?); }
|
||||
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> {
|
||||
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); }
|
||||
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 {
|
||||
@@ -108,14 +114,6 @@ pub fn import_imap_keywords(conn: &Connection, uid: u32, folder: &str, flags: &[
|
||||
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,
|
||||
@@ -153,7 +151,6 @@ pub fn reconcile_imap_keywords(
|
||||
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(
|
||||
@@ -164,7 +161,6 @@ pub fn update_tag(conn: &Connection, id: &str, name: &str, color: &str) -> Resul
|
||||
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])
|
||||
@@ -172,7 +168,6 @@ pub fn delete_tag(conn: &Connection, id: &str) -> Result<bool, String> {
|
||||
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,
|
||||
@@ -187,7 +182,6 @@ pub fn add_tag_to_message(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove a tag from a message.
|
||||
pub fn remove_tag_from_message(
|
||||
conn: &Connection,
|
||||
tag_id: &str,
|
||||
@@ -203,8 +197,11 @@ pub fn remove_tag_from_message(
|
||||
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> {
|
||||
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
|
||||
@@ -232,7 +229,6 @@ pub fn get_message_tags(conn: &Connection, uid: u32, folder: &str) -> Result<Vec
|
||||
Ok(tags)
|
||||
}
|
||||
|
||||
/// Batch-fetch tags for multiple messages (efficient for list view).
|
||||
pub fn get_tags_for_messages(
|
||||
conn: &Connection,
|
||||
messages: &[(u32, &str)],
|
||||
@@ -241,12 +237,16 @@ pub fn get_tags_for_messages(
|
||||
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))
|
||||
.map(|(i, _)| {
|
||||
format!(
|
||||
"(mt.message_uid = ?{} AND mt.message_folder = ?{})",
|
||||
i * 2 + 1,
|
||||
i * 2 + 2
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let sql = format!(
|
||||
@@ -267,7 +267,8 @@ pub fn get_tags_for_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 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| {
|
||||
@@ -290,7 +291,6 @@ pub fn get_tags_for_messages(
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
/// Get messages by tag (paginated), ordered by date descending.
|
||||
pub fn get_messages_by_tag(
|
||||
conn: &Connection,
|
||||
tag_id: &str,
|
||||
@@ -342,7 +342,6 @@ pub fn get_messages_by_tag(
|
||||
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",
|
||||
@@ -420,14 +419,21 @@ mod tests {
|
||||
|
||||
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);
|
||||
assert_eq!(
|
||||
tag_list
|
||||
.iter()
|
||||
.find(|t| t.name == "Urgent")
|
||||
.unwrap()
|
||||
.message_count,
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -483,13 +489,9 @@ mod tests {
|
||||
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();
|
||||
let (added, removed) =
|
||||
reconcile_imap_keywords(&conn, 7, "INBOX", &["\\Seen".to_string(), keyword_two])
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(added, 1);
|
||||
assert_eq!(removed, 1);
|
||||
@@ -497,5 +499,4 @@ mod tests {
|
||||
assert_eq!(tags.len(), 1);
|
||||
assert_eq!(tags[0].id, "t2");
|
||||
}
|
||||
|
||||
}
|
||||
Reference in new issue
Block a user