Initial commit
This commit is contained in:
+111
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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(_, _)));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user