Initial commit

This commit is contained in:
Maurus Decimus
2026-05-29 18:02:15 +02:00
commit 576073f8c9
263 changed files with 78638 additions and 0 deletions
+111
View File
@@ -0,0 +1,111 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
use rusqlite::{Connection, OptionalExtension, params};
pub fn intern_blob(conn: &Connection, bytes: &[u8]) -> Result<i64, rusqlite::Error> {
let hash = blake3::hash(bytes);
let hash_bytes = hash.as_bytes().as_slice();
conn.execute(
"INSERT OR IGNORE INTO blobs (hash, data) VALUES (?1, ?2)",
params![hash_bytes, bytes],
)?;
conn.query_row(
"SELECT id FROM blobs WHERE hash = ?1",
params![hash_bytes],
|row| row.get(0),
)
}
pub fn blob_bytes(conn: &Connection, id: i64) -> Result<Option<Vec<u8>>, rusqlite::Error> {
conn.query_row("SELECT data FROM blobs WHERE id = ?1", params![id], |row| {
row.get(0)
})
.optional()
}
pub fn gc_orphan_blobs(conn: &Connection) -> Result<usize, rusqlite::Error> {
conn.execute(
"DELETE FROM blobs WHERE id NOT IN (
SELECT blob_id FROM emails
UNION SELECT blob_id FROM sieve_scripts
UNION SELECT blob_id FROM file_nodes WHERE blob_id IS NOT NULL
UNION SELECT json_tree.atom FROM contact_cards, json_tree(contact_cards.data)
WHERE json_tree.key = '@blob'
UNION SELECT json_tree.atom FROM calendar_events, json_tree(calendar_events.data)
WHERE json_tree.key = '@blob')",
[],
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::init;
fn mem() -> Connection {
let c = Connection::open_in_memory().unwrap();
init::apply_schema(&c).unwrap();
c
}
#[test]
fn intern_dedups_identical_bytes() {
let c = mem();
let a = intern_blob(&c, b"hello world").unwrap();
let b = intern_blob(&c, b"hello world").unwrap();
let other = intern_blob(&c, b"different").unwrap();
assert_eq!(a, b);
assert_ne!(a, other);
let n: i64 = c
.query_row("SELECT count(*) FROM blobs", [], |r| r.get(0))
.unwrap();
assert_eq!(n, 2);
}
#[test]
fn blob_bytes_roundtrip() {
let c = mem();
let id = intern_blob(&c, b"payload").unwrap();
assert_eq!(
blob_bytes(&c, id).unwrap().as_deref(),
Some(&b"payload"[..])
);
assert_eq!(blob_bytes(&c, 9999).unwrap(), None);
}
#[test]
fn gc_keeps_referenced_and_reaps_orphans() {
let c = mem();
let referenced = intern_blob(&c, b"keep me").unwrap();
let _orphan = intern_blob(&c, b"reap me").unwrap();
c.execute(
"INSERT INTO emails (blob_id, received_at, mailbox_ids, keywords)
VALUES (?1, '2020-01-01T00:00:00Z', '[1]', '[]')",
params![referenced],
)
.unwrap();
let removed = gc_orphan_blobs(&c).unwrap();
assert_eq!(removed, 1);
assert!(blob_bytes(&c, referenced).unwrap().is_some());
}
#[test]
fn gc_follows_blob_sentinel_in_json_data() {
let c = mem();
let photo = intern_blob(&c, b"photo bytes").unwrap();
let _orphan = intern_blob(&c, b"orphan").unwrap();
c.execute(
"INSERT INTO contact_cards (uid, address_book_ids, data)
VALUES ('u1', '[1]', ?1)",
params![format!(r#"{{"photos":{{"p":{{"@blob":{photo}}}}}}}"#)],
)
.unwrap();
let removed = gc_orphan_blobs(&c).unwrap();
assert_eq!(removed, 1);
assert!(blob_bytes(&c, photo).unwrap().is_some());
}
}
+336
View File
@@ -0,0 +1,336 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
use std::collections::HashMap;
use rusqlite::{Connection, OptionalExtension, params};
pub const CALENDAR: &str = "calendar";
pub const CALENDAR_EVENT: &str = "calendarevent";
pub const ADDRESS_BOOK: &str = "addressbook";
pub const CONTACT_CARD: &str = "contactcard";
pub const FILE_NODE: &str = "filenode";
#[derive(Debug, Clone)]
pub struct ItemRow {
pub item_href: String,
pub etag: String,
pub local_id: i64,
}
pub fn insert(
conn: &Connection,
source_id: i64,
type_name: &str,
collection_href: &str,
item_href: &str,
etag: &str,
local_id: i64,
) -> Result<(), rusqlite::Error> {
conn.execute(
"INSERT INTO sync_id_dav
(source_id, type_name, collection_href, item_href, etag, local_id)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
params![
source_id,
type_name,
collection_href,
item_href,
etag,
local_id
],
)?;
Ok(())
}
pub fn update_etag(
conn: &Connection,
source_id: i64,
type_name: &str,
item_href: &str,
etag: &str,
) -> Result<(), rusqlite::Error> {
conn.execute(
"UPDATE sync_id_dav
SET etag = ?4
WHERE source_id = ?1 AND type_name = ?2 AND item_href = ?3",
params![source_id, type_name, item_href, etag],
)?;
Ok(())
}
pub fn local_for_item(
conn: &Connection,
source_id: i64,
type_name: &str,
item_href: &str,
) -> Result<Option<i64>, rusqlite::Error> {
conn.query_row(
"SELECT local_id FROM sync_id_dav
WHERE source_id = ?1 AND type_name = ?2 AND item_href = ?3",
params![source_id, type_name, item_href],
|row| row.get(0),
)
.optional()
}
pub fn items_in_collection(
conn: &Connection,
source_id: i64,
type_name: &str,
collection_href: &str,
) -> Result<Vec<ItemRow>, rusqlite::Error> {
let mut stmt = conn.prepare(
"SELECT item_href, etag, local_id FROM sync_id_dav
WHERE source_id = ?1 AND type_name = ?2 AND collection_href = ?3",
)?;
let rows = stmt.query_map(params![source_id, type_name, collection_href], |row| {
Ok(ItemRow {
item_href: row.get(0)?,
etag: row.get(1)?,
local_id: row.get(2)?,
})
})?;
let mut out = Vec::new();
for r in rows {
out.push(r?);
}
Ok(out)
}
pub fn collections_of_type(
conn: &Connection,
source_id: i64,
type_name: &str,
) -> Result<HashMap<String, i64>, rusqlite::Error> {
let mut stmt = conn.prepare(
"SELECT item_href, local_id FROM sync_id_dav
WHERE source_id = ?1 AND type_name = ?2",
)?;
let rows = stmt.query_map(params![source_id, type_name], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
})?;
let mut map = HashMap::new();
for r in rows {
let (href, id) = r?;
map.insert(href, id);
}
Ok(map)
}
pub fn delete_item(
conn: &Connection,
source_id: i64,
type_name: &str,
item_href: &str,
) -> Result<(), rusqlite::Error> {
conn.execute(
"DELETE FROM sync_id_dav
WHERE source_id = ?1 AND type_name = ?2 AND item_href = ?3",
params![source_id, type_name, item_href],
)?;
Ok(())
}
pub fn delete_collection(
conn: &Connection,
source_id: i64,
type_name: &str,
collection_href: &str,
) -> Result<usize, rusqlite::Error> {
let removed = conn.execute(
"DELETE FROM sync_id_dav
WHERE source_id = ?1 AND type_name = ?2
AND (item_href = ?3 OR collection_href = ?3)",
params![source_id, type_name, collection_href],
)?;
Ok(removed)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::init;
use crate::db::sources::{SourceKey, upsert_source};
fn setup() -> (rusqlite::Connection, i64) {
let conn = rusqlite::Connection::open_in_memory().unwrap();
init::apply_schema(&conn).unwrap();
let key = SourceKey {
kind: "caldav".to_owned(),
session_url: "https://x/".to_owned(),
account_id: "u".to_owned(),
};
let sid = upsert_source(&conn, &key, Some("u"), "u").unwrap();
(conn, sid)
}
#[test]
fn insert_and_lookup_round_trip() {
let (conn, sid) = setup();
insert(
&conn,
sid,
CALENDAR_EVENT,
"/cal/a/",
"/cal/a/e.ics",
"\"v1\"",
7,
)
.unwrap();
let id = local_for_item(&conn, sid, CALENDAR_EVENT, "/cal/a/e.ics")
.unwrap()
.unwrap();
assert_eq!(id, 7);
}
#[test]
fn replace_keeps_local_id_on_etag_change() {
let (conn, sid) = setup();
insert(
&conn,
sid,
CALENDAR_EVENT,
"/cal/a/",
"/cal/a/e.ics",
"\"v1\"",
7,
)
.unwrap();
update_etag(&conn, sid, CALENDAR_EVENT, "/cal/a/e.ics", "\"v2\"").unwrap();
let rows = items_in_collection(&conn, sid, CALENDAR_EVENT, "/cal/a/").unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].etag, "\"v2\"");
assert_eq!(rows[0].local_id, 7);
}
#[test]
fn items_in_collection_filters_by_collection_href() {
let (conn, sid) = setup();
insert(
&conn,
sid,
CALENDAR_EVENT,
"/cal/a/",
"/cal/a/e1.ics",
"v1",
1,
)
.unwrap();
insert(
&conn,
sid,
CALENDAR_EVENT,
"/cal/a/",
"/cal/a/e2.ics",
"v2",
2,
)
.unwrap();
insert(
&conn,
sid,
CALENDAR_EVENT,
"/cal/b/",
"/cal/b/e3.ics",
"v3",
3,
)
.unwrap();
let rows = items_in_collection(&conn, sid, CALENDAR_EVENT, "/cal/a/").unwrap();
assert_eq!(rows.len(), 2);
}
#[test]
fn delete_item_removes_only_one_row() {
let (conn, sid) = setup();
insert(
&conn,
sid,
CALENDAR_EVENT,
"/cal/a/",
"/cal/a/e1.ics",
"v",
1,
)
.unwrap();
insert(
&conn,
sid,
CALENDAR_EVENT,
"/cal/a/",
"/cal/a/e2.ics",
"v",
2,
)
.unwrap();
delete_item(&conn, sid, CALENDAR_EVENT, "/cal/a/e1.ics").unwrap();
let rows = items_in_collection(&conn, sid, CALENDAR_EVENT, "/cal/a/").unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].item_href, "/cal/a/e2.ics");
}
#[test]
fn delete_collection_removes_collection_row_and_items() {
let (conn, sid) = setup();
insert(&conn, sid, CALENDAR, "/cal/a/", "/cal/a/", "", 10).unwrap();
insert(
&conn,
sid,
CALENDAR_EVENT,
"/cal/a/",
"/cal/a/e.ics",
"v",
1,
)
.unwrap();
let removed = delete_collection(&conn, sid, CALENDAR_EVENT, "/cal/a/").unwrap();
assert_eq!(removed, 1);
let rows = items_in_collection(&conn, sid, CALENDAR_EVENT, "/cal/a/").unwrap();
assert!(rows.is_empty());
}
#[test]
fn collections_of_type_lists_all_known_collections() {
let (conn, sid) = setup();
insert(&conn, sid, CALENDAR, "/cal/a/", "/cal/a/", "", 1).unwrap();
insert(&conn, sid, CALENDAR, "/cal/b/", "/cal/b/", "", 2).unwrap();
let map = collections_of_type(&conn, sid, CALENDAR).unwrap();
assert_eq!(map.len(), 2);
assert_eq!(map.get("/cal/a/"), Some(&1));
assert_eq!(map.get("/cal/b/"), Some(&2));
}
#[test]
fn unique_local_id_per_type_enforced() {
let (conn, sid) = setup();
insert(
&conn,
sid,
CALENDAR_EVENT,
"/cal/a/",
"/cal/a/e1.ics",
"v",
1,
)
.unwrap();
let err = insert(
&conn,
sid,
CALENDAR_EVENT,
"/cal/a/",
"/cal/a/e2.ics",
"v",
1,
)
.unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("UNIQUE"),
"expected UNIQUE violation, got {msg}"
);
}
}
+265
View File
@@ -0,0 +1,265 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
use std::collections::HashMap;
use rusqlite::{Connection, OptionalExtension, params};
pub const MAILBOX: &str = "mailbox";
pub const CALENDAR: &str = "calendar";
pub const ADDRESS_BOOK: &str = "addressbook";
pub const EMAIL: &str = "email";
pub const CALENDAR_EVENT: &str = "calendarevent";
pub const CONTACT_CARD: &str = "contactcard";
#[derive(Debug, Clone)]
pub struct ItemRow {
pub item_id: String,
pub change_key: String,
pub local_id: i64,
}
#[derive(Debug, Clone)]
pub struct FolderRow {
pub item_id: String,
pub folder_id: String,
pub change_key: String,
pub local_id: i64,
}
pub fn insert(
conn: &Connection,
source_id: i64,
type_name: &str,
folder_id: &str,
item_id: &str,
change_key: &str,
local_id: i64,
) -> Result<(), rusqlite::Error> {
conn.execute(
"INSERT INTO sync_id_exchange_ews
(source_id, type_name, folder_id, item_id, change_key, sync_state, local_id)
VALUES (?1, ?2, ?3, ?4, ?5, '', ?6)",
params![
source_id, type_name, folder_id, item_id, change_key, local_id
],
)?;
Ok(())
}
pub fn update_change_key(
conn: &Connection,
source_id: i64,
type_name: &str,
item_id: &str,
change_key: &str,
) -> Result<(), rusqlite::Error> {
conn.execute(
"UPDATE sync_id_exchange_ews
SET change_key = ?4
WHERE source_id = ?1 AND type_name = ?2 AND item_id = ?3",
params![source_id, type_name, item_id, change_key],
)?;
Ok(())
}
pub fn delete_item(
conn: &Connection,
source_id: i64,
type_name: &str,
item_id: &str,
) -> Result<(), rusqlite::Error> {
conn.execute(
"DELETE FROM sync_id_exchange_ews
WHERE source_id = ?1 AND type_name = ?2 AND item_id = ?3",
params![source_id, type_name, item_id],
)?;
Ok(())
}
pub fn local_for_item(
conn: &Connection,
source_id: i64,
type_name: &str,
item_id: &str,
) -> Result<Option<i64>, rusqlite::Error> {
conn.query_row(
"SELECT local_id FROM sync_id_exchange_ews
WHERE source_id = ?1 AND type_name = ?2 AND item_id = ?3",
params![source_id, type_name, item_id],
|row| row.get(0),
)
.optional()
}
pub fn items_in_folder(
conn: &Connection,
source_id: i64,
type_name: &str,
folder_id: &str,
) -> Result<Vec<ItemRow>, rusqlite::Error> {
let mut stmt = conn.prepare(
"SELECT item_id, change_key, local_id FROM sync_id_exchange_ews
WHERE source_id = ?1 AND type_name = ?2 AND folder_id = ?3",
)?;
let rows = stmt.query_map(params![source_id, type_name, folder_id], |row| {
Ok(ItemRow {
item_id: row.get(0)?,
change_key: row.get(1)?,
local_id: row.get(2)?,
})
})?;
let mut out = Vec::new();
for r in rows {
out.push(r?);
}
Ok(out)
}
pub fn folders_of_type(
conn: &Connection,
source_id: i64,
type_name: &str,
) -> Result<HashMap<String, FolderRow>, rusqlite::Error> {
let mut stmt = conn.prepare(
"SELECT item_id, folder_id, change_key, local_id FROM sync_id_exchange_ews
WHERE source_id = ?1 AND type_name = ?2",
)?;
let rows = stmt.query_map(params![source_id, type_name], |row| {
Ok(FolderRow {
item_id: row.get(0)?,
folder_id: row.get(1)?,
change_key: row.get(2)?,
local_id: row.get(3)?,
})
})?;
let mut map = HashMap::new();
for r in rows {
let f = r?;
map.insert(f.item_id.clone(), f);
}
Ok(map)
}
pub fn get_sync_state(
conn: &Connection,
source_id: i64,
type_name: &str,
item_id: &str,
) -> Result<Option<String>, rusqlite::Error> {
conn.query_row(
"SELECT sync_state FROM sync_id_exchange_ews
WHERE source_id = ?1 AND type_name = ?2 AND item_id = ?3",
params![source_id, type_name, item_id],
|row| row.get::<_, String>(0),
)
.optional()
}
pub fn set_sync_state(
conn: &Connection,
source_id: i64,
type_name: &str,
item_id: &str,
sync_state: &str,
) -> Result<(), rusqlite::Error> {
conn.execute(
"UPDATE sync_id_exchange_ews
SET sync_state = ?4
WHERE source_id = ?1 AND type_name = ?2 AND item_id = ?3",
params![source_id, type_name, item_id, sync_state],
)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::init;
use crate::db::sources::{SourceKey, upsert_source};
fn setup() -> (rusqlite::Connection, i64) {
let conn = rusqlite::Connection::open_in_memory().unwrap();
init::apply_schema(&conn).unwrap();
let key = SourceKey {
kind: "exchange_ews".to_owned(),
session_url: "https://x/EWS/Exchange.asmx".to_owned(),
account_id: "u@d".to_owned(),
};
let sid = upsert_source(&conn, &key, Some("u"), "u@d").unwrap();
(conn, sid)
}
#[test]
fn insert_and_lookup_round_trip() {
let (conn, sid) = setup();
insert(&conn, sid, EMAIL, "FOLDER1", "ITEM1", "CK1", 42).unwrap();
let id = local_for_item(&conn, sid, EMAIL, "ITEM1").unwrap().unwrap();
assert_eq!(id, 42);
let rows = items_in_folder(&conn, sid, EMAIL, "FOLDER1").unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].change_key, "CK1");
}
#[test]
fn update_change_key_preserves_local_id() {
let (conn, sid) = setup();
insert(&conn, sid, EMAIL, "F1", "I1", "A", 7).unwrap();
update_change_key(&conn, sid, EMAIL, "I1", "B").unwrap();
let rows = items_in_folder(&conn, sid, EMAIL, "F1").unwrap();
assert_eq!(rows[0].change_key, "B");
assert_eq!(rows[0].local_id, 7);
}
#[test]
fn folders_of_type_lists_by_item_id() {
let (conn, sid) = setup();
insert(&conn, sid, MAILBOX, "", "F1", "CK1", 1).unwrap();
insert(&conn, sid, MAILBOX, "F1", "F2", "CK2", 2).unwrap();
let map = folders_of_type(&conn, sid, MAILBOX).unwrap();
assert_eq!(map.len(), 2);
assert_eq!(map.get("F1").map(|r| r.local_id), Some(1));
assert_eq!(map.get("F2").map(|r| r.local_id), Some(2));
}
#[test]
fn sync_state_round_trips() {
let (conn, sid) = setup();
insert(&conn, sid, MAILBOX, "", "F1", "", 1).unwrap();
assert_eq!(
get_sync_state(&conn, sid, MAILBOX, "F1")
.unwrap()
.as_deref(),
Some("")
);
set_sync_state(&conn, sid, MAILBOX, "F1", "opaque-state").unwrap();
assert_eq!(
get_sync_state(&conn, sid, MAILBOX, "F1")
.unwrap()
.as_deref(),
Some("opaque-state")
);
}
#[test]
fn delete_item_removes_only_one() {
let (conn, sid) = setup();
insert(&conn, sid, EMAIL, "F1", "I1", "A", 1).unwrap();
insert(&conn, sid, EMAIL, "F1", "I2", "A", 2).unwrap();
delete_item(&conn, sid, EMAIL, "I1").unwrap();
let rows = items_in_folder(&conn, sid, EMAIL, "F1").unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].item_id, "I2");
}
#[test]
fn unique_local_id_per_type_is_enforced() {
let (conn, sid) = setup();
insert(&conn, sid, EMAIL, "F1", "I1", "A", 1).unwrap();
let err = insert(&conn, sid, EMAIL, "F1", "I2", "A", 1).unwrap_err();
assert!(err.to_string().contains("UNIQUE"));
}
}
+167
View File
@@ -0,0 +1,167 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
use std::collections::HashMap;
use rusqlite::{Connection, OptionalExtension, params};
pub const MAILBOX: &str = "mailbox";
pub const EMAIL: &str = "email";
pub const CALENDAR: &str = "calendar";
pub const CALENDAR_EVENT: &str = "calendarevent";
pub const ADDRESS_BOOK: &str = "addressbook";
pub const CONTACT_CARD: &str = "contactcard";
#[derive(Debug, Clone)]
pub struct IdRow {
pub graph_id: String,
pub local_id: i64,
}
pub fn insert(
conn: &Connection,
source_id: i64,
type_name: &str,
graph_id: &str,
local_id: i64,
) -> Result<(), rusqlite::Error> {
conn.execute(
"INSERT INTO sync_id_exchange_graph (source_id, type_name, graph_id, local_id)
VALUES (?1, ?2, ?3, ?4)",
params![source_id, type_name, graph_id, local_id],
)?;
Ok(())
}
pub fn delete(
conn: &Connection,
source_id: i64,
type_name: &str,
graph_id: &str,
) -> Result<(), rusqlite::Error> {
conn.execute(
"DELETE FROM sync_id_exchange_graph
WHERE source_id = ?1 AND type_name = ?2 AND graph_id = ?3",
params![source_id, type_name, graph_id],
)?;
Ok(())
}
pub fn local_for_graph_id(
conn: &Connection,
source_id: i64,
type_name: &str,
graph_id: &str,
) -> Result<Option<i64>, rusqlite::Error> {
conn.query_row(
"SELECT local_id FROM sync_id_exchange_graph
WHERE source_id = ?1 AND type_name = ?2 AND graph_id = ?3",
params![source_id, type_name, graph_id],
|row| row.get(0),
)
.optional()
}
pub fn ids_of_type(
conn: &Connection,
source_id: i64,
type_name: &str,
) -> Result<HashMap<String, i64>, rusqlite::Error> {
let mut stmt = conn.prepare(
"SELECT graph_id, local_id FROM sync_id_exchange_graph
WHERE source_id = ?1 AND type_name = ?2",
)?;
let rows = stmt.query_map(params![source_id, type_name], |row| {
Ok(IdRow {
graph_id: row.get(0)?,
local_id: row.get(1)?,
})
})?;
let mut map = HashMap::new();
for r in rows {
let row = r?;
map.insert(row.graph_id, row.local_id);
}
Ok(map)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::init;
use crate::db::sources::{SourceKey, upsert_source};
fn setup() -> (Connection, i64) {
let conn = Connection::open_in_memory().unwrap();
init::apply_schema(&conn).unwrap();
let key = SourceKey {
kind: "exchange_graph".to_owned(),
session_url: "https://x|https://graph.microsoft.com/v1.0".to_owned(),
account_id: "u-uuid".to_owned(),
};
let sid = upsert_source(&conn, &key, Some("u@d"), "u@d").unwrap();
(conn, sid)
}
#[test]
fn insert_and_lookup_round_trip() {
let (conn, sid) = setup();
insert(&conn, sid, EMAIL, "AAkA-base64==", 17).unwrap();
let id = local_for_graph_id(&conn, sid, EMAIL, "AAkA-base64==")
.unwrap()
.unwrap();
assert_eq!(id, 17);
}
#[test]
fn graph_id_is_case_sensitive() {
let (conn, sid) = setup();
insert(&conn, sid, EMAIL, "AaBb", 1).unwrap();
assert!(
local_for_graph_id(&conn, sid, EMAIL, "aabb")
.unwrap()
.is_none()
);
assert!(
local_for_graph_id(&conn, sid, EMAIL, "AaBb")
.unwrap()
.is_some()
);
}
#[test]
fn unique_local_id_per_type_is_enforced() {
let (conn, sid) = setup();
insert(&conn, sid, EMAIL, "ID-A", 1).unwrap();
let err = insert(&conn, sid, EMAIL, "ID-B", 1).unwrap_err();
assert!(err.to_string().contains("UNIQUE"));
}
#[test]
fn ids_of_type_filters_by_type() {
let (conn, sid) = setup();
insert(&conn, sid, EMAIL, "M1", 1).unwrap();
insert(&conn, sid, EMAIL, "M2", 2).unwrap();
insert(&conn, sid, MAILBOX, "F1", 3).unwrap();
let emails = ids_of_type(&conn, sid, EMAIL).unwrap();
let folders = ids_of_type(&conn, sid, MAILBOX).unwrap();
assert_eq!(emails.len(), 2);
assert_eq!(folders.len(), 1);
assert_eq!(emails.get("M1"), Some(&1));
assert_eq!(folders.get("F1"), Some(&3));
}
#[test]
fn delete_removes_only_one() {
let (conn, sid) = setup();
insert(&conn, sid, EMAIL, "M1", 1).unwrap();
insert(&conn, sid, EMAIL, "M2", 2).unwrap();
delete(&conn, sid, EMAIL, "M1").unwrap();
let remaining = ids_of_type(&conn, sid, EMAIL).unwrap();
assert_eq!(remaining.len(), 1);
assert!(remaining.contains_key("M2"));
}
}
+114
View File
@@ -0,0 +1,114 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
use std::collections::HashMap;
use rusqlite::{Connection, OptionalExtension, params};
use crate::types::ObjectType;
pub fn insert(
conn: &Connection,
source_id: i64,
ty: ObjectType,
jmap_id: &str,
local_id: i64,
) -> Result<(), rusqlite::Error> {
conn.execute(
"INSERT OR REPLACE INTO sync_id_jmap (source_id, type_name, jmap_id, local_id)
VALUES (?1, ?2, ?3, ?4)",
params![source_id, ty.jmap_name(), jmap_id, local_id],
)?;
Ok(())
}
pub fn local_for(
conn: &Connection,
source_id: i64,
ty: ObjectType,
jmap_id: &str,
) -> Result<Option<i64>, rusqlite::Error> {
conn.query_row(
"SELECT local_id FROM sync_id_jmap
WHERE source_id = ?1 AND type_name = ?2 AND jmap_id = ?3",
params![source_id, ty.jmap_name(), jmap_id],
|row| row.get(0),
)
.optional()
}
pub fn jmap_to_local(
conn: &Connection,
source_id: i64,
ty: ObjectType,
) -> Result<HashMap<String, i64>, rusqlite::Error> {
let mut stmt = conn.prepare(
"SELECT jmap_id, local_id FROM sync_id_jmap WHERE source_id = ?1 AND type_name = ?2",
)?;
let rows = stmt.query_map(params![source_id, ty.jmap_name()], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
})?;
let mut map = HashMap::new();
for r in rows {
let (j, l) = r?;
map.insert(j, l);
}
Ok(map)
}
pub fn delete(
conn: &Connection,
source_id: i64,
ty: ObjectType,
jmap_id: &str,
) -> Result<(), rusqlite::Error> {
conn.execute(
"DELETE FROM sync_id_jmap
WHERE source_id = ?1 AND type_name = ?2 AND jmap_id = ?3",
params![source_id, ty.jmap_name(), jmap_id],
)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::init;
use crate::db::sources::{SourceKey, upsert_source};
fn setup() -> (Connection, i64) {
let c = Connection::open_in_memory().unwrap();
init::apply_schema(&c).unwrap();
let sid = upsert_source(
&c,
&SourceKey {
kind: "jmap".to_owned(),
session_url: "u".to_owned(),
account_id: "w".to_owned(),
},
None,
"alice",
)
.unwrap();
(c, sid)
}
#[test]
fn crud_roundtrip() {
let (c, sid) = setup();
insert(&c, sid, ObjectType::Mailbox, "M1", 1).unwrap();
insert(&c, sid, ObjectType::Mailbox, "M2", 2).unwrap();
assert_eq!(
local_for(&c, sid, ObjectType::Mailbox, "M1").unwrap(),
Some(1)
);
let map = jmap_to_local(&c, sid, ObjectType::Mailbox).unwrap();
assert_eq!(map.len(), 2);
assert_eq!(map.get("M2"), Some(&2));
delete(&c, sid, ObjectType::Mailbox, "M1").unwrap();
assert_eq!(local_for(&c, sid, ObjectType::Mailbox, "M1").unwrap(), None);
}
}
+292
View File
@@ -0,0 +1,292 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
use std::collections::{HashMap, HashSet};
use rusqlite::{Connection, OptionalExtension, params};
const MAILBOX: &str = "mailbox";
const EMAIL: &str = "email";
pub fn insert_mailbox(
conn: &Connection,
source_id: i64,
folder: &str,
local_id: i64,
) -> Result<(), rusqlite::Error> {
conn.execute(
"INSERT OR REPLACE INTO sync_id_imap
(source_id, type_name, folder, uidvalidity, uid, local_id)
VALUES (?1, ?2, ?3, 0, 0, ?4)",
params![source_id, MAILBOX, folder, local_id],
)?;
Ok(())
}
pub fn insert_email(
conn: &Connection,
source_id: i64,
folder: &str,
uidvalidity: u32,
uid: u32,
local_id: i64,
) -> Result<(), rusqlite::Error> {
conn.execute(
"INSERT OR REPLACE INTO sync_id_imap
(source_id, type_name, folder, uidvalidity, uid, local_id)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
params![source_id, EMAIL, folder, uidvalidity, uid, local_id],
)?;
Ok(())
}
pub fn local_for_mailbox(
conn: &Connection,
source_id: i64,
folder: &str,
) -> Result<Option<i64>, rusqlite::Error> {
conn.query_row(
"SELECT local_id FROM sync_id_imap
WHERE source_id = ?1 AND type_name = ?2 AND folder = ?3
AND uidvalidity = 0 AND uid = 0",
params![source_id, MAILBOX, folder],
|row| row.get(0),
)
.optional()
}
pub fn local_for_email(
conn: &Connection,
source_id: i64,
folder: &str,
uidvalidity: u32,
uid: u32,
) -> Result<Option<i64>, rusqlite::Error> {
conn.query_row(
"SELECT local_id FROM sync_id_imap
WHERE source_id = ?1 AND type_name = ?2
AND folder = ?3 AND uidvalidity = ?4 AND uid = ?5",
params![source_id, EMAIL, folder, uidvalidity, uid],
|row| row.get(0),
)
.optional()
}
pub fn mailbox_folders(
conn: &Connection,
source_id: i64,
) -> Result<HashMap<String, i64>, rusqlite::Error> {
let mut stmt = conn.prepare(
"SELECT folder, local_id FROM sync_id_imap
WHERE source_id = ?1 AND type_name = ?2",
)?;
let rows = stmt.query_map(params![source_id, MAILBOX], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
})?;
let mut map = HashMap::new();
for r in rows {
let (f, l) = r?;
map.insert(f, l);
}
Ok(map)
}
pub fn email_uids_in_folder(
conn: &Connection,
source_id: i64,
folder: &str,
) -> Result<HashMap<(u32, u32), i64>, rusqlite::Error> {
let mut stmt = conn.prepare(
"SELECT uidvalidity, uid, local_id FROM sync_id_imap
WHERE source_id = ?1 AND type_name = ?2 AND folder = ?3",
)?;
let rows = stmt.query_map(params![source_id, EMAIL, folder], |row| {
Ok((
row.get::<_, u32>(0)?,
row.get::<_, u32>(1)?,
row.get::<_, i64>(2)?,
))
})?;
let mut map = HashMap::new();
for r in rows {
let (v, u, l) = r?;
map.insert((v, u), l);
}
Ok(map)
}
pub fn delete_mailbox(
conn: &Connection,
source_id: i64,
folder: &str,
) -> Result<(), rusqlite::Error> {
conn.execute(
"DELETE FROM sync_id_imap
WHERE source_id = ?1 AND type_name = ?2 AND folder = ?3
AND uidvalidity = 0 AND uid = 0",
params![source_id, MAILBOX, folder],
)?;
Ok(())
}
pub fn delete_email(
conn: &Connection,
source_id: i64,
folder: &str,
uidvalidity: u32,
uid: u32,
) -> Result<(), rusqlite::Error> {
conn.execute(
"DELETE FROM sync_id_imap
WHERE source_id = ?1 AND type_name = ?2
AND folder = ?3 AND uidvalidity = ?4 AND uid = ?5",
params![source_id, EMAIL, folder, uidvalidity, uid],
)?;
Ok(())
}
pub fn delete_all_emails_in_folder(
conn: &Connection,
source_id: i64,
folder: &str,
) -> Result<usize, rusqlite::Error> {
conn.execute(
"DELETE FROM sync_id_imap
WHERE source_id = ?1 AND type_name = ?2 AND folder = ?3",
params![source_id, EMAIL, folder],
)
}
pub fn email_uids_at_validity(
conn: &Connection,
source_id: i64,
folder: &str,
uidvalidity: u32,
) -> Result<Vec<u32>, rusqlite::Error> {
let mut stmt = conn.prepare(
"SELECT uid FROM sync_id_imap
WHERE source_id = ?1 AND type_name = ?2 AND folder = ?3
AND uidvalidity = ?4
ORDER BY uid",
)?;
let rows = stmt.query_map(params![source_id, EMAIL, folder, uidvalidity], |row| {
row.get::<_, u32>(0)
})?;
let mut out = Vec::new();
for r in rows {
out.push(r?);
}
Ok(out)
}
pub fn folders_with_emails(
conn: &Connection,
source_id: i64,
) -> Result<HashSet<String>, rusqlite::Error> {
let mut stmt = conn.prepare(
"SELECT DISTINCT folder FROM sync_id_imap
WHERE source_id = ?1 AND type_name = ?2",
)?;
let rows = stmt.query_map(params![source_id, EMAIL], |row| row.get::<_, String>(0))?;
let mut out = HashSet::new();
for r in rows {
out.insert(r?);
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::init;
use crate::db::sources::{SourceKey, upsert_source};
fn setup() -> (Connection, i64) {
let c = Connection::open_in_memory().unwrap();
init::apply_schema(&c).unwrap();
let sid = upsert_source(
&c,
&SourceKey {
kind: "imap".to_owned(),
session_url: "imaps://host:993".to_owned(),
account_id: "alice".to_owned(),
},
None,
"alice",
)
.unwrap();
(c, sid)
}
#[test]
fn mailbox_roundtrip() {
let (c, sid) = setup();
insert_mailbox(&c, sid, "INBOX", 7).unwrap();
insert_mailbox(&c, sid, "INBOX/Sub", 8).unwrap();
assert_eq!(local_for_mailbox(&c, sid, "INBOX").unwrap(), Some(7));
let all = mailbox_folders(&c, sid).unwrap();
assert_eq!(all.len(), 2);
assert_eq!(all.get("INBOX/Sub"), Some(&8));
delete_mailbox(&c, sid, "INBOX").unwrap();
assert_eq!(local_for_mailbox(&c, sid, "INBOX").unwrap(), None);
}
#[test]
fn email_roundtrip() {
let (c, sid) = setup();
insert_email(&c, sid, "INBOX", 12345, 100, 1).unwrap();
insert_email(&c, sid, "INBOX", 12345, 101, 2).unwrap();
insert_email(&c, sid, "Sent", 7777, 1, 3).unwrap();
assert_eq!(
local_for_email(&c, sid, "INBOX", 12345, 100).unwrap(),
Some(1)
);
let inbox = email_uids_in_folder(&c, sid, "INBOX").unwrap();
assert_eq!(inbox.len(), 2);
assert_eq!(inbox.get(&(12345, 100)), Some(&1));
let folders = folders_with_emails(&c, sid).unwrap();
assert!(folders.contains("INBOX"));
assert!(folders.contains("Sent"));
}
#[test]
fn delete_all_emails_in_folder_wipes_only_that_folder() {
let (c, sid) = setup();
insert_email(&c, sid, "INBOX", 1, 10, 1).unwrap();
insert_email(&c, sid, "INBOX", 1, 11, 2).unwrap();
insert_email(&c, sid, "Sent", 1, 10, 3).unwrap();
let removed = delete_all_emails_in_folder(&c, sid, "INBOX").unwrap();
assert_eq!(removed, 2);
assert!(email_uids_in_folder(&c, sid, "INBOX").unwrap().is_empty());
assert_eq!(email_uids_in_folder(&c, sid, "Sent").unwrap().len(), 1);
}
#[test]
fn re_insert_with_same_local_id_replaces() {
let (c, sid) = setup();
insert_mailbox(&c, sid, "A", 1).unwrap();
insert_mailbox(&c, sid, "B", 1).unwrap();
assert_eq!(local_for_mailbox(&c, sid, "A").unwrap(), None);
assert_eq!(local_for_mailbox(&c, sid, "B").unwrap(), Some(1));
let all = mailbox_folders(&c, sid).unwrap();
assert_eq!(all.len(), 1);
}
#[test]
fn pk_composite_is_per_type_per_folder_per_uid() {
let (c, sid) = setup();
insert_email(&c, sid, "INBOX", 1, 10, 1).unwrap();
insert_email(&c, sid, "INBOX", 1, 10, 1).unwrap();
assert_eq!(email_uids_in_folder(&c, sid, "INBOX").unwrap().len(), 1);
}
#[test]
fn mailbox_and_email_namespaces_do_not_collide_on_local_id() {
let (c, sid) = setup();
insert_mailbox(&c, sid, "INBOX", 42).unwrap();
insert_email(&c, sid, "INBOX", 1, 1, 42).unwrap();
}
}
+110
View File
@@ -0,0 +1,110 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
use rusqlite::{Connection, OptionalExtension, params};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FolderState {
pub uidvalidity: u32,
pub uidnext: u32,
pub last_seen: String,
}
pub fn get(
conn: &Connection,
source_id: i64,
folder: &str,
) -> Result<Option<FolderState>, rusqlite::Error> {
conn.query_row(
"SELECT uidvalidity, uidnext, last_seen FROM imap_folder_state
WHERE source_id = ?1 AND folder = ?2",
params![source_id, folder],
|row| {
Ok(FolderState {
uidvalidity: row.get(0)?,
uidnext: row.get(1)?,
last_seen: row.get(2)?,
})
},
)
.optional()
}
pub fn upsert(
conn: &Connection,
source_id: i64,
folder: &str,
uidvalidity: u32,
uidnext: u32,
last_seen_rfc3339: &str,
) -> Result<(), rusqlite::Error> {
conn.execute(
"INSERT INTO imap_folder_state (source_id, folder, uidvalidity, uidnext, last_seen)
VALUES (?1, ?2, ?3, ?4, ?5)
ON CONFLICT (source_id, folder)
DO UPDATE SET uidvalidity = excluded.uidvalidity,
uidnext = excluded.uidnext,
last_seen = excluded.last_seen",
params![source_id, folder, uidvalidity, uidnext, last_seen_rfc3339],
)?;
Ok(())
}
pub fn delete(conn: &Connection, source_id: i64, folder: &str) -> Result<(), rusqlite::Error> {
conn.execute(
"DELETE FROM imap_folder_state WHERE source_id = ?1 AND folder = ?2",
params![source_id, folder],
)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::init;
use crate::db::sources::{SourceKey, upsert_source};
fn setup() -> (Connection, i64) {
let c = Connection::open_in_memory().unwrap();
init::apply_schema(&c).unwrap();
let sid = upsert_source(
&c,
&SourceKey {
kind: "imap".to_owned(),
session_url: "imaps://host:993".to_owned(),
account_id: "alice".to_owned(),
},
None,
"alice",
)
.unwrap();
(c, sid)
}
#[test]
fn upsert_then_get_returns_latest() {
let (c, sid) = setup();
assert!(get(&c, sid, "INBOX").unwrap().is_none());
upsert(&c, sid, "INBOX", 100, 50, "2026-05-24T00:00:00Z").unwrap();
let st = get(&c, sid, "INBOX").unwrap().unwrap();
assert_eq!(st.uidvalidity, 100);
assert_eq!(st.uidnext, 50);
upsert(&c, sid, "INBOX", 100, 75, "2026-05-24T01:00:00Z").unwrap();
let st = get(&c, sid, "INBOX").unwrap().unwrap();
assert_eq!(st.uidnext, 75);
assert_eq!(st.last_seen, "2026-05-24T01:00:00Z");
}
#[test]
fn delete_removes_only_named_folder() {
let (c, sid) = setup();
upsert(&c, sid, "INBOX", 1, 1, "2026-05-24T00:00:00Z").unwrap();
upsert(&c, sid, "Sent", 2, 1, "2026-05-24T00:00:00Z").unwrap();
delete(&c, sid, "INBOX").unwrap();
assert!(get(&c, sid, "INBOX").unwrap().is_none());
assert!(get(&c, sid, "Sent").unwrap().is_some());
}
}
+50
View File
@@ -0,0 +1,50 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
use rusqlite::Connection;
pub const SCHEMA_SQL: &str = include_str!("schema.sql");
pub fn open(path: &std::path::Path) -> Result<Connection, OpenError> {
let conn = Connection::open(path)?;
apply_pragmas(&conn)?;
apply_schema(&conn)?;
Ok(conn)
}
pub fn apply_schema(conn: &Connection) -> Result<(), OpenError> {
let tx = conn.unchecked_transaction()?;
tx.execute_batch(SCHEMA_SQL)?;
ensure_calendar_events_data_type(&tx)?;
tx.commit()?;
Ok(())
}
fn ensure_calendar_events_data_type(conn: &Connection) -> Result<(), OpenError> {
let mut stmt = conn.prepare("PRAGMA table_info(calendar_events)")?;
let rows = stmt.query_map([], |row| row.get::<_, String>(1))?;
let has_column = rows.filter_map(|r| r.ok()).any(|name| name == "data_type");
if !has_column {
conn.execute(
"ALTER TABLE calendar_events ADD COLUMN data_type TEXT NOT NULL DEFAULT 'Event'",
[],
)?;
}
Ok(())
}
fn apply_pragmas(conn: &Connection) -> Result<(), OpenError> {
conn.pragma_update(None, "journal_mode", "WAL")?;
conn.pragma_update(None, "foreign_keys", "ON")?;
conn.pragma_update(None, "locking_mode", "EXCLUSIVE")?;
Ok(())
}
#[derive(Debug, thiserror::Error)]
pub enum OpenError {
#[error("sqlite error: {0}")]
Sqlite(#[from] rusqlite::Error),
}
+269
View File
@@ -0,0 +1,269 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
use std::collections::{HashMap, HashSet};
use rusqlite::{Connection, OptionalExtension, params};
const MAILBOX: &str = "mailbox";
const EMAIL: &str = "email";
pub fn insert_mailbox(
conn: &Connection,
source_id: i64,
folder: &str,
local_id: i64,
) -> Result<(), rusqlite::Error> {
conn.execute(
"INSERT INTO sync_id_maildir
(source_id, type_name, folder, unique_id, local_id)
VALUES (?1, ?2, ?3, '', ?4)",
params![source_id, MAILBOX, folder, local_id],
)?;
Ok(())
}
pub fn insert_email(
conn: &Connection,
source_id: i64,
folder: &str,
unique_id: &str,
local_id: i64,
) -> Result<(), rusqlite::Error> {
conn.execute(
"INSERT INTO sync_id_maildir
(source_id, type_name, folder, unique_id, local_id)
VALUES (?1, ?2, ?3, ?4, ?5)",
params![source_id, EMAIL, folder, unique_id, local_id],
)?;
Ok(())
}
pub fn local_for_mailbox(
conn: &Connection,
source_id: i64,
folder: &str,
) -> Result<Option<i64>, rusqlite::Error> {
conn.query_row(
"SELECT local_id FROM sync_id_maildir
WHERE source_id = ?1 AND type_name = ?2 AND folder = ?3 AND unique_id = ''",
params![source_id, MAILBOX, folder],
|row| row.get(0),
)
.optional()
}
pub fn local_for_email(
conn: &Connection,
source_id: i64,
folder: &str,
unique_id: &str,
) -> Result<Option<i64>, rusqlite::Error> {
conn.query_row(
"SELECT local_id FROM sync_id_maildir
WHERE source_id = ?1 AND type_name = ?2 AND folder = ?3 AND unique_id = ?4",
params![source_id, EMAIL, folder, unique_id],
|row| row.get(0),
)
.optional()
}
pub fn mailbox_folders(
conn: &Connection,
source_id: i64,
) -> Result<HashMap<String, i64>, rusqlite::Error> {
let mut stmt = conn.prepare(
"SELECT folder, local_id FROM sync_id_maildir
WHERE source_id = ?1 AND type_name = ?2 AND unique_id = ''",
)?;
let rows = stmt.query_map(params![source_id, MAILBOX], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
})?;
let mut map = HashMap::new();
for r in rows {
let (f, l) = r?;
map.insert(f, l);
}
Ok(map)
}
pub fn email_ids_in_folder(
conn: &Connection,
source_id: i64,
folder: &str,
) -> Result<HashMap<String, i64>, rusqlite::Error> {
let mut stmt = conn.prepare(
"SELECT unique_id, local_id FROM sync_id_maildir
WHERE source_id = ?1 AND type_name = ?2 AND folder = ?3",
)?;
let rows = stmt.query_map(params![source_id, EMAIL, folder], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
})?;
let mut map = HashMap::new();
for r in rows {
let (u, l) = r?;
map.insert(u, l);
}
Ok(map)
}
pub fn delete_mailbox(
conn: &Connection,
source_id: i64,
folder: &str,
) -> Result<(), rusqlite::Error> {
conn.execute(
"DELETE FROM sync_id_maildir
WHERE source_id = ?1 AND type_name = ?2 AND folder = ?3 AND unique_id = ''",
params![source_id, MAILBOX, folder],
)?;
Ok(())
}
pub fn delete_email(
conn: &Connection,
source_id: i64,
folder: &str,
unique_id: &str,
) -> Result<(), rusqlite::Error> {
conn.execute(
"DELETE FROM sync_id_maildir
WHERE source_id = ?1 AND type_name = ?2 AND folder = ?3 AND unique_id = ?4",
params![source_id, EMAIL, folder, unique_id],
)?;
Ok(())
}
pub fn delete_all_emails_in_folder(
conn: &Connection,
source_id: i64,
folder: &str,
) -> Result<usize, rusqlite::Error> {
conn.execute(
"DELETE FROM sync_id_maildir
WHERE source_id = ?1 AND type_name = ?2 AND folder = ?3",
params![source_id, EMAIL, folder],
)
}
pub fn folders_with_emails(
conn: &Connection,
source_id: i64,
) -> Result<HashSet<String>, rusqlite::Error> {
let mut stmt = conn.prepare(
"SELECT DISTINCT folder FROM sync_id_maildir
WHERE source_id = ?1 AND type_name = ?2",
)?;
let rows = stmt.query_map(params![source_id, EMAIL], |row| row.get::<_, String>(0))?;
let mut out = HashSet::new();
for r in rows {
out.insert(r?);
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::init;
use crate::db::sources::{SourceKey, upsert_source};
fn setup() -> (Connection, i64) {
let c = Connection::open_in_memory().unwrap();
init::apply_schema(&c).unwrap();
let sid = upsert_source(
&c,
&SourceKey {
kind: "maildir".to_owned(),
session_url: "file:///home/alice/Maildir".to_owned(),
account_id: "/home/alice/Maildir".to_owned(),
},
Some("Maildir"),
"",
)
.unwrap();
(c, sid)
}
#[test]
fn mailbox_roundtrip() {
let (c, sid) = setup();
insert_mailbox(&c, sid, "INBOX", 7).unwrap();
insert_mailbox(&c, sid, "Archive.2025", 8).unwrap();
assert_eq!(local_for_mailbox(&c, sid, "INBOX").unwrap(), Some(7));
let all = mailbox_folders(&c, sid).unwrap();
assert_eq!(all.len(), 2);
assert_eq!(all.get("Archive.2025"), Some(&8));
delete_mailbox(&c, sid, "INBOX").unwrap();
assert_eq!(local_for_mailbox(&c, sid, "INBOX").unwrap(), None);
}
#[test]
fn email_roundtrip() {
let (c, sid) = setup();
insert_email(&c, sid, "INBOX", "1739471123.M001.host", 1).unwrap();
insert_email(&c, sid, "INBOX", "1739471200.M002.host", 2).unwrap();
insert_email(&c, sid, "Sent", "1739471300.M003.host", 3).unwrap();
assert_eq!(
local_for_email(&c, sid, "INBOX", "1739471123.M001.host").unwrap(),
Some(1)
);
let inbox = email_ids_in_folder(&c, sid, "INBOX").unwrap();
assert_eq!(inbox.len(), 2);
assert_eq!(inbox.get("1739471123.M001.host"), Some(&1));
let folders = folders_with_emails(&c, sid).unwrap();
assert!(folders.contains("INBOX"));
assert!(folders.contains("Sent"));
}
#[test]
fn delete_email_removes_only_that_row() {
let (c, sid) = setup();
insert_email(&c, sid, "INBOX", "u1", 1).unwrap();
insert_email(&c, sid, "INBOX", "u2", 2).unwrap();
delete_email(&c, sid, "INBOX", "u1").unwrap();
let inbox = email_ids_in_folder(&c, sid, "INBOX").unwrap();
assert_eq!(inbox.len(), 1);
assert!(inbox.contains_key("u2"));
}
#[test]
fn delete_all_emails_in_folder_wipes_only_that_folder() {
let (c, sid) = setup();
insert_email(&c, sid, "INBOX", "u1", 1).unwrap();
insert_email(&c, sid, "INBOX", "u2", 2).unwrap();
insert_email(&c, sid, "Sent", "u3", 3).unwrap();
let removed = delete_all_emails_in_folder(&c, sid, "INBOX").unwrap();
assert_eq!(removed, 2);
assert!(email_ids_in_folder(&c, sid, "INBOX").unwrap().is_empty());
assert_eq!(email_ids_in_folder(&c, sid, "Sent").unwrap().len(), 1);
}
#[test]
fn mailbox_and_email_namespaces_do_not_collide_on_local_id() {
let (c, sid) = setup();
insert_mailbox(&c, sid, "INBOX", 42).unwrap();
insert_email(&c, sid, "INBOX", "u1", 42).unwrap();
}
#[test]
fn re_insert_with_same_unique_id_is_rejected_by_pk() {
let (c, sid) = setup();
insert_email(&c, sid, "INBOX", "u1", 1).unwrap();
let err = insert_email(&c, sid, "INBOX", "u1", 2).expect_err("PK conflict");
assert!(matches!(err, rusqlite::Error::SqliteFailure(_, _)));
assert_eq!(local_for_email(&c, sid, "INBOX", "u1").unwrap(), Some(1));
assert_eq!(email_ids_in_folder(&c, sid, "INBOX").unwrap().len(), 1);
}
#[test]
fn re_insert_with_same_local_id_is_rejected_by_unique_constraint() {
let (c, sid) = setup();
insert_email(&c, sid, "INBOX", "u1", 42).unwrap();
let err = insert_email(&c, sid, "INBOX", "u2", 42).expect_err("UNIQUE conflict");
assert!(matches!(err, rusqlite::Error::SqliteFailure(_, _)));
}
}
+129
View File
@@ -0,0 +1,129 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
use std::collections::HashMap;
use rusqlite::{Connection, OptionalExtension, params};
pub fn insert(
conn: &Connection,
source_id: i64,
name: &str,
local_id: i64,
) -> Result<(), rusqlite::Error> {
conn.execute(
"INSERT OR REPLACE INTO sync_id_managesieve (source_id, name, local_id)
VALUES (?1, ?2, ?3)",
params![source_id, name, local_id],
)?;
Ok(())
}
pub fn local_for(
conn: &Connection,
source_id: i64,
name: &str,
) -> Result<Option<i64>, rusqlite::Error> {
conn.query_row(
"SELECT local_id FROM sync_id_managesieve WHERE source_id = ?1 AND name = ?2",
params![source_id, name],
|row| row.get(0),
)
.optional()
}
pub fn all_names(
conn: &Connection,
source_id: i64,
) -> Result<HashMap<String, i64>, rusqlite::Error> {
let mut stmt =
conn.prepare("SELECT name, local_id FROM sync_id_managesieve WHERE source_id = ?1")?;
let rows = stmt.query_map(params![source_id], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
})?;
let mut out = HashMap::new();
for r in rows {
let (n, l) = r?;
out.insert(n, l);
}
Ok(out)
}
pub fn delete(conn: &Connection, source_id: i64, name: &str) -> Result<(), rusqlite::Error> {
conn.execute(
"DELETE FROM sync_id_managesieve WHERE source_id = ?1 AND name = ?2",
params![source_id, name],
)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::init;
use crate::db::sources::{SourceKey, upsert_source};
fn setup() -> (Connection, i64) {
let c = Connection::open_in_memory().unwrap();
init::apply_schema(&c).unwrap();
let sid = upsert_source(
&c,
&SourceKey {
kind: "managesieve".to_owned(),
session_url: "sieve://host:4190".to_owned(),
account_id: "alice".to_owned(),
},
None,
"alice",
)
.unwrap();
(c, sid)
}
#[test]
fn insert_and_lookup_by_name() {
let (c, sid) = setup();
insert(&c, sid, "vacation", 7).unwrap();
assert_eq!(local_for(&c, sid, "vacation").unwrap(), Some(7));
assert_eq!(local_for(&c, sid, "missing").unwrap(), None);
}
#[test]
fn all_names_returns_full_map() {
let (c, sid) = setup();
insert(&c, sid, "a", 1).unwrap();
insert(&c, sid, "b", 2).unwrap();
let map = all_names(&c, sid).unwrap();
assert_eq!(map.len(), 2);
assert_eq!(map.get("a"), Some(&1));
}
#[test]
fn delete_removes_row() {
let (c, sid) = setup();
insert(&c, sid, "a", 1).unwrap();
delete(&c, sid, "a").unwrap();
assert_eq!(local_for(&c, sid, "a").unwrap(), None);
}
#[test]
fn re_insert_with_same_name_replaces_local_id() {
let (c, sid) = setup();
insert(&c, sid, "a", 1).unwrap();
insert(&c, sid, "a", 2).unwrap();
assert_eq!(local_for(&c, sid, "a").unwrap(), Some(2));
assert_eq!(all_names(&c, sid).unwrap().len(), 1);
}
#[test]
fn insert_or_replace_reassigns_when_local_id_clashes() {
let (c, sid) = setup();
insert(&c, sid, "a", 1).unwrap();
insert(&c, sid, "b", 1).unwrap();
assert!(local_for(&c, sid, "a").unwrap().is_none());
assert_eq!(local_for(&c, sid, "b").unwrap(), Some(1));
}
}
+18
View File
@@ -0,0 +1,18 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
pub mod blobs;
pub mod dav_ids;
pub mod exchange_ews_ids;
pub mod exchange_graph_ids;
pub mod ids;
pub mod imap_ids;
pub mod imap_state;
pub mod init;
pub mod maildir_ids;
pub mod managesieve_ids;
pub mod sources;
pub mod takeout_ids;
+249
View File
@@ -0,0 +1,249 @@
CREATE TABLE IF NOT EXISTS sources (
id INTEGER PRIMARY KEY,
kind TEXT NOT NULL CHECK (kind IN ('jmap','imap','caldav','carddav','webdav','managesieve','maildir','exchange_ews','exchange_graph','takeout')),
session_url TEXT NOT NULL,
account_id TEXT NOT NULL,
account_name TEXT,
username TEXT NOT NULL,
UNIQUE (kind, session_url, account_id)
);
CREATE TABLE IF NOT EXISTS blobs (
id INTEGER PRIMARY KEY,
hash BLOB NOT NULL UNIQUE,
data BLOB NOT NULL
);
CREATE INDEX IF NOT EXISTS blobs_hash_idx ON blobs (hash);
CREATE TABLE IF NOT EXISTS sync_id_jmap (
source_id INTEGER NOT NULL REFERENCES sources(id) ON DELETE CASCADE,
type_name TEXT NOT NULL,
jmap_id TEXT NOT NULL,
local_id INTEGER NOT NULL,
PRIMARY KEY (source_id, type_name, jmap_id),
UNIQUE (source_id, type_name, local_id)
);
CREATE INDEX IF NOT EXISTS sync_id_jmap_local_idx
ON sync_id_jmap (type_name, local_id);
CREATE TABLE IF NOT EXISTS sync_id_imap (
source_id INTEGER NOT NULL REFERENCES sources(id) ON DELETE CASCADE,
type_name TEXT NOT NULL CHECK (type_name IN ('mailbox','email')),
folder TEXT NOT NULL,
uidvalidity INTEGER NOT NULL,
uid INTEGER NOT NULL,
local_id INTEGER NOT NULL,
PRIMARY KEY (source_id, type_name, folder, uidvalidity, uid),
UNIQUE (source_id, type_name, local_id)
);
CREATE INDEX IF NOT EXISTS sync_id_imap_folder_idx
ON sync_id_imap (source_id, type_name, folder);
CREATE TABLE IF NOT EXISTS imap_folder_state (
source_id INTEGER NOT NULL REFERENCES sources(id) ON DELETE CASCADE,
folder TEXT NOT NULL,
uidvalidity INTEGER NOT NULL,
uidnext INTEGER NOT NULL,
last_seen TEXT NOT NULL,
PRIMARY KEY (source_id, folder)
);
CREATE TABLE IF NOT EXISTS sync_id_dav (
source_id INTEGER NOT NULL REFERENCES sources(id) ON DELETE CASCADE,
type_name TEXT NOT NULL CHECK (type_name IN (
'calendar','calendarevent',
'addressbook','contactcard',
'filenode')),
collection_href TEXT NOT NULL,
item_href TEXT NOT NULL,
etag TEXT NOT NULL DEFAULT '',
local_id INTEGER NOT NULL,
PRIMARY KEY (source_id, type_name, item_href),
UNIQUE (source_id, type_name, local_id)
);
CREATE INDEX IF NOT EXISTS sync_id_dav_collection_idx
ON sync_id_dav (source_id, type_name, collection_href);
CREATE TABLE IF NOT EXISTS sync_id_managesieve (
source_id INTEGER NOT NULL REFERENCES sources(id) ON DELETE CASCADE,
name TEXT NOT NULL,
local_id INTEGER NOT NULL,
PRIMARY KEY (source_id, name),
UNIQUE (source_id, local_id)
);
CREATE TABLE IF NOT EXISTS sync_id_maildir (
source_id INTEGER NOT NULL REFERENCES sources(id) ON DELETE CASCADE,
type_name TEXT NOT NULL CHECK (type_name IN ('mailbox','email')),
folder TEXT NOT NULL,
unique_id TEXT NOT NULL,
local_id INTEGER NOT NULL,
PRIMARY KEY (source_id, type_name, folder, unique_id),
UNIQUE (source_id, type_name, local_id)
);
CREATE INDEX IF NOT EXISTS sync_id_maildir_folder_idx
ON sync_id_maildir (source_id, type_name, folder);
CREATE TABLE IF NOT EXISTS sync_id_exchange_ews (
source_id INTEGER NOT NULL REFERENCES sources(id) ON DELETE CASCADE,
type_name TEXT NOT NULL CHECK (type_name IN (
'mailbox','calendar','addressbook',
'email','calendarevent','contactcard')),
folder_id TEXT NOT NULL DEFAULT '',
item_id TEXT NOT NULL,
change_key TEXT NOT NULL DEFAULT '',
sync_state TEXT NOT NULL DEFAULT '',
local_id INTEGER NOT NULL,
PRIMARY KEY (source_id, type_name, item_id),
UNIQUE (source_id, type_name, local_id)
);
CREATE INDEX IF NOT EXISTS sync_id_exchange_ews_folder_idx
ON sync_id_exchange_ews (source_id, type_name, folder_id);
CREATE TABLE IF NOT EXISTS sync_id_exchange_graph (
source_id INTEGER NOT NULL REFERENCES sources(id) ON DELETE CASCADE,
type_name TEXT NOT NULL CHECK (type_name IN (
'mailbox','email',
'calendar','calendarevent',
'addressbook','contactcard')),
graph_id TEXT NOT NULL,
local_id INTEGER NOT NULL,
PRIMARY KEY (source_id, type_name, graph_id),
UNIQUE (source_id, type_name, local_id)
);
CREATE INDEX IF NOT EXISTS sync_id_exchange_graph_type_idx
ON sync_id_exchange_graph (source_id, type_name);
CREATE TABLE IF NOT EXISTS mailboxes (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
parent_id INTEGER REFERENCES mailboxes(id) ON DELETE RESTRICT,
role TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
is_subscribed INTEGER NOT NULL DEFAULT 1,
UNIQUE (parent_id, name)
);
CREATE TABLE IF NOT EXISTS emails (
id INTEGER PRIMARY KEY,
blob_id INTEGER NOT NULL REFERENCES blobs(id),
received_at TEXT NOT NULL,
mailbox_ids TEXT NOT NULL CHECK (json_valid(mailbox_ids)),
keywords TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(keywords)),
message_match TEXT NOT NULL DEFAULT '{}' CHECK (json_valid(message_match))
);
CREATE INDEX IF NOT EXISTS emails_blob_idx ON emails (blob_id);
CREATE TABLE IF NOT EXISTS identities (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL DEFAULT '',
email TEXT NOT NULL,
reply_to TEXT CHECK (reply_to IS NULL OR json_valid(reply_to)),
bcc TEXT CHECK (bcc IS NULL OR json_valid(bcc)),
text_signature TEXT NOT NULL DEFAULT '',
html_signature TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS sieve_scripts (
id INTEGER PRIMARY KEY,
name TEXT,
is_active INTEGER NOT NULL DEFAULT 0,
blob_id INTEGER NOT NULL REFERENCES blobs(id),
UNIQUE (name)
);
CREATE UNIQUE INDEX IF NOT EXISTS sieve_scripts_one_active_idx
ON sieve_scripts (is_active) WHERE is_active = 1;
CREATE TABLE IF NOT EXISTS address_books (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
is_default INTEGER NOT NULL DEFAULT 0,
is_subscribed INTEGER NOT NULL DEFAULT 1
);
CREATE TABLE IF NOT EXISTS contact_cards (
id INTEGER PRIMARY KEY,
uid TEXT NOT NULL UNIQUE,
address_book_ids TEXT NOT NULL CHECK (json_valid(address_book_ids)),
data TEXT NOT NULL CHECK (json_valid(data))
);
CREATE TABLE IF NOT EXISTS calendars (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
color TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
is_subscribed INTEGER NOT NULL DEFAULT 1,
is_visible INTEGER NOT NULL DEFAULT 1,
is_default INTEGER NOT NULL DEFAULT 0,
include_in_availability TEXT NOT NULL DEFAULT 'all'
CHECK (include_in_availability IN ('all','attending','none')),
default_alerts_with_time TEXT CHECK (default_alerts_with_time IS NULL
OR json_valid(default_alerts_with_time)),
default_alerts_without_time TEXT CHECK (default_alerts_without_time IS NULL
OR json_valid(default_alerts_without_time)),
time_zone TEXT
);
CREATE TABLE IF NOT EXISTS calendar_events (
id INTEGER PRIMARY KEY,
calendar_ids TEXT NOT NULL CHECK (json_valid(calendar_ids)),
is_draft INTEGER NOT NULL DEFAULT 0,
use_default_alerts INTEGER NOT NULL DEFAULT 0,
data TEXT NOT NULL CHECK (json_valid(data)),
data_type TEXT NOT NULL DEFAULT 'Event'
CHECK (data_type IN ('Event','Task','Note','Group'))
);
CREATE TABLE IF NOT EXISTS participant_identities (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL DEFAULT '',
calendar_address TEXT NOT NULL,
is_default INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS file_nodes (
id INTEGER PRIMARY KEY,
parent_id INTEGER REFERENCES file_nodes(id) ON DELETE RESTRICT,
node_type TEXT NOT NULL
CHECK (node_type IN ('file','directory','symlink')),
blob_id INTEGER REFERENCES blobs(id),
target TEXT CHECK (target IS NULL OR json_valid(target)),
name TEXT NOT NULL,
media_type TEXT,
created TEXT NOT NULL,
modified TEXT,
is_subscribed INTEGER NOT NULL DEFAULT 1,
role TEXT,
UNIQUE (parent_id, name)
);
CREATE INDEX IF NOT EXISTS file_nodes_parent_idx ON file_nodes (parent_id);
CREATE INDEX IF NOT EXISTS file_nodes_blob_idx ON file_nodes (blob_id);
CREATE TABLE IF NOT EXISTS sync_id_takeout (
source_id INTEGER NOT NULL REFERENCES sources(id) ON DELETE CASCADE,
type_name TEXT NOT NULL CHECK (type_name IN (
'mailbox','email',
'addressbook','contactcard',
'calendar','calendarevent')),
source_obj_id TEXT NOT NULL,
local_id INTEGER NOT NULL,
PRIMARY KEY (source_id, type_name, source_obj_id),
UNIQUE (source_id, type_name, local_id)
);
CREATE INDEX IF NOT EXISTS sync_id_takeout_type_idx
ON sync_id_takeout (source_id, type_name);
+105
View File
@@ -0,0 +1,105 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
use rusqlite::{Connection, OptionalExtension, params};
#[derive(Debug, Clone)]
pub struct SourceKey {
pub kind: String,
pub session_url: String,
pub account_id: String,
}
pub fn find_source(conn: &Connection, key: &SourceKey) -> Result<Option<i64>, rusqlite::Error> {
conn.query_row(
"SELECT id FROM sources WHERE kind = ?1 AND session_url = ?2 AND account_id = ?3",
params![key.kind, key.session_url, key.account_id],
|row| row.get(0),
)
.optional()
}
pub fn conflicting_source(
conn: &Connection,
kind: &str,
session_url: &str,
account_id: &str,
) -> Result<Option<(String, String)>, rusqlite::Error> {
conn.query_row(
"SELECT session_url, account_id FROM sources
WHERE kind = ?1 AND NOT (session_url = ?2 AND account_id = ?3) LIMIT 1",
params![kind, session_url, account_id],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.optional()
}
pub fn upsert_source(
conn: &Connection,
key: &SourceKey,
account_name: Option<&str>,
username: &str,
) -> Result<i64, rusqlite::Error> {
conn.execute(
"INSERT INTO sources (kind, session_url, account_id, account_name, username)
VALUES (?1, ?2, ?3, ?4, ?5)
ON CONFLICT (kind, session_url, account_id)
DO UPDATE SET account_name = excluded.account_name, username = excluded.username",
params![
key.kind,
key.session_url,
key.account_id,
account_name,
username
],
)?;
find_source(conn, key).map(|opt| opt.unwrap_or_else(|| conn.last_insert_rowid()))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::init;
fn mem() -> Connection {
let c = Connection::open_in_memory().unwrap();
init::apply_schema(&c).unwrap();
c
}
fn key(url: &str, acc: &str) -> SourceKey {
SourceKey {
kind: "jmap".to_owned(),
session_url: url.to_owned(),
account_id: acc.to_owned(),
}
}
#[test]
fn upsert_is_idempotent_and_returns_stable_id() {
let c = mem();
let k = key("https://a/jmap", "w");
let id1 = upsert_source(&c, &k, Some("alice"), "alice").unwrap();
let id2 = upsert_source(&c, &k, Some("alice2"), "alice").unwrap();
assert_eq!(id1, id2);
assert!(find_source(&c, &k).unwrap().is_some());
}
#[test]
fn conflicting_source_detects_a_different_account() {
let c = mem();
upsert_source(&c, &key("https://a/jmap", "w"), None, "alice").unwrap();
assert!(
conflicting_source(&c, "jmap", "https://a/jmap", "w")
.unwrap()
.is_none()
);
let conflict = conflicting_source(&c, "jmap", "https://b/jmap", "v")
.unwrap()
.unwrap();
assert_eq!(conflict, ("https://a/jmap".to_owned(), "w".to_owned()));
}
}
+177
View File
@@ -0,0 +1,177 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
use std::collections::HashMap;
use rusqlite::{Connection, OptionalExtension, params};
pub const MAILBOX: &str = "mailbox";
pub const EMAIL: &str = "email";
pub const ADDRESS_BOOK: &str = "addressbook";
pub const CONTACT_CARD: &str = "contactcard";
pub const CALENDAR: &str = "calendar";
pub const CALENDAR_EVENT: &str = "calendarevent";
pub fn insert(
conn: &Connection,
source_id: i64,
type_name: &str,
source_obj_id: &str,
local_id: i64,
) -> Result<(), rusqlite::Error> {
conn.execute(
"INSERT INTO sync_id_takeout
(source_id, type_name, source_obj_id, local_id)
VALUES (?1, ?2, ?3, ?4)",
params![source_id, type_name, source_obj_id, local_id],
)?;
Ok(())
}
pub fn local_for(
conn: &Connection,
source_id: i64,
type_name: &str,
source_obj_id: &str,
) -> Result<Option<i64>, rusqlite::Error> {
conn.query_row(
"SELECT local_id FROM sync_id_takeout
WHERE source_id = ?1 AND type_name = ?2 AND source_obj_id = ?3",
params![source_id, type_name, source_obj_id],
|row| row.get(0),
)
.optional()
}
pub fn delete(
conn: &Connection,
source_id: i64,
type_name: &str,
source_obj_id: &str,
) -> Result<(), rusqlite::Error> {
conn.execute(
"DELETE FROM sync_id_takeout
WHERE source_id = ?1 AND type_name = ?2 AND source_obj_id = ?3",
params![source_id, type_name, source_obj_id],
)?;
Ok(())
}
pub fn all_for_type(
conn: &Connection,
source_id: i64,
type_name: &str,
) -> Result<HashMap<String, i64>, rusqlite::Error> {
let mut stmt = conn.prepare(
"SELECT source_obj_id, local_id FROM sync_id_takeout
WHERE source_id = ?1 AND type_name = ?2",
)?;
let rows = stmt.query_map(params![source_id, type_name], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
})?;
let mut map = HashMap::new();
for r in rows {
let (k, v) = r?;
map.insert(k, v);
}
Ok(map)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::init;
use crate::db::sources::{SourceKey, upsert_source};
fn setup() -> (Connection, i64) {
let c = Connection::open_in_memory().unwrap();
init::apply_schema(&c).unwrap();
let sid = upsert_source(
&c,
&SourceKey {
kind: "takeout".to_owned(),
session_url: "file:///tmp/Takeout".to_owned(),
account_id: "/tmp/Takeout".to_owned(),
},
Some("Takeout"),
"",
)
.unwrap();
(c, sid)
}
#[test]
fn insert_and_lookup_roundtrip() {
let (c, sid) = setup();
insert(&c, sid, MAILBOX, "Inbox", 7).unwrap();
insert(&c, sid, MAILBOX, "Label/Sub", 8).unwrap();
assert_eq!(local_for(&c, sid, MAILBOX, "Inbox").unwrap(), Some(7));
assert_eq!(local_for(&c, sid, MAILBOX, "Label/Sub").unwrap(), Some(8));
assert_eq!(local_for(&c, sid, MAILBOX, "Missing").unwrap(), None);
}
#[test]
fn types_are_isolated_namespaces() {
let (c, sid) = setup();
insert(&c, sid, MAILBOX, "Inbox", 1).unwrap();
insert(&c, sid, EMAIL, "ab12", 2).unwrap();
insert(&c, sid, ADDRESS_BOOK, "Imported", 3).unwrap();
insert(&c, sid, CONTACT_CARD, "uid-1", 4).unwrap();
insert(&c, sid, CALENDAR, "Imported", 5).unwrap();
insert(&c, sid, CALENDAR_EVENT, "uid-e1", 6).unwrap();
assert_eq!(all_for_type(&c, sid, MAILBOX).unwrap().len(), 1);
assert_eq!(all_for_type(&c, sid, EMAIL).unwrap().len(), 1);
assert_eq!(all_for_type(&c, sid, ADDRESS_BOOK).unwrap().len(), 1);
assert_eq!(all_for_type(&c, sid, CONTACT_CARD).unwrap().len(), 1);
assert_eq!(all_for_type(&c, sid, CALENDAR).unwrap().len(), 1);
assert_eq!(all_for_type(&c, sid, CALENDAR_EVENT).unwrap().len(), 1);
}
#[test]
fn delete_removes_only_target_row() {
let (c, sid) = setup();
insert(&c, sid, EMAIL, "hash-a", 1).unwrap();
insert(&c, sid, EMAIL, "hash-b", 2).unwrap();
delete(&c, sid, EMAIL, "hash-a").unwrap();
assert_eq!(local_for(&c, sid, EMAIL, "hash-a").unwrap(), None);
assert_eq!(local_for(&c, sid, EMAIL, "hash-b").unwrap(), Some(2));
}
#[test]
fn pk_rejects_duplicate_source_obj_id() {
let (c, sid) = setup();
insert(&c, sid, EMAIL, "h", 1).unwrap();
let err = insert(&c, sid, EMAIL, "h", 2).expect_err("PK conflict");
assert!(matches!(err, rusqlite::Error::SqliteFailure(_, _)));
}
#[test]
fn unique_constraint_rejects_duplicate_local_id_within_type() {
let (c, sid) = setup();
insert(&c, sid, EMAIL, "h1", 42).unwrap();
let err = insert(&c, sid, EMAIL, "h2", 42).expect_err("UNIQUE conflict");
assert!(matches!(err, rusqlite::Error::SqliteFailure(_, _)));
}
#[test]
fn same_local_id_allowed_across_different_types() {
let (c, sid) = setup();
insert(&c, sid, MAILBOX, "Inbox", 42).unwrap();
insert(&c, sid, EMAIL, "h1", 42).unwrap();
}
#[test]
fn all_for_type_returns_full_map() {
let (c, sid) = setup();
insert(&c, sid, EMAIL, "h1", 1).unwrap();
insert(&c, sid, EMAIL, "h2", 2).unwrap();
insert(&c, sid, EMAIL, "h3", 3).unwrap();
let all = all_for_type(&c, sid, EMAIL).unwrap();
assert_eq!(all.len(), 3);
assert_eq!(all.get("h1"), Some(&1));
assert_eq!(all.get("h3"), Some(&3));
}
}