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

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

No files matched your search

+686
View File
@@ -0,0 +1,686 @@
use rusqlite::{params, Connection};
use serde::Serialize;
use uuid::Uuid;
/// A known address extracted from message headers (not necessarily a contact).
#[derive(Debug, Clone, Serialize)]
pub struct KnownAddress {
pub email: String,
pub name: String,
}
/// A contact record, mirroring the `contacts` table.
#[derive(Debug, Clone, Serialize)]
pub struct Contact {
pub id: String,
pub email: String,
pub name: String,
pub company: String,
pub notes: String,
pub phone: String,
pub job_title: String,
pub address: String,
pub website: String,
pub birthday: String,
pub emails: Vec<String>,
pub phones: Vec<String>,
pub addresses: Vec<String>,
pub is_favorite: bool,
pub last_contacted: Option<String>,
pub contact_count: i64,
pub source: String,
pub created_at: String,
pub updated_at: String,
pub has_photo: bool,
}
/// Map a rusqlite row to a Contact struct.
fn row_to_contact(row: &rusqlite::Row<'_>) -> rusqlite::Result<Contact> {
Ok(Contact {
id: row.get(0)?,
email: row.get(1)?,
name: row.get(2)?,
company: row.get(3)?,
notes: row.get(4)?,
phone: row.get(5)?,
job_title: row.get(6)?,
address: row.get(7)?,
website: row.get(8)?,
birthday: row.get(9)?,
emails: serde_json::from_str::<Vec<String>>(&row.get::<_, String>(16)?).unwrap_or_default(),
phones: serde_json::from_str::<Vec<String>>(&row.get::<_, String>(17)?).unwrap_or_default(),
addresses: serde_json::from_str::<Vec<String>>(&row.get::<_, String>(18)?).unwrap_or_default(),
is_favorite: row.get::<_, i32>(10)? != 0,
last_contacted: row.get(11)?,
contact_count: row.get(12)?,
source: row.get(13)?,
created_at: row.get(14)?,
updated_at: row.get(15)?,
has_photo: row.get::<_, i32>(19)? != 0,
})
}
/// The SELECT column list used by all queries that return `Contact`.
const CONTACT_SELECT_COLS: &str =
"id, email, name, company, notes, phone, job_title, address, website, birthday, is_favorite, last_contacted, contact_count, source, created_at, updated_at, emails_json, phones_json, addresses_json, EXISTS(SELECT 1 FROM contact_photos p WHERE p.contact_id = contacts.id) AS has_photo";
/// Escape `%` and `_` in a search term to prevent LIKE wildcard injection.
fn escape_like(term: &str) -> String {
term.replace('\\', "\\\\")
.replace('%', "\\%")
.replace('_', "\\_")
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/// Insert or replace a contact. All fields are written.
pub fn upsert_contact(conn: &Connection, contact: &Contact) -> Result<(), String> {
conn.execute(
"INSERT INTO contacts
(id, email, name, company, notes, phone, job_title, address, website, birthday, is_favorite, last_contacted,
contact_count, source, created_at, updated_at, emails_json, phones_json, addresses_json)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19)
ON CONFLICT(id) DO UPDATE SET
email=excluded.email, name=excluded.name, company=excluded.company, notes=excluded.notes,
phone=excluded.phone, job_title=excluded.job_title, address=excluded.address, website=excluded.website, birthday=excluded.birthday,
is_favorite=excluded.is_favorite, last_contacted=excluded.last_contacted, contact_count=excluded.contact_count,
source=excluded.source, updated_at=excluded.updated_at, emails_json=excluded.emails_json,
phones_json=excluded.phones_json, addresses_json=excluded.addresses_json",
params![
contact.id,
contact.email,
contact.name,
contact.company,
contact.notes,
contact.phone,
contact.job_title,
contact.address,
contact.website,
contact.birthday,
contact.is_favorite as i32,
contact.last_contacted,
contact.contact_count,
contact.source,
contact.created_at,
contact.updated_at,
serde_json::to_string(&contact.emails).unwrap_or_else(|_| "[]".to_string()),
serde_json::to_string(&contact.phones).unwrap_or_else(|_| "[]".to_string()),
serde_json::to_string(&contact.addresses).unwrap_or_else(|_| "[]".to_string()),
],
)
.map_err(|e| format!("Failed to upsert contact: {e}"))?;
Ok(())
}
/// Get a contact by its primary key `id`. Returns `None` if not found.
pub fn get_contact(conn: &Connection, id: &str) -> Result<Option<Contact>, String> {
let sql = format!("SELECT {CONTACT_SELECT_COLS} FROM contacts WHERE id = ?1");
let result = conn.query_row(&sql, params![id], row_to_contact);
match result {
Ok(c) => Ok(Some(c)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(format!("Failed to get contact: {e}")),
}
}
/// Get a contact by email address. Returns `None` if not found.
pub fn get_contact_by_email(conn: &Connection, email: &str) -> Result<Option<Contact>, String> {
let sql = format!(
"SELECT {CONTACT_SELECT_COLS} FROM contacts \
WHERE lower(email) = lower(?1) \
OR EXISTS (SELECT 1 FROM json_each(contacts.emails_json) WHERE lower(value) = lower(?1))"
);
let result = conn.query_row(&sql, params![email], row_to_contact);
match result {
Ok(c) => Ok(Some(c)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(format!("Failed to get contact by email: {e}")),
}
}
/// List contacts with optional search filter on name/email.
/// Results are ordered by name ascending, with LIMIT/OFFSET pagination.
pub fn list_contacts(
conn: &Connection,
search: Option<&str>,
limit: u32,
offset: u32,
) -> Result<Vec<Contact>, String> {
let (sql, has_search) = match search {
Some(q) if !q.is_empty() => {
let escaped = escape_like(q);
let pattern = format!("%{escaped}%");
let sql = format!(
"SELECT {CONTACT_SELECT_COLS} FROM contacts
WHERE name LIKE ?1 ESCAPE '\\' OR email LIKE ?1 ESCAPE '\\' OR emails_json LIKE ?1 ESCAPE '\\'
ORDER BY name ASC
LIMIT ?2 OFFSET ?3"
);
// We need to pass the pattern, so we store it and handle below.
// To keep things simple we branch on has_search.
drop(pattern);
(sql, Some(escaped))
}
_ => {
let sql = format!(
"SELECT {CONTACT_SELECT_COLS} FROM contacts
ORDER BY name ASC
LIMIT ?1 OFFSET ?2"
);
(sql, None)
}
};
match has_search {
Some(escaped) => {
let pattern = format!("%{escaped}%");
let mut stmt = conn
.prepare(&sql)
.map_err(|e| format!("Failed to prepare list_contacts: {e}"))?;
let rows = stmt
.query_map(params![pattern, limit, offset], row_to_contact)
.map_err(|e| format!("Failed to query contacts: {e}"))?;
let mut contacts = Vec::new();
for row in rows {
contacts.push(row.map_err(|e| format!("Failed to read contact row: {e}"))?);
}
Ok(contacts)
}
None => {
let mut stmt = conn
.prepare(&sql)
.map_err(|e| format!("Failed to prepare list_contacts: {e}"))?;
let rows = stmt
.query_map(params![limit, offset], row_to_contact)
.map_err(|e| format!("Failed to query contacts: {e}"))?;
let mut contacts = Vec::new();
for row in rows {
contacts.push(row.map_err(|e| format!("Failed to read contact row: {e}"))?);
}
Ok(contacts)
}
}
}
/// Delete a contact by id. Returns `true` if a row was deleted.
pub fn delete_contact(conn: &Connection, id: &str) -> Result<bool, String> {
let deleted = conn
.execute("DELETE FROM contacts WHERE id = ?1", params![id])
.map_err(|e| format!("Failed to delete contact: {e}"))?;
Ok(deleted > 0)
}
/// Search contacts by name or email using LIKE with escaped wildcards.
/// Results are ordered by name ascending.
pub fn search_contacts(conn: &Connection, query: &str, limit: u32) -> Result<Vec<Contact>, String> {
let escaped = escape_like(query);
let pattern = format!("%{escaped}%");
let sql = format!(
"SELECT {CONTACT_SELECT_COLS} FROM contacts
WHERE name LIKE ?1 ESCAPE '\\' OR email LIKE ?1 ESCAPE '\\' OR emails_json LIKE ?1 ESCAPE '\\'
ORDER BY name ASC
LIMIT ?2"
);
let mut stmt = conn
.prepare(&sql)
.map_err(|e| format!("Failed to prepare search_contacts: {e}"))?;
let rows = stmt
.query_map(params![pattern, limit], row_to_contact)
.map_err(|e| format!("Failed to query search contacts: {e}"))?;
let mut contacts = Vec::new();
for row in rows {
contacts.push(row.map_err(|e| format!("Failed to read contact row: {e}"))?);
}
Ok(contacts)
}
/// Search known addresses from the denormalized `known_addresses` table.
/// Excludes any addresses already present in the contacts table.
/// Returns distinct addresses ordered by email ascending.
pub fn search_known_addresses(
conn: &Connection,
query: &str,
limit: u32,
) -> Result<Vec<KnownAddress>, String> {
let escaped = escape_like(query);
let pattern = format!("%{escaped}%");
let sql = r#"
SELECT email, name
FROM known_addresses
WHERE (email LIKE ?1 ESCAPE '\' OR name LIKE ?1 ESCAPE '\')
AND email NOT IN (SELECT email FROM contacts)
AND NOT EXISTS (
SELECT 1 FROM contacts c, json_each(c.emails_json) alt
WHERE lower(alt.value) = lower(known_addresses.email)
)
ORDER BY email ASC
LIMIT ?2
"#;
let mut stmt = conn
.prepare(sql)
.map_err(|e| format!("Failed to prepare search_known_addresses: {e}"))?;
let rows = stmt
.query_map(params![pattern, limit], |row| {
Ok(KnownAddress {
email: row.get(0)?,
name: row.get(1)?,
})
})
.map_err(|e| format!("Failed to query known addresses: {e}"))?;
let mut addresses = Vec::new();
for row in rows {
addresses.push(row.map_err(|e| format!("Failed to read known address row: {e}"))?);
}
Ok(addresses)
}
/// Populate the `known_addresses` table from a single message's header fields.
/// Uses INSERT OR IGNORE so existing rows are not overwritten.
pub fn populate_known_addresses(
conn: &Connection,
from_address: &str,
from_name: &str,
to_json: &str,
cc_json: &str,
) -> Result<(), String> {
// Insert from address.
if !from_address.is_empty() {
conn.execute(
"INSERT INTO known_addresses (email, name) VALUES (?1, ?2)
ON CONFLICT(email) DO UPDATE SET name = excluded.name WHERE excluded.name != ''",
params![from_address, from_name],
)
.map_err(|e| format!("Failed to insert known address (from): {e}"))?;
}
// Insert to addresses from JSON array.
insert_addresses_from_json(conn, to_json)?;
// Insert cc addresses from JSON array.
insert_addresses_from_json(conn, cc_json)?;
Ok(())
}
/// Parse a JSON array of `{"address": "...", "name": "..."}` objects and insert
/// each into `known_addresses`.
fn insert_addresses_from_json(conn: &Connection, json: &str) -> Result<(), String> {
let entries: Vec<serde_json::Value> = match serde_json::from_str(json) {
Ok(v) => v,
Err(_) => return Ok(()), // Invalid JSON, skip silently.
};
for entry in &entries {
let email = entry
.get("address")
.and_then(|v| v.as_str())
.unwrap_or("");
if email.is_empty() {
continue;
}
let name = entry.get("name").and_then(|v| v.as_str()).unwrap_or("");
conn.execute(
"INSERT INTO known_addresses (email, name) VALUES (?1, ?2)
ON CONFLICT(email) DO UPDATE SET name = excluded.name WHERE excluded.name != ''",
params![email, name],
)
.map_err(|e| format!("Failed to insert known address (json): {e}"))?;
}
Ok(())
}
/// Increment the contact_count by 1 and set last_contacted to now for the
/// contact with the given email address.
#[allow(dead_code)]
pub fn increment_contact_count(conn: &Connection, email: &str) -> Result<(), String> {
conn.execute(
"UPDATE contacts
SET contact_count = contact_count + 1,
last_contacted = datetime('now'),
updated_at = datetime('now')
WHERE email = ?1",
params![email],
)
.map_err(|e| format!("Failed to increment contact count: {e}"))?;
Ok(())
}
/// Auto-add a contact if one with the same email does not already exist.
/// Uses INSERT OR IGNORE so existing contacts are not overwritten.
/// Source is set to 'auto'.
#[allow(dead_code)]
pub fn auto_add_contact(conn: &Connection, email: &str, name: &str) -> Result<(), String> {
let id = Uuid::new_v4().to_string();
conn.execute(
"INSERT OR IGNORE INTO contacts (id, email, name, source)
VALUES (?1, ?2, ?3, 'auto')",
params![id, email, name],
)
.map_err(|e| format!("Failed to auto-add contact: {e}"))?;
Ok(())
}
pub fn set_contact_photo(conn: &Connection, contact_id: &str, content_type: &str, data: &[u8]) -> Result<(), String> {
conn.execute(
"INSERT INTO contact_photos (contact_id, content_type, data, updated_at) VALUES (?1, ?2, ?3, datetime('now')) ON CONFLICT(contact_id) DO UPDATE SET content_type=excluded.content_type, data=excluded.data, updated_at=datetime('now')",
params![contact_id, content_type, data],
).map_err(|e| format!("Failed to save contact photo: {e}"))?;
Ok(())
}
pub fn get_contact_photo(conn: &Connection, contact_id: &str) -> Result<Option<(String, Vec<u8>)>, String> {
match conn.query_row(
"SELECT content_type, data FROM contact_photos WHERE contact_id = ?1",
params![contact_id],
|row| Ok((row.get(0)?, row.get(1)?)),
) {
Ok(value) => Ok(Some(value)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(format!("Failed to load contact photo: {e}")),
}
}
pub fn delete_contact_photo(conn: &Connection, contact_id: &str) -> Result<bool, String> {
conn.execute("DELETE FROM contact_photos WHERE contact_id = ?1", params![contact_id])
.map(|n| n > 0)
.map_err(|e| format!("Failed to delete contact photo: {e}"))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::pool::open_test_db;
/// Helper: create a sample Contact with sensible defaults.
fn sample_contact(id: &str, email: &str, name: &str) -> Contact {
Contact {
id: id.to_string(),
email: email.to_string(),
name: name.to_string(),
company: String::new(),
notes: String::new(),
phone: String::new(),
job_title: String::new(),
address: String::new(),
website: String::new(),
birthday: String::new(),
emails: Vec::new(),
phones: Vec::new(),
addresses: Vec::new(),
is_favorite: false,
last_contacted: None,
contact_count: 0,
source: "manual".to_string(),
created_at: "2024-01-01 00:00:00".to_string(),
updated_at: "2024-01-01 00:00:00".to_string(),
has_photo: false,
}
}
#[test]
fn test_upsert_and_get_contact() {
let conn = open_test_db();
let contact = sample_contact("c1", "alice@example.com", "Alice");
upsert_contact(&conn, &contact).unwrap();
let fetched = get_contact(&conn, "c1").unwrap();
assert!(fetched.is_some());
let fetched = fetched.unwrap();
assert_eq!(fetched.id, "c1");
assert_eq!(fetched.email, "alice@example.com");
assert_eq!(fetched.name, "Alice");
assert_eq!(fetched.source, "manual");
}
#[test]
fn test_get_contact_by_email() {
let conn = open_test_db();
let contact = sample_contact("c2", "bob@example.com", "Bob");
upsert_contact(&conn, &contact).unwrap();
let fetched = get_contact_by_email(&conn, "bob@example.com").unwrap();
assert!(fetched.is_some());
let fetched = fetched.unwrap();
assert_eq!(fetched.id, "c2");
assert_eq!(fetched.name, "Bob");
// Non-existent email returns None.
let missing = get_contact_by_email(&conn, "nobody@example.com").unwrap();
assert!(missing.is_none());
}
#[test]
fn test_list_contacts() {
let conn = open_test_db();
upsert_contact(&conn, &sample_contact("c1", "alice@example.com", "Alice")).unwrap();
upsert_contact(&conn, &sample_contact("c2", "bob@example.com", "Bob")).unwrap();
let all = list_contacts(&conn, None, 100, 0).unwrap();
assert_eq!(all.len(), 2);
// Ordered by name ASC: Alice, Bob.
assert_eq!(all[0].name, "Alice");
assert_eq!(all[1].name, "Bob");
}
#[test]
fn test_search_contacts() {
let conn = open_test_db();
upsert_contact(
&conn,
&sample_contact("c1", "alice@example.com", "Alice Smith"),
)
.unwrap();
upsert_contact(&conn, &sample_contact("c2", "bob@example.com", "Bob Jones")).unwrap();
// Search by name should find only Alice.
let results = search_contacts(&conn, "Alice", 10).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].name, "Alice Smith");
// Search by email should find only Bob.
let results = search_contacts(&conn, "bob@", 10).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].name, "Bob Jones");
}
#[test]
fn test_delete_contact() {
let conn = open_test_db();
upsert_contact(&conn, &sample_contact("c1", "alice@example.com", "Alice")).unwrap();
let deleted = delete_contact(&conn, "c1").unwrap();
assert!(deleted);
// Should be gone now.
let fetched = get_contact(&conn, "c1").unwrap();
assert!(fetched.is_none());
// Deleting again returns false.
let deleted_again = delete_contact(&conn, "c1").unwrap();
assert!(!deleted_again);
}
#[test]
fn test_auto_add_contact() {
let conn = open_test_db();
auto_add_contact(&conn, "auto@example.com", "Auto User").unwrap();
let fetched = get_contact_by_email(&conn, "auto@example.com").unwrap();
assert!(fetched.is_some());
let fetched = fetched.unwrap();
assert_eq!(fetched.source, "auto");
assert_eq!(fetched.name, "Auto User");
// Auto-adding the same email again should be a no-op (INSERT OR IGNORE).
auto_add_contact(&conn, "auto@example.com", "Different Name").unwrap();
let fetched2 = get_contact_by_email(&conn, "auto@example.com")
.unwrap()
.unwrap();
assert_eq!(fetched2.name, "Auto User"); // Name should NOT change.
}
#[test]
fn test_increment_contact_count() {
let conn = open_test_db();
upsert_contact(&conn, &sample_contact("c1", "alice@example.com", "Alice")).unwrap();
// Increment twice.
increment_contact_count(&conn, "alice@example.com").unwrap();
increment_contact_count(&conn, "alice@example.com").unwrap();
let fetched = get_contact_by_email(&conn, "alice@example.com")
.unwrap()
.unwrap();
assert_eq!(fetched.contact_count, 2);
assert!(fetched.last_contacted.is_some());
}
#[test]
fn test_search_known_addresses_basic() {
let conn = open_test_db();
// Populate known_addresses directly (simulating what happens at sync time).
populate_known_addresses(&conn, "alice@test.com", "Alice From", "[]", "[]").unwrap();
populate_known_addresses(
&conn,
"sender@other.com",
"Sender",
r#"[{"name":"Bob To","address":"bob@test.com"}]"#,
"[]",
)
.unwrap();
populate_known_addresses(
&conn,
"sender@other.com",
"Sender",
"[]",
r#"[{"name":"Charlie Cc","address":"charlie@test.com"}]"#,
)
.unwrap();
let results = search_known_addresses(&conn, "test.com", 10).unwrap();
assert_eq!(results.len(), 3);
assert_eq!(results[0].email, "alice@test.com");
assert_eq!(results[0].name, "Alice From");
assert_eq!(results[1].email, "bob@test.com");
assert_eq!(results[1].name, "Bob To");
assert_eq!(results[2].email, "charlie@test.com");
assert_eq!(results[2].name, "Charlie Cc");
}
#[test]
fn test_search_known_addresses_excludes_contacts() {
let conn = open_test_db();
// Add alice@test.com as a contact.
upsert_contact(
&conn,
&sample_contact("c1", "alice@test.com", "Alice Contact"),
)
.unwrap();
// Populate known_addresses (simulating sync).
populate_known_addresses(&conn, "alice@test.com", "Alice From", "[]", "[]").unwrap();
populate_known_addresses(
&conn,
"bob@test.com",
"Bob From",
r#"[{"name":"Alice To","address":"alice@test.com"}]"#,
"[]",
)
.unwrap();
populate_known_addresses(
&conn,
"charlie@test.com",
"Charlie",
"[]",
r#"[{"name":"Alice Cc","address":"alice@test.com"}]"#,
)
.unwrap();
// Search for "test.com" should exclude alice@test.com (she's a contact).
let results = search_known_addresses(&conn, "test.com", 10).unwrap();
// Should only have bob and charlie (alice excluded from all sources).
assert_eq!(results.len(), 2);
let emails: Vec<&str> = results.iter().map(|a| a.email.as_str()).collect();
assert!(emails.contains(&"bob@test.com"));
assert!(emails.contains(&"charlie@test.com"));
assert!(!emails.contains(&"alice@test.com"));
}
#[test]
fn test_search_known_addresses_handles_invalid_json() {
let conn = open_test_db();
// Populate known_addresses (simulating sync).
populate_known_addresses(&conn, "valid@test.com", "Valid User", "[]", "[]").unwrap();
populate_known_addresses(
&conn,
"sender@other.com",
"Sender",
r#"[{"name":"","address":"emptyname@test.com"}]"#,
r#"[{"name":"Cc Person","address":"ccperson@test.com"}]"#,
)
.unwrap();
populate_known_addresses(
&conn,
"sender@other.com",
"Sender",
r#"[{"name":"Json Person","address":"json@test.com"}]"#,
"[]",
)
.unwrap();
let results = search_known_addresses(&conn, "test.com", 10).unwrap();
assert_eq!(results.len(), 4);
let emails: Vec<&str> = results.iter().map(|a| a.email.as_str()).collect();
assert!(emails.contains(&"valid@test.com"));
assert!(emails.contains(&"emptyname@test.com"));
assert!(emails.contains(&"ccperson@test.com"));
assert!(emails.contains(&"json@test.com"));
}
#[test]
fn test_populate_known_addresses_deduplicates() {
let conn = open_test_db();
populate_known_addresses(&conn, "alice@test.com", "Alice", "[]", "[]").unwrap();
// Same email again with non-empty name -- should update the name.
populate_known_addresses(&conn, "alice@test.com", "Alice Different", "[]", "[]").unwrap();
let results = search_known_addresses(&conn, "alice@test.com", 10).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].name, "Alice Different");
}
#[test]
fn test_populate_known_addresses_skips_invalid_json() {
let conn = open_test_db();
// Invalid JSON in to_addresses should not cause an error.
populate_known_addresses(&conn, "sender@test.com", "Sender", "not valid json", "[]")
.unwrap();
let results = search_known_addresses(&conn, "sender@test.com", 10).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].email, "sender@test.com");
}
}