1 parent
96a32ea8fb
commit
a643e7e3ab
106 files changed
+2223
-4669
No files matched your search
+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();
|
||||
|
||||
|
||||
Reference in new issue
Block a user