MS Exchange Graph: Import the default Contacts folder, recover series exceptions (fixes #39)
This commit is contained in:
@@ -2,6 +2,16 @@
|
|||||||
|
|
||||||
All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/).
|
All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/).
|
||||||
|
|
||||||
|
## [1.0.10] - 2026-08-27
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- MS Exchange Graph: Import the default Contacts folder, recover series exceptions (fixes #39)
|
||||||
|
- OneDrive files import
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
## [1.0.9] - 2026-08-22
|
## [1.0.9] - 2026-08-22
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
Generated
+23
-23
@@ -157,7 +157,7 @@ checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.3",
|
"syn 3.0.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -371,9 +371,9 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "calcard"
|
name = "calcard"
|
||||||
version = "0.3.11"
|
version = "0.3.13"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f908fcb612ff8729e4302e071562bfbb0f7cdeb1b791e06024e7f11eedb83d9c"
|
checksum = "75b779382e675380a1ff8a4873acee5ba60158be7a00458fb98e6b85aad1f2ee"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ahash",
|
"ahash",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -471,7 +471,7 @@ dependencies = [
|
|||||||
"heck",
|
"heck",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.3",
|
"syn 3.0.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -506,9 +506,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "combine"
|
name = "combine"
|
||||||
version = "4.6.7"
|
version = "4.6.8"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd"
|
checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
"memchr",
|
"memchr",
|
||||||
@@ -576,9 +576,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crc32fast"
|
name = "crc32fast"
|
||||||
version = "1.5.0"
|
version = "1.5.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
|
checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
]
|
]
|
||||||
@@ -680,7 +680,7 @@ checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.3",
|
"syn 3.0.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -875,7 +875,7 @@ checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.3",
|
"syn 3.0.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -944,9 +944,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "h2"
|
name = "h2"
|
||||||
version = "0.4.18"
|
version = "0.4.19"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "839c0e8a181239723652be9062bb56ca5bf5f64011f73b623f6f4fc59086a228"
|
checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"atomic-waker",
|
"atomic-waker",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -2025,7 +2025,7 @@ checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.3",
|
"syn 3.0.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2316,7 +2316,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.3",
|
"syn 3.0.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2340,7 +2340,7 @@ checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.3",
|
"syn 3.0.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2532,9 +2532,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "syn"
|
name = "syn"
|
||||||
version = "3.0.3"
|
version = "3.0.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
|
checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
@@ -2619,7 +2619,7 @@ checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.3",
|
"syn 3.0.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2702,7 +2702,7 @@ checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.3",
|
"syn 3.0.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2925,16 +2925,16 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "uuid"
|
name = "uuid"
|
||||||
version = "1.25.0"
|
version = "1.26.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f053576934f05a761a402421fbbe3d425d9366f75f978806a037b3ca481abecc"
|
checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"sha1_smol",
|
"sha1_smol",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "vandelay"
|
name = "vandelay"
|
||||||
version = "1.0.9"
|
version = "1.0.10"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.23.1",
|
"base64 0.23.1",
|
||||||
"blake3",
|
"blake3",
|
||||||
@@ -3368,7 +3368,7 @@ checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.3",
|
"syn 3.0.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "vandelay"
|
name = "vandelay"
|
||||||
description = "JMAP account migration utility"
|
description = "JMAP account migration utility"
|
||||||
version = "1.0.9"
|
version = "1.0.10"
|
||||||
authors = ["Stalwart Labs LLC <[email protected]>"]
|
authors = ["Stalwart Labs LLC <[email protected]>"]
|
||||||
license = "Apache-2.0 OR MIT"
|
license = "Apache-2.0 OR MIT"
|
||||||
repository = "https://github.com/stalwartlabs/vandelay"
|
repository = "https://github.com/stalwartlabs/vandelay"
|
||||||
|
|||||||
+17
-2
@@ -1132,7 +1132,7 @@ pub struct ExchangeGraphImportArgs {
|
|||||||
#[arg(
|
#[arg(
|
||||||
long,
|
long,
|
||||||
value_name = "LIST",
|
value_name = "LIST",
|
||||||
help = "Comma-separated surface list: mail | calendar | contacts (default: all three)"
|
help = "Comma-separated surface list: mail | calendar | contacts | files (default: all)"
|
||||||
)]
|
)]
|
||||||
objects: Option<String>,
|
objects: Option<String>,
|
||||||
|
|
||||||
@@ -1161,6 +1161,19 @@ pub struct ExchangeGraphImportArgs {
|
|||||||
)]
|
)]
|
||||||
top: usize,
|
top: usize,
|
||||||
|
|
||||||
|
#[arg(
|
||||||
|
long,
|
||||||
|
value_name = "N",
|
||||||
|
default_value_t = 5,
|
||||||
|
help = "Years either side of today to scan for edited recurrence occurrences (max 5)",
|
||||||
|
long_help = "Years either side of today to scan for edited recurrence occurrences.\n \
|
||||||
|
Graph reports a series exception only inside an expanded calendar view, and caps\n \
|
||||||
|
any single view at five years, so vandelay scans [today - N, today] and\n \
|
||||||
|
[today, today + N]. An occurrence edited outside that span imports with the\n \
|
||||||
|
series default instead of its edit."
|
||||||
|
)]
|
||||||
|
exception_window_years: i32,
|
||||||
|
|
||||||
#[arg(
|
#[arg(
|
||||||
long,
|
long,
|
||||||
value_name = "URL",
|
value_name = "URL",
|
||||||
@@ -1225,6 +1238,7 @@ fn resolve_exchange_graph_import(args: ExchangeGraphImportArgs) -> Result<Action
|
|||||||
event_body_format,
|
event_body_format,
|
||||||
graph_connections,
|
graph_connections,
|
||||||
top,
|
top,
|
||||||
|
exception_window_years: args.exception_window_years,
|
||||||
allow_source_change: args.allow_source_change,
|
allow_source_change: args.allow_source_change,
|
||||||
},
|
},
|
||||||
))
|
))
|
||||||
@@ -1389,7 +1403,8 @@ mod tests {
|
|||||||
Surfaces {
|
Surfaces {
|
||||||
mail: false,
|
mail: false,
|
||||||
calendar: false,
|
calendar: false,
|
||||||
contacts: true
|
contacts: true,
|
||||||
|
files: false
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ pub const CALENDAR: &str = "calendar";
|
|||||||
pub const CALENDAR_EVENT: &str = "calendarevent";
|
pub const CALENDAR_EVENT: &str = "calendarevent";
|
||||||
pub const ADDRESS_BOOK: &str = "addressbook";
|
pub const ADDRESS_BOOK: &str = "addressbook";
|
||||||
pub const CONTACT_CARD: &str = "contactcard";
|
pub const CONTACT_CARD: &str = "contactcard";
|
||||||
|
pub const FILE_NODE: &str = "filenode";
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct IdRow {
|
pub struct IdRow {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ pub fn apply_schema(conn: &Connection) -> Result<(), OpenError> {
|
|||||||
let tx = conn.unchecked_transaction()?;
|
let tx = conn.unchecked_transaction()?;
|
||||||
tx.execute_batch(SCHEMA_SQL)?;
|
tx.execute_batch(SCHEMA_SQL)?;
|
||||||
ensure_calendar_events_data_type(&tx)?;
|
ensure_calendar_events_data_type(&tx)?;
|
||||||
|
ensure_graph_ids_accept_file_nodes(&tx)?;
|
||||||
tx.commit()?;
|
tx.commit()?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -36,6 +37,41 @@ fn ensure_calendar_events_data_type(conn: &Connection) -> Result<(), OpenError>
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn ensure_graph_ids_accept_file_nodes(conn: &Connection) -> Result<(), OpenError> {
|
||||||
|
let sql: Option<String> = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT sql FROM sqlite_master WHERE type = 'table' \
|
||||||
|
AND name = 'sync_id_exchange_graph'",
|
||||||
|
[],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.ok();
|
||||||
|
let Some(sql) = sql else { return Ok(()) };
|
||||||
|
if sql.contains("'filenode'") {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
conn.execute_batch(
|
||||||
|
"ALTER TABLE sync_id_exchange_graph RENAME TO sync_id_exchange_graph_old;
|
||||||
|
CREATE TABLE 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',
|
||||||
|
'filenode')),
|
||||||
|
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)
|
||||||
|
);
|
||||||
|
INSERT INTO sync_id_exchange_graph SELECT * FROM sync_id_exchange_graph_old;
|
||||||
|
DROP TABLE sync_id_exchange_graph_old;
|
||||||
|
CREATE INDEX IF NOT EXISTS sync_id_exchange_graph_type_idx
|
||||||
|
ON sync_id_exchange_graph (source_id, type_name);",
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn apply_pragmas(conn: &Connection) -> Result<(), OpenError> {
|
fn apply_pragmas(conn: &Connection) -> Result<(), OpenError> {
|
||||||
conn.pragma_update(None, "journal_mode", "WAL")?;
|
conn.pragma_update(None, "journal_mode", "WAL")?;
|
||||||
conn.pragma_update(None, "foreign_keys", "ON")?;
|
conn.pragma_update(None, "foreign_keys", "ON")?;
|
||||||
|
|||||||
+2
-1
@@ -118,7 +118,8 @@ CREATE TABLE IF NOT EXISTS sync_id_exchange_graph (
|
|||||||
type_name TEXT NOT NULL CHECK (type_name IN (
|
type_name TEXT NOT NULL CHECK (type_name IN (
|
||||||
'mailbox','email',
|
'mailbox','email',
|
||||||
'calendar','calendarevent',
|
'calendar','calendarevent',
|
||||||
'addressbook','contactcard')),
|
'addressbook','contactcard',
|
||||||
|
'filenode')),
|
||||||
graph_id TEXT NOT NULL,
|
graph_id TEXT NOT NULL,
|
||||||
local_id INTEGER NOT NULL,
|
local_id INTEGER NOT NULL,
|
||||||
PRIMARY KEY (source_id, type_name, graph_id),
|
PRIMARY KEY (source_id, type_name, graph_id),
|
||||||
|
|||||||
@@ -16,6 +16,18 @@ pub const PREFER_TIMEZONE_UTC: &str = "outlook.timezone=\"UTC\"";
|
|||||||
pub const PREFER_BODY_TEXT: &str = "outlook.body-content-type=\"text\"";
|
pub const PREFER_BODY_TEXT: &str = "outlook.body-content-type=\"text\"";
|
||||||
pub const PREFER_BODY_HTML: &str = "outlook.body-content-type=\"html\"";
|
pub const PREFER_BODY_HTML: &str = "outlook.body-content-type=\"html\"";
|
||||||
|
|
||||||
|
pub const EXCEPTION_SELECT: &str = "id,type,seriesMasterId,originalStart,iCalUId,subject,body,\
|
||||||
|
start,end,isAllDay,isCancelled,isDraft,sensitivity,importance,showAs,categories,locations,\
|
||||||
|
organizer,attendees,createdDateTime,lastModifiedDateTime,isReminderOn,\
|
||||||
|
reminderMinutesBeforeStart,originalStartTimeZone";
|
||||||
|
|
||||||
|
pub const EXCEPTION_WINDOW_MAX_DAYS: i64 = 1825;
|
||||||
|
|
||||||
|
pub const DRIVE_ITEM_SELECT: &str =
|
||||||
|
"id,name,size,folder,file,package,remoteItem,createdDateTime,lastModifiedDateTime";
|
||||||
|
|
||||||
|
pub const MESSAGE_STATE_SELECT: &str = "id,isRead,isDraft,isReadReceiptRequested,flag,categories";
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct Endpoints {
|
pub struct Endpoints {
|
||||||
pub api_base: String,
|
pub api_base: String,
|
||||||
@@ -68,7 +80,7 @@ impl Endpoints {
|
|||||||
|
|
||||||
pub fn folder_messages_ids(&self, folder_id: &str, top: usize) -> String {
|
pub fn folder_messages_ids(&self, folder_id: &str, top: usize) -> String {
|
||||||
format!(
|
format!(
|
||||||
"{}/mailFolders/{}/messages?$top={top}&$select=id",
|
"{}/mailFolders/{}/messages?$top={top}&$select={MESSAGE_STATE_SELECT}",
|
||||||
self.me_or_user(),
|
self.me_or_user(),
|
||||||
url_escape(folder_id)
|
url_escape(folder_id)
|
||||||
)
|
)
|
||||||
@@ -106,10 +118,64 @@ impl Endpoints {
|
|||||||
format!("{}/events/{}", self.me_or_user(), url_escape(event_id))
|
format!("{}/events/{}", self.me_or_user(), url_escape(event_id))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn calendar_exceptions(
|
||||||
|
&self,
|
||||||
|
calendar_id: &str,
|
||||||
|
window_start: &str,
|
||||||
|
window_end: &str,
|
||||||
|
top: usize,
|
||||||
|
) -> String {
|
||||||
|
format!(
|
||||||
|
"{}/calendars/{}/calendarView?startDateTime={window_start}&endDateTime={window_end}\
|
||||||
|
&$filter=type%20eq%20%27exception%27&$top={top}&$select={EXCEPTION_SELECT}",
|
||||||
|
self.me_or_user(),
|
||||||
|
url_escape(calendar_id)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn drive_root(&self) -> String {
|
||||||
|
format!("{}/drive/root?$select=id,name", self.me_or_user())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn drive_children(&self, item_id: &str, top: usize) -> String {
|
||||||
|
format!(
|
||||||
|
"{}/drive/items/{}/children?$top={top}&$select={DRIVE_ITEM_SELECT}",
|
||||||
|
self.me_or_user(),
|
||||||
|
url_escape(item_id)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn drive_item_content(&self, item_id: &str) -> String {
|
||||||
|
format!(
|
||||||
|
"{}/drive/items/{}/content",
|
||||||
|
self.me_or_user(),
|
||||||
|
url_escape(item_id)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn contact_folders(&self, top: usize) -> String {
|
pub fn contact_folders(&self, top: usize) -> String {
|
||||||
format!("{}/contactFolders?$top={top}", self.me_or_user())
|
format!("{}/contactFolders?$top={top}", self.me_or_user())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn default_contact_folder(&self) -> String {
|
||||||
|
format!("{}/contactFolders/contacts", self.me_or_user())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn contact_folder(&self, folder_id: &str) -> String {
|
||||||
|
format!(
|
||||||
|
"{}/contactFolders/{}",
|
||||||
|
self.me_or_user(),
|
||||||
|
url_escape(folder_id)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn any_contact_parent(&self) -> String {
|
||||||
|
format!(
|
||||||
|
"{}/contacts?$top=1&$select=id,parentFolderId",
|
||||||
|
self.me_or_user()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn contact_folder_children(&self, folder_id: &str, top: usize) -> String {
|
pub fn contact_folder_children(&self, folder_id: &str, top: usize) -> String {
|
||||||
format!(
|
format!(
|
||||||
"{}/contactFolders/{}/childFolders?$top={top}",
|
"{}/contactFolders/{}/childFolders?$top={top}",
|
||||||
|
|||||||
@@ -320,9 +320,15 @@ pub fn convert_event(
|
|||||||
.and_then(Value::as_array)
|
.and_then(Value::as_array)
|
||||||
&& !cancels.is_empty()
|
&& !cancels.is_empty()
|
||||||
{
|
{
|
||||||
|
let start_time = card
|
||||||
|
.get("start")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.and_then(|s| s.split_once('T').map(|(_, time)| time.to_owned()));
|
||||||
let mut overrides = Map::new();
|
let mut overrides = Map::new();
|
||||||
for entry in cancels.iter().filter_map(Value::as_str) {
|
for entry in cancels.iter().filter_map(Value::as_str) {
|
||||||
overrides.insert(entry.to_owned(), json!({"excluded": true}));
|
if let Some(key) = cancelled_occurrence_key(entry, start_time.as_deref()) {
|
||||||
|
overrides.insert(key, json!({"excluded": true}));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if !overrides.is_empty() {
|
if !overrides.is_empty() {
|
||||||
card.insert("recurrenceOverrides".to_owned(), Value::Object(overrides));
|
card.insert("recurrenceOverrides".to_owned(), Value::Object(overrides));
|
||||||
@@ -340,6 +346,17 @@ pub fn convert_event(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn cancelled_occurrence_key(entry: &str, start_time: Option<&str>) -> Option<String> {
|
||||||
|
let date = entry.rsplit('.').next().filter(|d| d.len() == 10)?;
|
||||||
|
if !date.as_bytes().iter().enumerate().all(|(i, b)| match i {
|
||||||
|
4 | 7 => *b == b'-',
|
||||||
|
_ => b.is_ascii_digit(),
|
||||||
|
}) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(format!("{date}T{}", start_time.unwrap_or("00:00:00")))
|
||||||
|
}
|
||||||
|
|
||||||
fn extract_local_datetime(slot: Option<&Value>) -> Option<String> {
|
fn extract_local_datetime(slot: Option<&Value>) -> Option<String> {
|
||||||
let slot = slot?;
|
let slot = slot?;
|
||||||
let dt = slot.get("dateTime").and_then(Value::as_str)?;
|
let dt = slot.get("dateTime").and_then(Value::as_str)?;
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ const LONG_RETRY_THRESHOLD: Duration = Duration::from_secs(10);
|
|||||||
pub enum Accept {
|
pub enum Accept {
|
||||||
Json,
|
Json,
|
||||||
Text,
|
Text,
|
||||||
|
Binary,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Accept {
|
impl Accept {
|
||||||
@@ -35,6 +36,7 @@ impl Accept {
|
|||||||
match self {
|
match self {
|
||||||
Accept::Json => "application/json",
|
Accept::Json => "application/json",
|
||||||
Accept::Text => "text/plain",
|
Accept::Text => "text/plain",
|
||||||
|
Accept::Binary => "application/octet-stream",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -411,10 +413,17 @@ struct RetryLog<'a> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn warn_on_redirect(logger: &Logger, method: &str, requested: &str, final_uri: &Uri) {
|
fn warn_on_redirect(logger: &Logger, method: &str, requested: &str, final_uri: &Uri) {
|
||||||
|
if requested.ends_with("/content") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
let landed = final_uri.to_string();
|
let landed = final_uri.to_string();
|
||||||
if cross_host(requested, &landed) {
|
if cross_host(requested, &landed) {
|
||||||
|
let host = url::Url::parse(&landed)
|
||||||
|
.ok()
|
||||||
|
.and_then(|u| u.host_str().map(str::to_owned))
|
||||||
|
.unwrap_or_else(|| "another host".to_owned());
|
||||||
logger.warn(&format!(
|
logger.warn(&format!(
|
||||||
"{method} {requested} was redirected across hosts to {landed}; verify the Graph endpoint host is reachable directly without redirection"
|
"{method} {requested} was redirected across hosts to {host}; verify the Graph endpoint host is reachable directly without redirection"
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,7 +69,9 @@ pub fn convert_patterned_recurrence_rule(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(index) = pattern.get("index").and_then(Value::as_str)
|
let pattern_type = pattern.get("type").and_then(Value::as_str).unwrap_or("");
|
||||||
|
if matches!(pattern_type, "relativeMonthly" | "relativeYearly")
|
||||||
|
&& let Some(index) = pattern.get("index").and_then(Value::as_str)
|
||||||
&& let Some(setpos) = set_position_for(index)
|
&& let Some(setpos) = set_position_for(index)
|
||||||
{
|
{
|
||||||
rule.insert(
|
rule.insert(
|
||||||
@@ -205,6 +207,35 @@ mod tests {
|
|||||||
assert_eq!(rule["byDay"][0]["day"], "th");
|
assert_eq!(rule["byDay"][0]["day"], "th");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn index_is_ignored_unless_the_pattern_is_relative() {
|
||||||
|
for kind in ["daily", "weekly", "absoluteMonthly", "absoluteYearly"] {
|
||||||
|
let pr = json!({
|
||||||
|
"pattern": {"type": kind, "interval": 1, "index": "first",
|
||||||
|
"daysOfWeek": ["monday"], "dayOfMonth": 1, "month": 1},
|
||||||
|
"range": {"type": "noEnd"}
|
||||||
|
});
|
||||||
|
let out = convert_patterned_recurrence(&pr).unwrap();
|
||||||
|
assert!(
|
||||||
|
out.get("bySetPosition").is_none(),
|
||||||
|
"{kind} carries Graph's default index=first, which must not become bySetPosition"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn index_survives_for_relative_patterns() {
|
||||||
|
for kind in ["relativeMonthly", "relativeYearly"] {
|
||||||
|
let pr = json!({
|
||||||
|
"pattern": {"type": kind, "interval": 1, "index": "third",
|
||||||
|
"daysOfWeek": ["thursday"]},
|
||||||
|
"range": {"type": "noEnd"}
|
||||||
|
});
|
||||||
|
let out = convert_patterned_recurrence(&pr).unwrap();
|
||||||
|
assert_eq!(out["bySetPosition"][0], 3, "{kind} keeps its index");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn absolute_yearly_with_month_and_day() {
|
fn absolute_yearly_with_month_and_day() {
|
||||||
let pr = json!({
|
let pr = json!({
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ pub struct Surfaces {
|
|||||||
pub mail: bool,
|
pub mail: bool,
|
||||||
pub calendar: bool,
|
pub calendar: bool,
|
||||||
pub contacts: bool,
|
pub contacts: bool,
|
||||||
|
pub files: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Surfaces {
|
impl Default for Surfaces {
|
||||||
@@ -77,12 +78,14 @@ impl Surfaces {
|
|||||||
mail: true,
|
mail: true,
|
||||||
calendar: true,
|
calendar: true,
|
||||||
contacts: true,
|
contacts: true,
|
||||||
|
files: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub const NONE: Surfaces = Surfaces {
|
pub const NONE: Surfaces = Surfaces {
|
||||||
mail: false,
|
mail: false,
|
||||||
calendar: false,
|
calendar: false,
|
||||||
contacts: false,
|
contacts: false,
|
||||||
|
files: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub fn parse_list(list: &str) -> Result<Surfaces, Error> {
|
pub fn parse_list(list: &str) -> Result<Surfaces, Error> {
|
||||||
@@ -96,9 +99,10 @@ impl Surfaces {
|
|||||||
"mail" => selected.mail = true,
|
"mail" => selected.mail = true,
|
||||||
"calendar" => selected.calendar = true,
|
"calendar" => selected.calendar = true,
|
||||||
"contacts" => selected.contacts = true,
|
"contacts" => selected.contacts = true,
|
||||||
|
"files" => selected.files = true,
|
||||||
_ => {
|
_ => {
|
||||||
return Err(Error::Usage(format!(
|
return Err(Error::Usage(format!(
|
||||||
"unknown surface: {token} (valid: mail, calendar, contacts)"
|
"unknown surface: {token} (valid: mail, calendar, contacts, files)"
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -158,7 +162,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn surfaces_default_is_all_three() {
|
fn surfaces_default_is_all_three() {
|
||||||
let all = Surfaces::default();
|
let all = Surfaces::default();
|
||||||
assert!(all.mail && all.calendar && all.contacts);
|
assert!(all.mail && all.calendar && all.contacts && all.files);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -168,7 +172,8 @@ mod tests {
|
|||||||
Surfaces {
|
Surfaces {
|
||||||
mail: true,
|
mail: true,
|
||||||
calendar: false,
|
calendar: false,
|
||||||
contacts: false
|
contacts: false,
|
||||||
|
files: false
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -176,7 +181,8 @@ mod tests {
|
|||||||
Surfaces {
|
Surfaces {
|
||||||
mail: false,
|
mail: false,
|
||||||
calendar: true,
|
calendar: true,
|
||||||
contacts: false
|
contacts: false,
|
||||||
|
files: false
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -184,7 +190,8 @@ mod tests {
|
|||||||
Surfaces {
|
Surfaces {
|
||||||
mail: false,
|
mail: false,
|
||||||
calendar: false,
|
calendar: false,
|
||||||
contacts: true
|
contacts: true,
|
||||||
|
files: false
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -192,7 +199,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn surfaces_parse_is_case_insensitive() {
|
fn surfaces_parse_is_case_insensitive() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
Surfaces::parse_list("Mail,CALENDAR,Contacts").unwrap(),
|
Surfaces::parse_list("Mail,CALENDAR,Contacts,Files").unwrap(),
|
||||||
Surfaces::ALL
|
Surfaces::ALL
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -204,7 +211,8 @@ mod tests {
|
|||||||
Surfaces {
|
Surfaces {
|
||||||
mail: true,
|
mail: true,
|
||||||
calendar: false,
|
calendar: false,
|
||||||
contacts: true
|
contacts: true,
|
||||||
|
files: false
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
pub mod calendar;
|
pub mod calendar;
|
||||||
pub mod contacts;
|
pub mod contacts;
|
||||||
pub mod coordinator;
|
pub mod coordinator;
|
||||||
|
pub mod files;
|
||||||
pub mod folders;
|
pub mod folders;
|
||||||
pub mod messages;
|
pub mod messages;
|
||||||
|
|
||||||
|
|||||||
@@ -57,7 +57,9 @@ pub fn reconcile_all(
|
|||||||
_ => {
|
_ => {
|
||||||
if let Some(id) = stub.get("id").and_then(Value::as_str) {
|
if let Some(id) = stub.get("id").and_then(Value::as_str) {
|
||||||
server_total.insert(id.to_owned());
|
server_total.insert(id.to_owned());
|
||||||
if !local.contains_key(id) && planned.insert(id.to_owned()) {
|
if local.contains_key(id) {
|
||||||
|
counts.fetched += 1;
|
||||||
|
} else if planned.insert(id.to_owned()) {
|
||||||
want_ids.push(id.to_owned());
|
want_ids.push(id.to_owned());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -79,9 +81,12 @@ pub fn reconcile_all(
|
|||||||
let fetched = fetch_events(ctx, &want_ids);
|
let fetched = fetch_events(ctx, &want_ids);
|
||||||
let mut masters: Vec<(String, ConvertedEvent)> = Vec::new();
|
let mut masters: Vec<(String, ConvertedEvent)> = Vec::new();
|
||||||
let mut exceptions: Vec<(String, ConvertedEvent)> = Vec::new();
|
let mut exceptions: Vec<(String, ConvertedEvent)> = Vec::new();
|
||||||
|
let mut any_series = false;
|
||||||
for (graph_id, result) in fetched {
|
for (graph_id, result) in fetched {
|
||||||
match result {
|
match result {
|
||||||
Ok(raw) => match convert_event(&raw, None) {
|
Ok(raw) => {
|
||||||
|
any_series |= is_series_master(&raw);
|
||||||
|
match convert_event(&raw, None) {
|
||||||
Ok(c) => match c.event_type {
|
Ok(c) => match c.event_type {
|
||||||
EventType::Exception => exceptions.push((graph_id, c)),
|
EventType::Exception => exceptions.push((graph_id, c)),
|
||||||
_ => masters.push((graph_id, c)),
|
_ => masters.push((graph_id, c)),
|
||||||
@@ -91,7 +96,8 @@ pub fn reconcile_all(
|
|||||||
ctx.logger
|
ctx.logger
|
||||||
.warn(&format!("graph event {graph_id} convert failed: {e}"));
|
.warn(&format!("graph event {graph_id} convert failed: {e}"));
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
|
}
|
||||||
Err(GraphError::Vanished) => counts.skipped += 1,
|
Err(GraphError::Vanished) => counts.skipped += 1,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
counts.failed += 1;
|
counts.failed += 1;
|
||||||
@@ -101,6 +107,10 @@ pub fn reconcile_all(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if any_series {
|
||||||
|
exceptions.extend(fetch_calendar_exceptions(ctx, &cal.graph_id, counts));
|
||||||
|
}
|
||||||
|
|
||||||
let mut master_by_graph_id: HashMap<String, ConvertedEvent> = masters.into_iter().collect();
|
let mut master_by_graph_id: HashMap<String, ConvertedEvent> = masters.into_iter().collect();
|
||||||
for (ex_graph_id, ex) in exceptions {
|
for (ex_graph_id, ex) in exceptions {
|
||||||
let Some(master_graph_id) = ex.series_master_id.clone() else {
|
let Some(master_graph_id) = ex.series_master_id.clone() else {
|
||||||
@@ -167,6 +177,73 @@ fn fetch_events(
|
|||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn is_series_master(raw: &Value) -> bool {
|
||||||
|
raw.get("recurrence").is_some_and(|r| !r.is_null())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn exception_windows(years: i32) -> Vec<(String, String)> {
|
||||||
|
let today = chrono::Utc::now().date_naive();
|
||||||
|
let span = chrono::Duration::days(
|
||||||
|
i64::from(years.max(1))
|
||||||
|
.saturating_mul(365)
|
||||||
|
.min(api::EXCEPTION_WINDOW_MAX_DAYS),
|
||||||
|
);
|
||||||
|
let fmt = |d: chrono::NaiveDate| d.format("%Y-%m-%dT00:00:00").to_string();
|
||||||
|
vec![
|
||||||
|
(fmt(today - span), fmt(today)),
|
||||||
|
(fmt(today), fmt(today + span)),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fetch_calendar_exceptions(
|
||||||
|
ctx: &GraphCoordinator<'_>,
|
||||||
|
calendar_id: &str,
|
||||||
|
counts: &mut TypeCounts,
|
||||||
|
) -> Vec<(String, ConvertedEvent)> {
|
||||||
|
let windows = exception_windows(ctx.exception_window_years);
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||||
|
for (from, to) in windows {
|
||||||
|
let url = ctx
|
||||||
|
.endpoints
|
||||||
|
.calendar_exceptions(calendar_id, &from, &to, ctx.top);
|
||||||
|
match api::collect_all_values(ctx.client, &url, &[PREFER_TIMEZONE_UTC]) {
|
||||||
|
Ok(values) => {
|
||||||
|
if ctx.logger.enabled(LEVEL_PROGRESS) {
|
||||||
|
eprintln!(
|
||||||
|
"graph calendar {calendar_id} exceptions in {from}..{to}: {}",
|
||||||
|
values.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for raw in values {
|
||||||
|
let Some(ex_id) = raw.get("id").and_then(Value::as_str).map(str::to_owned)
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if !seen.insert(ex_id.clone()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match convert_event(&raw, None) {
|
||||||
|
Ok(c) => out.push((ex_id, c)),
|
||||||
|
Err(e) => {
|
||||||
|
counts.failed += 1;
|
||||||
|
ctx.logger
|
||||||
|
.warn(&format!("graph exception {ex_id} convert failed: {e}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
counts.failed += 1;
|
||||||
|
ctx.logger.warn(&format!(
|
||||||
|
"graph calendar {calendar_id} exception lookup {from}..{to} failed: {e}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
fn merge_exception_into(master: &mut ConvertedEvent, ex: &ConvertedEvent) {
|
fn merge_exception_into(master: &mut ConvertedEvent, ex: &ConvertedEvent) {
|
||||||
let Some(raw_key) = ex.original_start.as_deref() else {
|
let Some(raw_key) = ex.original_start.as_deref() else {
|
||||||
return;
|
return;
|
||||||
@@ -407,6 +484,48 @@ fn delete_vanished(
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn exception_windows_never_exceed_the_graph_limit() {
|
||||||
|
for requested in [1, 5, 50] {
|
||||||
|
let windows = exception_windows(requested);
|
||||||
|
assert_eq!(windows.len(), 2, "one window behind today, one ahead");
|
||||||
|
for (from, to) in windows {
|
||||||
|
let start = chrono::NaiveDate::parse_from_str(&from[..10], "%Y-%m-%d").unwrap();
|
||||||
|
let end = chrono::NaiveDate::parse_from_str(&to[..10], "%Y-%m-%d").unwrap();
|
||||||
|
let days = (end - start).num_days();
|
||||||
|
assert!(days > 0, "window runs forwards: {from}..{to}");
|
||||||
|
assert!(
|
||||||
|
days <= api::EXCEPTION_WINDOW_MAX_DAYS,
|
||||||
|
"requested {requested}y produced {days} days, over Graph's cap"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cancelled_occurrence_keys_drop_the_oid_prefix() {
|
||||||
|
let raw = serde_json::json!({
|
||||||
|
"id": "M",
|
||||||
|
"start": {"dateTime": "2026-09-21T11:00:00.0000000", "timeZone": "UTC"},
|
||||||
|
"end": {"dateTime": "2026-09-21T11:30:00.0000000", "timeZone": "UTC"},
|
||||||
|
"recurrence": {
|
||||||
|
"pattern": {"type": "daily", "interval": 1},
|
||||||
|
"range": {"type": "numbered", "startDate": "2026-09-21",
|
||||||
|
"numberOfOccurrences": 6}
|
||||||
|
},
|
||||||
|
"cancelledOccurrences": ["OID.AAMkAGI2TGuLAAA=.2026-09-24"]
|
||||||
|
});
|
||||||
|
let converted = convert_event(&raw, None).unwrap();
|
||||||
|
let overrides = converted.data["recurrenceOverrides"].as_object().unwrap();
|
||||||
|
assert!(
|
||||||
|
overrides.contains_key("2026-09-24T11:00:00"),
|
||||||
|
"beta cancelledOccurrences are OID.<id>.<date>; the key must be a LocalDateTime, \
|
||||||
|
got {:?}",
|
||||||
|
overrides.keys().collect::<Vec<_>>()
|
||||||
|
);
|
||||||
|
assert_eq!(overrides["2026-09-24T11:00:00"]["excluded"], true);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn override_key_utc_converts_to_master_local_time() {
|
fn override_key_utc_converts_to_master_local_time() {
|
||||||
let local = normalise_override_key("2026-05-11T15:00:00Z", Some("America/New_York"));
|
let local = normalise_override_key("2026-05-11T15:00:00Z", Some("America/New_York"));
|
||||||
|
|||||||
@@ -59,10 +59,14 @@ pub fn reconcile_all(
|
|||||||
for id in &ids {
|
for id in &ids {
|
||||||
server_total.insert(id.clone());
|
server_total.insert(id.clone());
|
||||||
}
|
}
|
||||||
let new_ids: Vec<String> = ids
|
let mut new_ids: Vec<String> = Vec::new();
|
||||||
.into_iter()
|
for id in ids {
|
||||||
.filter(|id| !local.contains_key(id) && planned.insert(id.clone()))
|
if local.contains_key(&id) {
|
||||||
.collect();
|
counts.fetched += 1;
|
||||||
|
} else if planned.insert(id.clone()) {
|
||||||
|
new_ids.push(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
if new_ids.is_empty() {
|
if new_ids.is_empty() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ pub struct GraphImportConfig {
|
|||||||
pub event_body_format: EventBodyFormat,
|
pub event_body_format: EventBodyFormat,
|
||||||
pub graph_connections: usize,
|
pub graph_connections: usize,
|
||||||
pub top: usize,
|
pub top: usize,
|
||||||
|
pub exception_window_years: i32,
|
||||||
pub allow_source_change: bool,
|
pub allow_source_change: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,6 +56,7 @@ pub struct GraphCoordinator<'a> {
|
|||||||
pub workers: usize,
|
pub workers: usize,
|
||||||
pub logger: crate::logging::Logger,
|
pub logger: crate::logging::Logger,
|
||||||
pub event_body_format: EventBodyFormat,
|
pub event_body_format: EventBodyFormat,
|
||||||
|
pub exception_window_years: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn run(common: CommonConfig, config: GraphImportConfig) -> Result<Summary, Error> {
|
pub fn run(common: CommonConfig, config: GraphImportConfig) -> Result<Summary, Error> {
|
||||||
@@ -129,6 +131,7 @@ pub fn run(common: CommonConfig, config: GraphImportConfig) -> Result<Summary, E
|
|||||||
let mut event_counts = TypeCounts::default();
|
let mut event_counts = TypeCounts::default();
|
||||||
let mut addressbook_counts = TypeCounts::default();
|
let mut addressbook_counts = TypeCounts::default();
|
||||||
let mut contact_counts = TypeCounts::default();
|
let mut contact_counts = TypeCounts::default();
|
||||||
|
let mut file_counts = TypeCounts::default();
|
||||||
|
|
||||||
let ctx = GraphCoordinator {
|
let ctx = GraphCoordinator {
|
||||||
client: &client,
|
client: &client,
|
||||||
@@ -138,12 +141,14 @@ pub fn run(common: CommonConfig, config: GraphImportConfig) -> Result<Summary, E
|
|||||||
workers: config.graph_connections.clamp(1, 16),
|
workers: config.graph_connections.clamp(1, 16),
|
||||||
logger,
|
logger,
|
||||||
event_body_format: config.event_body_format,
|
event_body_format: config.event_body_format,
|
||||||
|
exception_window_years: config.exception_window_years,
|
||||||
};
|
};
|
||||||
|
|
||||||
let primary = !matches!(config.mailbox_kind, MailboxKind::Archive);
|
let primary = !matches!(config.mailbox_kind, MailboxKind::Archive);
|
||||||
let want_mail = config.surfaces.mail;
|
let want_mail = config.surfaces.mail;
|
||||||
let want_calendar = config.surfaces.calendar && primary;
|
let want_calendar = config.surfaces.calendar && primary;
|
||||||
let want_contacts = config.surfaces.contacts && primary;
|
let want_contacts = config.surfaces.contacts && primary;
|
||||||
|
let want_files = config.surfaces.files && primary;
|
||||||
|
|
||||||
if want_mail {
|
if want_mail {
|
||||||
let folders = super::folders::reconcile_mail(
|
let folders = super::folders::reconcile_mail(
|
||||||
@@ -172,12 +177,17 @@ pub fn run(common: CommonConfig, config: GraphImportConfig) -> Result<Summary, E
|
|||||||
super::contacts::reconcile_all(&mut conn, &ctx, &books, &mut contact_counts)?;
|
super::contacts::reconcile_all(&mut conn, &ctx, &books, &mut contact_counts)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if want_files {
|
||||||
|
super::files::reconcile_all(&mut conn, &ctx, &mut file_counts)?;
|
||||||
|
}
|
||||||
|
|
||||||
summary.per_type.push(("mailbox", mailbox_counts));
|
summary.per_type.push(("mailbox", mailbox_counts));
|
||||||
summary.per_type.push(("email", email_counts));
|
summary.per_type.push(("email", email_counts));
|
||||||
summary.per_type.push(("calendar", calendar_counts));
|
summary.per_type.push(("calendar", calendar_counts));
|
||||||
summary.per_type.push(("calendarevent", event_counts));
|
summary.per_type.push(("calendarevent", event_counts));
|
||||||
summary.per_type.push(("addressbook", addressbook_counts));
|
summary.per_type.push(("addressbook", addressbook_counts));
|
||||||
summary.per_type.push(("contactcard", contact_counts));
|
summary.per_type.push(("contactcard", contact_counts));
|
||||||
|
summary.per_type.push(("filenode", file_counts));
|
||||||
|
|
||||||
if !summary.any_failed()
|
if !summary.any_failed()
|
||||||
&& let Err(e) = run_gc(&conn)
|
&& let Err(e) = run_gc(&conn)
|
||||||
@@ -240,25 +250,27 @@ pub fn enumerate_mail_folders(
|
|||||||
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
|
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||||
let initial = endpoints.mail_folders_root(mailbox_kind, top);
|
let initial = endpoints.mail_folders_root(mailbox_kind, top);
|
||||||
let level = crate::exchange_graph::api::collect_all_values(client, &initial, &[])?;
|
let level = crate::exchange_graph::api::collect_all_values(client, &initial, &[])?;
|
||||||
for f in &level {
|
for folder in level {
|
||||||
if let Some(id) = f.get("id").and_then(Value::as_str)
|
let Some(id) = folder.get("id").and_then(Value::as_str) else {
|
||||||
&& seen.insert(id.to_owned())
|
continue;
|
||||||
{
|
};
|
||||||
|
if seen.insert(id.to_owned()) {
|
||||||
frontier.push(id.to_owned());
|
frontier.push(id.to_owned());
|
||||||
|
all.push(folder);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
all.extend(level);
|
|
||||||
while let Some(parent) = frontier.pop() {
|
while let Some(parent) = frontier.pop() {
|
||||||
let url = endpoints.mail_folder_child_folders(&parent, top);
|
let url = endpoints.mail_folder_child_folders(&parent, top);
|
||||||
let children = crate::exchange_graph::api::collect_all_values(client, &url, &[])?;
|
let children = crate::exchange_graph::api::collect_all_values(client, &url, &[])?;
|
||||||
for c in &children {
|
for child in children {
|
||||||
if let Some(id) = c.get("id").and_then(Value::as_str)
|
let Some(id) = child.get("id").and_then(Value::as_str) else {
|
||||||
&& seen.insert(id.to_owned())
|
continue;
|
||||||
{
|
};
|
||||||
|
if seen.insert(id.to_owned()) {
|
||||||
frontier.push(id.to_owned());
|
frontier.push(id.to_owned());
|
||||||
|
all.push(child);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
all.extend(children);
|
|
||||||
}
|
}
|
||||||
Ok(all)
|
Ok(all)
|
||||||
}
|
}
|
||||||
@@ -490,6 +502,7 @@ mod tests {
|
|||||||
event_body_format: EventBodyFormat::Text,
|
event_body_format: EventBodyFormat::Text,
|
||||||
graph_connections: 4,
|
graph_connections: 4,
|
||||||
top: 100,
|
top: 100,
|
||||||
|
exception_window_years: 5,
|
||||||
allow_source_change: false,
|
allow_source_change: false,
|
||||||
};
|
};
|
||||||
let url = canonical_session_url(&config);
|
let url = canonical_session_url(&config);
|
||||||
|
|||||||
@@ -0,0 +1,438 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||||
|
*/
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use rusqlite::{Connection, Transaction, params};
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
use crate::db::{blobs, exchange_graph_ids};
|
||||||
|
use crate::error::Error;
|
||||||
|
use crate::exchange_graph::api;
|
||||||
|
use crate::exchange_graph::client::Accept;
|
||||||
|
use crate::exchange_graph::error::GraphError;
|
||||||
|
use crate::logging::LEVEL_PROGRESS;
|
||||||
|
use crate::sync::TypeCounts;
|
||||||
|
use crate::sync::import_jmap::pool::Pool;
|
||||||
|
|
||||||
|
use super::coordinator::{CHUNK_SIZE, GraphCoordinator};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct DriveNode {
|
||||||
|
graph_id: String,
|
||||||
|
name: String,
|
||||||
|
is_folder: bool,
|
||||||
|
media_type: Option<String>,
|
||||||
|
created: String,
|
||||||
|
modified: Option<String>,
|
||||||
|
parent_graph_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn reconcile_all(
|
||||||
|
conn: &mut Connection,
|
||||||
|
ctx: &GraphCoordinator<'_>,
|
||||||
|
counts: &mut TypeCounts,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
let Some(root_id) = drive_root_id(ctx) else {
|
||||||
|
ctx.logger
|
||||||
|
.warn("graph drive unavailable for this account; file import skipped");
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
let nodes = match enumerate_drive(ctx, &root_id) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(e) => {
|
||||||
|
counts.failed += 1;
|
||||||
|
ctx.logger
|
||||||
|
.warn(&format!("graph drive enumeration failed: {e}"));
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if ctx.logger.enabled(LEVEL_PROGRESS) {
|
||||||
|
eprintln!("graph drive items enumerated: {}", nodes.len());
|
||||||
|
}
|
||||||
|
|
||||||
|
let local: HashMap<String, i64> =
|
||||||
|
exchange_graph_ids::ids_of_type(conn, ctx.source_id, exchange_graph_ids::FILE_NODE)?;
|
||||||
|
let mut by_graph_id: HashMap<String, i64> = HashMap::new();
|
||||||
|
let mut server_ids: Vec<String> = Vec::new();
|
||||||
|
|
||||||
|
let (folders, files): (Vec<DriveNode>, Vec<DriveNode>) =
|
||||||
|
nodes.into_iter().partition(|n| n.is_folder);
|
||||||
|
|
||||||
|
for node in order_by_depth(folders) {
|
||||||
|
server_ids.push(node.graph_id.clone());
|
||||||
|
let parent_local = node
|
||||||
|
.parent_graph_id
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|p| by_graph_id.get(p))
|
||||||
|
.copied();
|
||||||
|
let local_id = upsert_directory(conn, ctx, &node, parent_local, &local, counts)?;
|
||||||
|
by_graph_id.insert(node.graph_id.clone(), local_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut wanted: Vec<DriveNode> = Vec::new();
|
||||||
|
for node in files {
|
||||||
|
server_ids.push(node.graph_id.clone());
|
||||||
|
if local.contains_key(&node.graph_id) {
|
||||||
|
counts.fetched += 1;
|
||||||
|
} else {
|
||||||
|
wanted.push(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
download_and_insert(conn, ctx, &wanted, &by_graph_id, counts)?;
|
||||||
|
delete_vanished(conn, ctx.source_id, &local, &server_ids, counts, ctx)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn drive_root_id(ctx: &GraphCoordinator<'_>) -> Option<String> {
|
||||||
|
let value = ctx.client.get_json(&ctx.endpoints.drive_root()).ok()?;
|
||||||
|
value.get("id").and_then(Value::as_str).map(str::to_owned)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn enumerate_drive(
|
||||||
|
ctx: &GraphCoordinator<'_>,
|
||||||
|
root_id: &str,
|
||||||
|
) -> Result<Vec<DriveNode>, GraphError> {
|
||||||
|
let mut out: Vec<DriveNode> = Vec::new();
|
||||||
|
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||||
|
let mut frontier: Vec<String> = vec![root_id.to_owned()];
|
||||||
|
seen.insert(root_id.to_owned());
|
||||||
|
|
||||||
|
while let Some(parent) = frontier.pop() {
|
||||||
|
let url = ctx.endpoints.drive_children(&parent, ctx.top);
|
||||||
|
let children = api::collect_all_values(ctx.client, &url, &[])?;
|
||||||
|
for child in &children {
|
||||||
|
let Some(node) = parse_node(child, &parent) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if !seen.insert(node.graph_id.clone()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if node.is_folder {
|
||||||
|
frontier.push(node.graph_id.clone());
|
||||||
|
}
|
||||||
|
out.push(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_node(value: &Value, parent_graph_id: &str) -> Option<DriveNode> {
|
||||||
|
let graph_id = value.get("id").and_then(Value::as_str)?.to_owned();
|
||||||
|
let name = value.get("name").and_then(Value::as_str)?.to_owned();
|
||||||
|
let is_folder = value.get("folder").is_some_and(|f| !f.is_null());
|
||||||
|
let is_file = value.get("file").is_some_and(|f| !f.is_null());
|
||||||
|
if !is_folder && !is_file {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(DriveNode {
|
||||||
|
graph_id,
|
||||||
|
name,
|
||||||
|
is_folder,
|
||||||
|
media_type: value
|
||||||
|
.get("file")
|
||||||
|
.and_then(|f| f.get("mimeType"))
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(str::to_owned),
|
||||||
|
created: value
|
||||||
|
.get("createdDateTime")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("1970-01-01T00:00:00Z")
|
||||||
|
.to_owned(),
|
||||||
|
modified: value
|
||||||
|
.get("lastModifiedDateTime")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(str::to_owned),
|
||||||
|
parent_graph_id: Some(parent_graph_id.to_owned()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn order_by_depth(folders: Vec<DriveNode>) -> Vec<DriveNode> {
|
||||||
|
let parents: HashMap<String, Option<String>> = folders
|
||||||
|
.iter()
|
||||||
|
.map(|n| (n.graph_id.clone(), n.parent_graph_id.clone()))
|
||||||
|
.collect();
|
||||||
|
let depth = |start: &str| -> usize {
|
||||||
|
let mut d = 0;
|
||||||
|
let mut id = start.to_owned();
|
||||||
|
let mut guard = 0;
|
||||||
|
while let Some(Some(parent)) = parents.get(&id) {
|
||||||
|
d += 1;
|
||||||
|
id = parent.clone();
|
||||||
|
guard += 1;
|
||||||
|
if guard > 256 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
d
|
||||||
|
};
|
||||||
|
let mut ordered: Vec<(usize, DriveNode)> = folders
|
||||||
|
.iter()
|
||||||
|
.map(|n| (depth(n.graph_id.as_str()), n.clone()))
|
||||||
|
.collect();
|
||||||
|
ordered.sort_by_key(|(d, _)| *d);
|
||||||
|
ordered.into_iter().map(|(_, n)| n).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn upsert_directory(
|
||||||
|
conn: &mut Connection,
|
||||||
|
ctx: &GraphCoordinator<'_>,
|
||||||
|
node: &DriveNode,
|
||||||
|
parent_local: Option<i64>,
|
||||||
|
local: &HashMap<String, i64>,
|
||||||
|
counts: &mut TypeCounts,
|
||||||
|
) -> Result<i64, Error> {
|
||||||
|
let tx = conn.unchecked_transaction()?;
|
||||||
|
let id = if let Some(existing) = local.get(&node.graph_id).copied() {
|
||||||
|
tx.execute(
|
||||||
|
"UPDATE file_nodes SET parent_id = ?1, name = ?2, modified = ?3 WHERE id = ?4",
|
||||||
|
params![parent_local, node.name, node.modified, existing],
|
||||||
|
)?;
|
||||||
|
counts.fetched += 1;
|
||||||
|
existing
|
||||||
|
} else {
|
||||||
|
tx.execute(
|
||||||
|
"INSERT INTO file_nodes (parent_id, node_type, blob_id, target, name, media_type,
|
||||||
|
created, modified, is_subscribed, role)
|
||||||
|
VALUES (?1, 'directory', NULL, NULL, ?2, NULL, ?3, ?4, 1, NULL)",
|
||||||
|
params![parent_local, node.name, node.created, node.modified],
|
||||||
|
)?;
|
||||||
|
let new_id = tx.last_insert_rowid();
|
||||||
|
exchange_graph_ids::insert(
|
||||||
|
&tx,
|
||||||
|
ctx.source_id,
|
||||||
|
exchange_graph_ids::FILE_NODE,
|
||||||
|
&node.graph_id,
|
||||||
|
new_id,
|
||||||
|
)?;
|
||||||
|
counts.created += 1;
|
||||||
|
new_id
|
||||||
|
};
|
||||||
|
tx.commit()?;
|
||||||
|
Ok(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn download_and_insert(
|
||||||
|
conn: &mut Connection,
|
||||||
|
ctx: &GraphCoordinator<'_>,
|
||||||
|
nodes: &[DriveNode],
|
||||||
|
by_graph_id: &HashMap<String, i64>,
|
||||||
|
counts: &mut TypeCounts,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
if nodes.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let node_by_id: HashMap<&str, &DriveNode> =
|
||||||
|
nodes.iter().map(|n| (n.graph_id.as_str(), n)).collect();
|
||||||
|
let client = ctx.client.clone();
|
||||||
|
let endpoints: crate::exchange_graph::api::Endpoints = (*ctx.endpoints).clone();
|
||||||
|
type FetchResult = (String, Result<Vec<u8>, GraphError>);
|
||||||
|
let pool: Pool<String, FetchResult> = Pool::new(ctx.workers, move |id: String| {
|
||||||
|
let url = endpoints.drive_item_content(&id);
|
||||||
|
match client.get_with_prefer(&url, Accept::Binary, &[]) {
|
||||||
|
Ok(resp) => (id, Ok(resp.body)),
|
||||||
|
Err(e) => (id, Err(e)),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
for node in nodes {
|
||||||
|
pool.submit(node.graph_id.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut tx_opt: Option<Transaction<'_>> = None;
|
||||||
|
let mut in_batch = 0usize;
|
||||||
|
for _ in 0..nodes.len() {
|
||||||
|
let Ok((graph_id, result)) = pool.results().recv() else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
let Some(node) = node_by_id.get(graph_id.as_str()).copied() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
match result {
|
||||||
|
Ok(bytes) => {
|
||||||
|
if tx_opt.is_none() {
|
||||||
|
tx_opt = Some(conn.unchecked_transaction()?);
|
||||||
|
}
|
||||||
|
let tx = tx_opt.as_mut().expect("tx is Some");
|
||||||
|
let parent_local = node
|
||||||
|
.parent_graph_id
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|p| by_graph_id.get(p))
|
||||||
|
.copied();
|
||||||
|
insert_file_in_tx(tx, ctx, node, parent_local, &bytes, counts)?;
|
||||||
|
in_batch += 1;
|
||||||
|
if in_batch >= CHUNK_SIZE {
|
||||||
|
if let Some(t) = tx_opt.take() {
|
||||||
|
t.commit()?;
|
||||||
|
}
|
||||||
|
in_batch = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(GraphError::Vanished) => counts.skipped += 1,
|
||||||
|
Err(e) => {
|
||||||
|
counts.failed += 1;
|
||||||
|
ctx.logger
|
||||||
|
.warn(&format!("graph drive item {graph_id} download failed: {e}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(t) = tx_opt.take() {
|
||||||
|
t.commit()?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn insert_file_in_tx(
|
||||||
|
tx: &Transaction<'_>,
|
||||||
|
ctx: &GraphCoordinator<'_>,
|
||||||
|
node: &DriveNode,
|
||||||
|
parent_local: Option<i64>,
|
||||||
|
bytes: &[u8],
|
||||||
|
counts: &mut TypeCounts,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
let blob_id = blobs::intern_blob(tx, bytes)?;
|
||||||
|
tx.execute(
|
||||||
|
"INSERT INTO file_nodes (parent_id, node_type, blob_id, target, name, media_type,
|
||||||
|
created, modified, is_subscribed, role)
|
||||||
|
VALUES (?1, 'file', ?2, NULL, ?3, ?4, ?5, ?6, 1, NULL)",
|
||||||
|
params![
|
||||||
|
parent_local,
|
||||||
|
blob_id,
|
||||||
|
node.name,
|
||||||
|
node.media_type,
|
||||||
|
node.created,
|
||||||
|
node.modified,
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
let new_id = tx.last_insert_rowid();
|
||||||
|
exchange_graph_ids::insert(
|
||||||
|
tx,
|
||||||
|
ctx.source_id,
|
||||||
|
exchange_graph_ids::FILE_NODE,
|
||||||
|
&node.graph_id,
|
||||||
|
new_id,
|
||||||
|
)?;
|
||||||
|
counts.created += 1;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delete_vanished(
|
||||||
|
conn: &mut Connection,
|
||||||
|
source_id: i64,
|
||||||
|
local: &HashMap<String, i64>,
|
||||||
|
server_ids: &[String],
|
||||||
|
counts: &mut TypeCounts,
|
||||||
|
ctx: &GraphCoordinator<'_>,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
let server: std::collections::HashSet<&str> = server_ids.iter().map(String::as_str).collect();
|
||||||
|
let mut vanished: Vec<(&String, &i64)> = local
|
||||||
|
.iter()
|
||||||
|
.filter(|(graph_id, _)| !server.contains(graph_id.as_str()))
|
||||||
|
.collect();
|
||||||
|
let depths = node_depths(conn)?;
|
||||||
|
vanished.sort_by_key(|(_, local_id)| std::cmp::Reverse(*depths.get(*local_id).unwrap_or(&0)));
|
||||||
|
|
||||||
|
for (graph_id, local_id) in vanished {
|
||||||
|
let tx = conn.unchecked_transaction()?;
|
||||||
|
match tx.execute("DELETE FROM file_nodes WHERE id = ?1", params![local_id]) {
|
||||||
|
Ok(_) => {
|
||||||
|
exchange_graph_ids::delete(
|
||||||
|
&tx,
|
||||||
|
source_id,
|
||||||
|
exchange_graph_ids::FILE_NODE,
|
||||||
|
graph_id,
|
||||||
|
)?;
|
||||||
|
tx.commit()?;
|
||||||
|
counts.deleted += 1;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let _ = tx.rollback();
|
||||||
|
ctx.logger.warn(&format!(
|
||||||
|
"file node {graph_id} (local id {local_id}) could not be deleted: {e}"
|
||||||
|
));
|
||||||
|
counts.failed += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn node_depths(conn: &Connection) -> Result<HashMap<i64, usize>, Error> {
|
||||||
|
let mut parents: HashMap<i64, Option<i64>> = HashMap::new();
|
||||||
|
{
|
||||||
|
let mut stmt = conn.prepare("SELECT id, parent_id FROM file_nodes")?;
|
||||||
|
let mut rows = stmt.query([])?;
|
||||||
|
while let Some(row) = rows.next()? {
|
||||||
|
parents.insert(row.get(0)?, row.get(1)?);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut depths = HashMap::new();
|
||||||
|
for id in parents.keys().copied() {
|
||||||
|
let mut d = 0;
|
||||||
|
let mut cursor = id;
|
||||||
|
let mut seen: std::collections::HashSet<i64> = std::collections::HashSet::new();
|
||||||
|
while seen.insert(cursor) {
|
||||||
|
match parents.get(&cursor).copied().flatten() {
|
||||||
|
Some(p) => {
|
||||||
|
d += 1;
|
||||||
|
cursor = p;
|
||||||
|
}
|
||||||
|
None => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
depths.insert(id, d);
|
||||||
|
}
|
||||||
|
Ok(depths)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_node_rejects_items_without_file_or_folder_facet() {
|
||||||
|
let vault = json!({"id": "V", "name": "Personal Vault", "remoteItem": {}});
|
||||||
|
assert!(parse_node(&vault, "root").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_node_reads_folder_and_file_facets() {
|
||||||
|
let folder = json!({"id": "F", "name": "Docs", "folder": {"childCount": 2}});
|
||||||
|
let parsed = parse_node(&folder, "root").expect("folder parses");
|
||||||
|
assert!(parsed.is_folder);
|
||||||
|
|
||||||
|
let file = json!({
|
||||||
|
"id": "X", "name": "a.txt", "size": 12,
|
||||||
|
"file": {"mimeType": "text/plain"},
|
||||||
|
"createdDateTime": "2026-01-01T00:00:00Z"
|
||||||
|
});
|
||||||
|
let parsed = parse_node(&file, "root").expect("file parses");
|
||||||
|
assert!(!parsed.is_folder);
|
||||||
|
assert_eq!(parsed.media_type.as_deref(), Some("text/plain"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn order_by_depth_puts_parents_before_children() {
|
||||||
|
let node = |id: &str, parent: Option<&str>| DriveNode {
|
||||||
|
graph_id: id.to_owned(),
|
||||||
|
name: id.to_owned(),
|
||||||
|
is_folder: true,
|
||||||
|
media_type: None,
|
||||||
|
created: "2026-01-01T00:00:00Z".to_owned(),
|
||||||
|
modified: None,
|
||||||
|
parent_graph_id: parent.map(str::to_owned),
|
||||||
|
};
|
||||||
|
let ordered = order_by_depth(vec![
|
||||||
|
node("C", Some("B")),
|
||||||
|
node("B", Some("A")),
|
||||||
|
node("A", None),
|
||||||
|
]);
|
||||||
|
let names: Vec<&str> = ordered.iter().map(|n| n.graph_id.as_str()).collect();
|
||||||
|
assert_eq!(names, ["A", "B", "C"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ use crate::exchange_graph::calendar_map::{graph_calendar_color_to_hex, windows_o
|
|||||||
use crate::exchange_graph::types::MailboxKind;
|
use crate::exchange_graph::types::MailboxKind;
|
||||||
use crate::logging::LEVEL_PROGRESS;
|
use crate::logging::LEVEL_PROGRESS;
|
||||||
use crate::sync::TypeCounts;
|
use crate::sync::TypeCounts;
|
||||||
|
use crate::types::ObjectType;
|
||||||
|
|
||||||
use super::coordinator::{CHUNK_SIZE, GraphCoordinator, enumerate_mail_folders};
|
use super::coordinator::{CHUNK_SIZE, GraphCoordinator, enumerate_mail_folders};
|
||||||
|
|
||||||
@@ -242,18 +243,28 @@ pub fn reconcile_address_books(
|
|||||||
.filter_map(|f| f.get("id").and_then(Value::as_str))
|
.filter_map(|f| f.get("id").and_then(Value::as_str))
|
||||||
.map(str::to_owned)
|
.map(str::to_owned)
|
||||||
.collect();
|
.collect();
|
||||||
|
let mut default_id: Option<String> = None;
|
||||||
|
if let Some(default) = default_contact_folder(ctx)
|
||||||
|
&& let Some(id) = default.get("id").and_then(Value::as_str)
|
||||||
|
{
|
||||||
|
default_id = Some(id.to_owned());
|
||||||
|
if seen.insert(id.to_owned()) {
|
||||||
|
server.insert(0, default);
|
||||||
|
}
|
||||||
|
}
|
||||||
let mut frontier: Vec<String> = seen.iter().cloned().collect();
|
let mut frontier: Vec<String> = seen.iter().cloned().collect();
|
||||||
while let Some(parent) = frontier.pop() {
|
while let Some(parent) = frontier.pop() {
|
||||||
let url = ctx.endpoints.contact_folder_children(&parent, ctx.top);
|
let url = ctx.endpoints.contact_folder_children(&parent, ctx.top);
|
||||||
let children = api::collect_all_values(ctx.client, &url, &[]).map_err(Error::from)?;
|
let children = api::collect_all_values(ctx.client, &url, &[]).map_err(Error::from)?;
|
||||||
for c in &children {
|
for child in children {
|
||||||
if let Some(id) = c.get("id").and_then(Value::as_str)
|
let Some(id) = child.get("id").and_then(Value::as_str) else {
|
||||||
&& seen.insert(id.to_owned())
|
continue;
|
||||||
{
|
};
|
||||||
|
if seen.insert(id.to_owned()) {
|
||||||
frontier.push(id.to_owned());
|
frontier.push(id.to_owned());
|
||||||
|
server.push(child);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
server.extend(children);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let local: HashMap<String, i64> =
|
let local: HashMap<String, i64> =
|
||||||
@@ -272,19 +283,32 @@ pub fn reconcile_address_books(
|
|||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
.unwrap_or("Contacts")
|
.unwrap_or("Contacts")
|
||||||
.to_owned();
|
.to_owned();
|
||||||
|
let is_default = default_id.as_deref() == Some(graph_id);
|
||||||
let existing = local.get(graph_id).copied();
|
let existing = local.get(graph_id).copied();
|
||||||
let local_id = if let Some(id) = existing {
|
let local_id = if let Some(id) = existing {
|
||||||
|
let is_default = crate::db::defaults::unique_default(
|
||||||
|
&tx,
|
||||||
|
ObjectType::AddressBook,
|
||||||
|
is_default,
|
||||||
|
Some(id),
|
||||||
|
)?;
|
||||||
tx.execute(
|
tx.execute(
|
||||||
"UPDATE address_books SET name = ?1 WHERE id = ?2",
|
"UPDATE address_books SET name = ?1, is_default = ?2 WHERE id = ?3",
|
||||||
params![name, id],
|
params![name, is_default as i64, id],
|
||||||
)?;
|
)?;
|
||||||
counts.fetched += 1;
|
counts.fetched += 1;
|
||||||
id
|
id
|
||||||
} else {
|
} else {
|
||||||
|
let is_default = crate::db::defaults::unique_default(
|
||||||
|
&tx,
|
||||||
|
ObjectType::AddressBook,
|
||||||
|
is_default,
|
||||||
|
None,
|
||||||
|
)?;
|
||||||
tx.execute(
|
tx.execute(
|
||||||
"INSERT INTO address_books (name, sort_order, is_subscribed)
|
"INSERT INTO address_books (name, sort_order, is_default, is_subscribed)
|
||||||
VALUES (?1, 0, 1)",
|
VALUES (?1, 0, ?2, 1)",
|
||||||
params![name],
|
params![name, is_default as i64],
|
||||||
)?;
|
)?;
|
||||||
let new_id = tx.last_insert_rowid();
|
let new_id = tx.last_insert_rowid();
|
||||||
exchange_graph_ids::insert(
|
exchange_graph_ids::insert(
|
||||||
@@ -317,6 +341,30 @@ pub fn reconcile_address_books(
|
|||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_contact_folder(ctx: &GraphCoordinator<'_>) -> Option<Value> {
|
||||||
|
match ctx.client.get_json(&ctx.endpoints.default_contact_folder()) {
|
||||||
|
Ok(value) if value.get("id").and_then(Value::as_str).is_some() => return Some(value),
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(e) => ctx.logger.warn(&format!(
|
||||||
|
"graph default contact folder lookup failed ({e}); \
|
||||||
|
falling back to deriving it from an existing contact"
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
let probe = ctx
|
||||||
|
.client
|
||||||
|
.get_json(&ctx.endpoints.any_contact_parent())
|
||||||
|
.ok()?;
|
||||||
|
let parent = probe
|
||||||
|
.get("value")
|
||||||
|
.and_then(Value::as_array)?
|
||||||
|
.first()?
|
||||||
|
.get("parentFolderId")
|
||||||
|
.and_then(Value::as_str)?;
|
||||||
|
ctx.client
|
||||||
|
.get_json(&ctx.endpoints.contact_folder(parent))
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
fn mailbox_timezone(ctx: &GraphCoordinator<'_>) -> Option<String> {
|
fn mailbox_timezone(ctx: &GraphCoordinator<'_>) -> Option<String> {
|
||||||
let url = ctx.endpoints.mailbox_settings_timezone();
|
let url = ctx.endpoints.mailbox_settings_timezone();
|
||||||
let value = ctx.client.get_json(&url).ok()?;
|
let value = ctx.client.get_json(&url).ok()?;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
use rusqlite::{Connection, Transaction, params};
|
use rusqlite::{Connection, Transaction, params};
|
||||||
use serde_json::json;
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
use crate::db::{blobs, exchange_graph_ids};
|
use crate::db::{blobs, exchange_graph_ids};
|
||||||
use crate::error::Error;
|
use crate::error::Error;
|
||||||
@@ -35,7 +35,7 @@ pub fn reconcile_all(
|
|||||||
|
|
||||||
for folder in folders {
|
for folder in folders {
|
||||||
let url = ctx.endpoints.folder_messages_ids(&folder.graph_id, ctx.top);
|
let url = ctx.endpoints.folder_messages_ids(&folder.graph_id, ctx.top);
|
||||||
let ids = match api::collect_all_ids(ctx.client, &url, &[]) {
|
let stubs = match api::collect_all_values(ctx.client, &url, &[]) {
|
||||||
Ok(v) => v,
|
Ok(v) => v,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
ctx.logger.warn(&format!(
|
ctx.logger.warn(&format!(
|
||||||
@@ -51,16 +51,21 @@ pub fn reconcile_all(
|
|||||||
eprintln!(
|
eprintln!(
|
||||||
"graph folder {} enumerated {} messages",
|
"graph folder {} enumerated {} messages",
|
||||||
folder.graph_id,
|
folder.graph_id,
|
||||||
ids.len()
|
stubs.len()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
for id in &ids {
|
let mut new_ids: Vec<(String, String)> = Vec::new();
|
||||||
server_total.insert(id.clone());
|
for stub in &stubs {
|
||||||
|
let Some(id) = stub.get("id").and_then(Value::as_str) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
server_total.insert(id.to_owned());
|
||||||
|
if local.contains_key(id) {
|
||||||
|
counts.fetched += 1;
|
||||||
|
} else if planned.insert(id.to_owned()) {
|
||||||
|
new_ids.push((id.to_owned(), keyword_array(stub)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
let new_ids: Vec<String> = ids
|
|
||||||
.into_iter()
|
|
||||||
.filter(|id| !local.contains_key(id) && planned.insert(id.clone()))
|
|
||||||
.collect();
|
|
||||||
if new_ids.is_empty() {
|
if new_ids.is_empty() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -82,9 +87,13 @@ fn fetch_and_insert(
|
|||||||
conn: &mut Connection,
|
conn: &mut Connection,
|
||||||
ctx: &GraphCoordinator<'_>,
|
ctx: &GraphCoordinator<'_>,
|
||||||
folder: &MailFolder,
|
folder: &MailFolder,
|
||||||
ids: &[String],
|
ids: &[(String, String)],
|
||||||
counts: &mut TypeCounts,
|
counts: &mut TypeCounts,
|
||||||
) -> Result<(), Error> {
|
) -> Result<(), Error> {
|
||||||
|
let keywords_by_id: std::collections::HashMap<&str, &str> = ids
|
||||||
|
.iter()
|
||||||
|
.map(|(id, kw)| (id.as_str(), kw.as_str()))
|
||||||
|
.collect();
|
||||||
let client = ctx.client.clone();
|
let client = ctx.client.clone();
|
||||||
let endpoints: crate::exchange_graph::api::Endpoints = (*ctx.endpoints).clone();
|
let endpoints: crate::exchange_graph::api::Endpoints = (*ctx.endpoints).clone();
|
||||||
type FetchResult = (String, Result<Vec<u8>, GraphError>);
|
type FetchResult = (String, Result<Vec<u8>, GraphError>);
|
||||||
@@ -95,7 +104,7 @@ fn fetch_and_insert(
|
|||||||
Err(e) => (id, Err(e)),
|
Err(e) => (id, Err(e)),
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
for id in ids {
|
for (id, _) in ids {
|
||||||
pool.submit(id.clone());
|
pool.submit(id.clone());
|
||||||
}
|
}
|
||||||
let mut tx_opt: Option<Transaction<'_>> = None;
|
let mut tx_opt: Option<Transaction<'_>> = None;
|
||||||
@@ -110,7 +119,11 @@ fn fetch_and_insert(
|
|||||||
tx_opt = Some(conn.unchecked_transaction()?);
|
tx_opt = Some(conn.unchecked_transaction()?);
|
||||||
}
|
}
|
||||||
let tx = tx_opt.as_mut().expect("tx is Some");
|
let tx = tx_opt.as_mut().expect("tx is Some");
|
||||||
apply_message_in_tx(tx, ctx, folder, &graph_id, &bytes, counts)?;
|
let keywords = keywords_by_id
|
||||||
|
.get(graph_id.as_str())
|
||||||
|
.copied()
|
||||||
|
.unwrap_or("[]");
|
||||||
|
apply_message_in_tx(tx, ctx, folder, &graph_id, &bytes, keywords, counts)?;
|
||||||
in_batch += 1;
|
in_batch += 1;
|
||||||
if in_batch >= CHUNK_SIZE {
|
if in_batch >= CHUNK_SIZE {
|
||||||
if let Some(t) = tx_opt.take() {
|
if let Some(t) = tx_opt.take() {
|
||||||
@@ -141,13 +154,13 @@ fn apply_message_in_tx(
|
|||||||
folder: &MailFolder,
|
folder: &MailFolder,
|
||||||
graph_id: &str,
|
graph_id: &str,
|
||||||
bytes: &[u8],
|
bytes: &[u8],
|
||||||
|
keywords: &str,
|
||||||
counts: &mut TypeCounts,
|
counts: &mut TypeCounts,
|
||||||
) -> Result<(), Error> {
|
) -> Result<(), Error> {
|
||||||
let (idx, date_header) = email_meta_from_blob(bytes);
|
let (idx, date_header) = email_meta_from_blob(bytes);
|
||||||
let message_match = index_to_json(&idx);
|
let message_match = index_to_json(&idx);
|
||||||
let received_at = date_header.unwrap_or_else(|| "1970-01-01T00:00:00Z".to_owned());
|
let received_at = date_header.unwrap_or_else(|| "1970-01-01T00:00:00Z".to_owned());
|
||||||
let mailbox_ids = json!([folder.local_id]).to_string();
|
let mailbox_ids = json!([folder.local_id]).to_string();
|
||||||
let keywords = "[]".to_owned();
|
|
||||||
|
|
||||||
let blob_id = blobs::intern_blob(tx, bytes)?;
|
let blob_id = blobs::intern_blob(tx, bytes)?;
|
||||||
tx.execute(
|
tx.execute(
|
||||||
@@ -167,6 +180,33 @@ fn apply_message_in_tx(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn keyword_array(stub: &Value) -> String {
|
||||||
|
let mut kws: Vec<String> = Vec::new();
|
||||||
|
if stub.get("isRead").and_then(Value::as_bool) == Some(true) {
|
||||||
|
kws.push("$seen".to_owned());
|
||||||
|
}
|
||||||
|
if stub.get("isDraft").and_then(Value::as_bool) == Some(true) {
|
||||||
|
kws.push("$draft".to_owned());
|
||||||
|
}
|
||||||
|
if stub.get("isReadReceiptRequested").and_then(Value::as_bool) == Some(true) {
|
||||||
|
kws.push("$notified".to_owned());
|
||||||
|
}
|
||||||
|
if stub
|
||||||
|
.get("flag")
|
||||||
|
.and_then(|f| f.get("flagStatus"))
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
== Some("flagged")
|
||||||
|
{
|
||||||
|
kws.push("$flagged".to_owned());
|
||||||
|
}
|
||||||
|
if let Some(cats) = stub.get("categories").and_then(Value::as_array) {
|
||||||
|
for cat in cats.iter().filter_map(Value::as_str) {
|
||||||
|
kws.push(cat.to_ascii_lowercase());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Value::Array(kws.into_iter().map(Value::String).collect()).to_string()
|
||||||
|
}
|
||||||
|
|
||||||
fn delete_vanished(
|
fn delete_vanished(
|
||||||
conn: &mut Connection,
|
conn: &mut Connection,
|
||||||
source_id: i64,
|
source_id: i64,
|
||||||
|
|||||||
+427
-34
@@ -186,7 +186,7 @@ fn pagination_collects_ids_across_three_pages_with_one_empty() {
|
|||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
let _m1 = server
|
let _m1 = server
|
||||||
.mock("GET", "/me/mailFolders/F1/messages?$top=100&$select=id")
|
.mock("GET", "/me/mailFolders/F1/messages?$top=100&$select=id,isRead,isDraft,isReadReceiptRequested,flag,categories")
|
||||||
.with_status(200)
|
.with_status(200)
|
||||||
.with_header("content-type", "application/json")
|
.with_header("content-type", "application/json")
|
||||||
.with_body(page1)
|
.with_body(page1)
|
||||||
@@ -220,7 +220,7 @@ fn pagination_terminates_when_no_next_link_present() {
|
|||||||
let base = server.url();
|
let base = server.url();
|
||||||
let body = json!({"value": [{"id": "A"}, {"id": "B"}]}).to_string();
|
let body = json!({"value": [{"id": "A"}, {"id": "B"}]}).to_string();
|
||||||
let _m = server
|
let _m = server
|
||||||
.mock("GET", "/me/mailFolders/F/messages?$top=100&$select=id")
|
.mock("GET", "/me/mailFolders/F/messages?$top=100&$select=id,isRead,isDraft,isReadReceiptRequested,flag,categories")
|
||||||
.with_status(200)
|
.with_status(200)
|
||||||
.with_header("content-type", "application/json")
|
.with_header("content-type", "application/json")
|
||||||
.with_body(body)
|
.with_body(body)
|
||||||
@@ -248,7 +248,7 @@ fn header_redelivery_is_asserted_via_mockito_match() {
|
|||||||
.to_string();
|
.to_string();
|
||||||
let page2 = json!({"value": [{"id": "OnlyOnPageTwo"}]}).to_string();
|
let page2 = json!({"value": [{"id": "OnlyOnPageTwo"}]}).to_string();
|
||||||
let _m1 = server
|
let _m1 = server
|
||||||
.mock("GET", "/me/mailFolders/F/messages?$top=100&$select=id")
|
.mock("GET", "/me/mailFolders/F/messages?$top=100&$select=id,isRead,isDraft,isReadReceiptRequested,flag,categories")
|
||||||
.match_header("prefer", "IdType=\"ImmutableId\"")
|
.match_header("prefer", "IdType=\"ImmutableId\"")
|
||||||
.match_header("authorization", "Bearer BEARER")
|
.match_header("authorization", "Bearer BEARER")
|
||||||
.with_status(200)
|
.with_status(200)
|
||||||
@@ -275,7 +275,7 @@ fn case_sensitive_immutable_id_round_trip() {
|
|||||||
let id = "AAkAAGFsaWNlAAA=";
|
let id = "AAkAAGFsaWNlAAA=";
|
||||||
let body = json!({"value": [{"id": id}]}).to_string();
|
let body = json!({"value": [{"id": id}]}).to_string();
|
||||||
let _m = server
|
let _m = server
|
||||||
.mock("GET", "/me/mailFolders/F/messages?$top=100&$select=id")
|
.mock("GET", "/me/mailFolders/F/messages?$top=100&$select=id,isRead,isDraft,isReadReceiptRequested,flag,categories")
|
||||||
.with_status(200)
|
.with_status(200)
|
||||||
.with_header("content-type", "application/json")
|
.with_header("content-type", "application/json")
|
||||||
.with_body(body)
|
.with_body(body)
|
||||||
@@ -589,6 +589,7 @@ fn integration_dry_run_against_mock_server_lists_three_surfaces() {
|
|||||||
event_body_format: vandelay::exchange_graph::types::EventBodyFormat::Text,
|
event_body_format: vandelay::exchange_graph::types::EventBodyFormat::Text,
|
||||||
graph_connections: 2,
|
graph_connections: 2,
|
||||||
top: 100,
|
top: 100,
|
||||||
|
exception_window_years: 5,
|
||||||
allow_source_change: false,
|
allow_source_change: false,
|
||||||
};
|
};
|
||||||
let summary = vandelay::sync::import_exchange_graph::run(common, config).unwrap();
|
let summary = vandelay::sync::import_exchange_graph::run(common, config).unwrap();
|
||||||
@@ -663,7 +664,7 @@ fn integration_full_run_mail_only_imports_mime_via_value() {
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
let _ids = server
|
let _ids = server
|
||||||
.mock("GET", "/me/mailFolders/FMAIL/messages?$top=100&$select=id")
|
.mock("GET", "/me/mailFolders/FMAIL/messages?$top=100&$select=id,isRead,isDraft,isReadReceiptRequested,flag,categories")
|
||||||
.with_status(200)
|
.with_status(200)
|
||||||
.with_header("content-type", "application/json")
|
.with_header("content-type", "application/json")
|
||||||
.with_body(r#"{"value":[{"id":"MSG-1"}]}"#)
|
.with_body(r#"{"value":[{"id":"MSG-1"}]}"#)
|
||||||
@@ -698,6 +699,7 @@ fn integration_full_run_mail_only_imports_mime_via_value() {
|
|||||||
event_body_format: vandelay::exchange_graph::types::EventBodyFormat::Text,
|
event_body_format: vandelay::exchange_graph::types::EventBodyFormat::Text,
|
||||||
graph_connections: 2,
|
graph_connections: 2,
|
||||||
top: 100,
|
top: 100,
|
||||||
|
exception_window_years: 5,
|
||||||
allow_source_change: false,
|
allow_source_change: false,
|
||||||
};
|
};
|
||||||
drop(tmp);
|
drop(tmp);
|
||||||
@@ -759,7 +761,7 @@ fn integration_duplicate_message_id_does_not_abort_run() {
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
let _ids = server
|
let _ids = server
|
||||||
.mock("GET", "/me/mailFolders/FMAIL/messages?$top=100&$select=id")
|
.mock("GET", "/me/mailFolders/FMAIL/messages?$top=100&$select=id,isRead,isDraft,isReadReceiptRequested,flag,categories")
|
||||||
.with_status(200)
|
.with_status(200)
|
||||||
.with_header("content-type", "application/json")
|
.with_header("content-type", "application/json")
|
||||||
.with_body(r#"{"value":[{"id":"MSG-1"},{"id":"MSG-1"}]}"#)
|
.with_body(r#"{"value":[{"id":"MSG-1"},{"id":"MSG-1"}]}"#)
|
||||||
@@ -794,6 +796,7 @@ fn integration_duplicate_message_id_does_not_abort_run() {
|
|||||||
event_body_format: vandelay::exchange_graph::types::EventBodyFormat::Text,
|
event_body_format: vandelay::exchange_graph::types::EventBodyFormat::Text,
|
||||||
graph_connections: 2,
|
graph_connections: 2,
|
||||||
top: 100,
|
top: 100,
|
||||||
|
exception_window_years: 5,
|
||||||
allow_source_change: false,
|
allow_source_change: false,
|
||||||
};
|
};
|
||||||
drop(tmp);
|
drop(tmp);
|
||||||
@@ -855,7 +858,7 @@ fn integration_full_run_is_convergent_on_second_invocation() {
|
|||||||
.create();
|
.create();
|
||||||
}
|
}
|
||||||
let _ids = server
|
let _ids = server
|
||||||
.mock("GET", "/me/mailFolders/FMAIL/messages?$top=100&$select=id")
|
.mock("GET", "/me/mailFolders/FMAIL/messages?$top=100&$select=id,isRead,isDraft,isReadReceiptRequested,flag,categories")
|
||||||
.with_status(200)
|
.with_status(200)
|
||||||
.with_header("content-type", "application/json")
|
.with_header("content-type", "application/json")
|
||||||
.with_body(r#"{"value":[{"id":"MSG-A"}]}"#)
|
.with_body(r#"{"value":[{"id":"MSG-A"}]}"#)
|
||||||
@@ -883,6 +886,7 @@ fn integration_full_run_is_convergent_on_second_invocation() {
|
|||||||
event_body_format: vandelay::exchange_graph::types::EventBodyFormat::Text,
|
event_body_format: vandelay::exchange_graph::types::EventBodyFormat::Text,
|
||||||
graph_connections: 2,
|
graph_connections: 2,
|
||||||
top: 100,
|
top: 100,
|
||||||
|
exception_window_years: 5,
|
||||||
allow_source_change: false,
|
allow_source_change: false,
|
||||||
};
|
};
|
||||||
let make_common = |path: std::path::PathBuf| vandelay::sync::CommonConfig {
|
let make_common = |path: std::path::PathBuf| vandelay::sync::CommonConfig {
|
||||||
@@ -962,6 +966,7 @@ fn source_change_protection_refuses_a_different_account() {
|
|||||||
event_body_format: vandelay::exchange_graph::types::EventBodyFormat::Text,
|
event_body_format: vandelay::exchange_graph::types::EventBodyFormat::Text,
|
||||||
graph_connections: 2,
|
graph_connections: 2,
|
||||||
top: 100,
|
top: 100,
|
||||||
|
exception_window_years: 5,
|
||||||
allow_source_change: false,
|
allow_source_change: false,
|
||||||
};
|
};
|
||||||
let err = vandelay::sync::import_exchange_graph::run(common, config).unwrap_err();
|
let err = vandelay::sync::import_exchange_graph::run(common, config).unwrap_err();
|
||||||
@@ -1028,6 +1033,7 @@ fn make_config(
|
|||||||
event_body_format: vandelay::exchange_graph::types::EventBodyFormat::Text,
|
event_body_format: vandelay::exchange_graph::types::EventBodyFormat::Text,
|
||||||
graph_connections: 2,
|
graph_connections: 2,
|
||||||
top: 100,
|
top: 100,
|
||||||
|
exception_window_years: 5,
|
||||||
allow_source_change: false,
|
allow_source_change: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1144,11 +1150,31 @@ fn series_master_with_exception_merges_into_recurrence_overrides() {
|
|||||||
.with_header("content-type", "application/json")
|
.with_header("content-type", "application/json")
|
||||||
.with_body(
|
.with_body(
|
||||||
r#"{"value":[
|
r#"{"value":[
|
||||||
{"id":"MASTER","type":"seriesMaster","iCalUId":"uid-master"},
|
{"id":"MASTER","type":"seriesMaster","iCalUId":"uid-master"}
|
||||||
{"id":"EX","type":"exception","seriesMasterId":"MASTER","iCalUId":"uid-master"}
|
|
||||||
]}"#,
|
]}"#,
|
||||||
)
|
)
|
||||||
.create();
|
.create();
|
||||||
|
server
|
||||||
|
.mock(
|
||||||
|
"GET",
|
||||||
|
Matcher::Regex(r"^/me/calendars/CAL1/calendarView\?startDateTime=".to_owned()),
|
||||||
|
)
|
||||||
|
.with_status(200)
|
||||||
|
.with_header("content-type", "application/json")
|
||||||
|
.with_body(
|
||||||
|
r#"{"value":[{
|
||||||
|
"id":"EX",
|
||||||
|
"iCalUId":"uid-master",
|
||||||
|
"type":"exception",
|
||||||
|
"seriesMasterId":"MASTER",
|
||||||
|
"originalStart":"2026-05-11T15:00:00Z",
|
||||||
|
"subject":"Moved sync",
|
||||||
|
"start":{"dateTime":"2026-05-11T16:00:00.0000000","timeZone":"UTC"},
|
||||||
|
"end":{"dateTime":"2026-05-11T17:00:00.0000000","timeZone":"UTC"}
|
||||||
|
}]}"#,
|
||||||
|
)
|
||||||
|
.expect_at_least(1)
|
||||||
|
.create();
|
||||||
server
|
server
|
||||||
.mock("GET", "/me/events/MASTER")
|
.mock("GET", "/me/events/MASTER")
|
||||||
.with_status(200)
|
.with_status(200)
|
||||||
@@ -1168,23 +1194,6 @@ fn series_master_with_exception_merges_into_recurrence_overrides() {
|
|||||||
}"#,
|
}"#,
|
||||||
)
|
)
|
||||||
.create();
|
.create();
|
||||||
server
|
|
||||||
.mock("GET", "/me/events/EX")
|
|
||||||
.with_status(200)
|
|
||||||
.with_header("content-type", "application/json")
|
|
||||||
.with_body(
|
|
||||||
r#"{
|
|
||||||
"id":"EX",
|
|
||||||
"iCalUId":"uid-master",
|
|
||||||
"type":"exception",
|
|
||||||
"seriesMasterId":"MASTER",
|
|
||||||
"originalStart":"2026-05-11T15:00:00Z",
|
|
||||||
"subject":"Moved sync",
|
|
||||||
"start":{"dateTime":"2026-05-11T16:00:00.0000000","timeZone":"UTC"},
|
|
||||||
"end":{"dateTime":"2026-05-11T17:00:00.0000000","timeZone":"UTC"}
|
|
||||||
}"#,
|
|
||||||
)
|
|
||||||
.create();
|
|
||||||
let base = server.url();
|
let base = server.url();
|
||||||
let archive = tempfile::NamedTempFile::new().unwrap().path().to_owned();
|
let archive = tempfile::NamedTempFile::new().unwrap().path().to_owned();
|
||||||
let summary = vandelay::sync::import_exchange_graph::run(
|
let summary = vandelay::sync::import_exchange_graph::run(
|
||||||
@@ -1354,7 +1363,7 @@ fn hidden_mail_folder_has_is_subscribed_zero() {
|
|||||||
server
|
server
|
||||||
.mock(
|
.mock(
|
||||||
"GET",
|
"GET",
|
||||||
"/me/mailFolders/FVISIBLE/messages?$top=100&$select=id",
|
"/me/mailFolders/FVISIBLE/messages?$top=100&$select=id,isRead,isDraft,isReadReceiptRequested,flag,categories",
|
||||||
)
|
)
|
||||||
.with_status(200)
|
.with_status(200)
|
||||||
.with_header("content-type", "application/json")
|
.with_header("content-type", "application/json")
|
||||||
@@ -1363,7 +1372,7 @@ fn hidden_mail_folder_has_is_subscribed_zero() {
|
|||||||
server
|
server
|
||||||
.mock(
|
.mock(
|
||||||
"GET",
|
"GET",
|
||||||
"/me/mailFolders/FHIDDEN/messages?$top=100&$select=id",
|
"/me/mailFolders/FHIDDEN/messages?$top=100&$select=id,isRead,isDraft,isReadReceiptRequested,flag,categories",
|
||||||
)
|
)
|
||||||
.with_status(200)
|
.with_status(200)
|
||||||
.with_header("content-type", "application/json")
|
.with_header("content-type", "application/json")
|
||||||
@@ -1441,7 +1450,7 @@ fn well_known_folder_probes_assign_jmap_roles() {
|
|||||||
server
|
server
|
||||||
.mock(
|
.mock(
|
||||||
"GET",
|
"GET",
|
||||||
format!("/me/mailFolders/{fid}/messages?$top=100&$select=id").as_str(),
|
format!("/me/mailFolders/{fid}/messages?$top=100&$select=id,isRead,isDraft,isReadReceiptRequested,flag,categories").as_str(),
|
||||||
)
|
)
|
||||||
.with_status(200)
|
.with_status(200)
|
||||||
.with_header("content-type", "application/json")
|
.with_header("content-type", "application/json")
|
||||||
@@ -1515,7 +1524,7 @@ fn attachment_message_does_not_trigger_attachments_endpoint() {
|
|||||||
.create();
|
.create();
|
||||||
stub_well_known_folders(&mut server, "FMAIL");
|
stub_well_known_folders(&mut server, "FMAIL");
|
||||||
server
|
server
|
||||||
.mock("GET", "/me/mailFolders/FMAIL/messages?$top=100&$select=id")
|
.mock("GET", "/me/mailFolders/FMAIL/messages?$top=100&$select=id,isRead,isDraft,isReadReceiptRequested,flag,categories")
|
||||||
.with_status(200)
|
.with_status(200)
|
||||||
.with_header("content-type", "application/json")
|
.with_header("content-type", "application/json")
|
||||||
.with_body(r#"{"value":[{"id":"MSG-ATT"}]}"#)
|
.with_body(r#"{"value":[{"id":"MSG-ATT"}]}"#)
|
||||||
@@ -1708,7 +1717,7 @@ fn full_run_records_graph_id_in_sync_id_exchange_graph_with_padding() {
|
|||||||
let _ids = server
|
let _ids = server
|
||||||
.mock(
|
.mock(
|
||||||
"GET",
|
"GET",
|
||||||
"/me/mailFolders/AAkA-Padded%3D%3D/messages?$top=100&$select=id",
|
"/me/mailFolders/AAkA-Padded%3D%3D/messages?$top=100&$select=id,isRead,isDraft,isReadReceiptRequested,flag,categories",
|
||||||
)
|
)
|
||||||
.with_status(200)
|
.with_status(200)
|
||||||
.with_header("content-type", "application/json")
|
.with_header("content-type", "application/json")
|
||||||
@@ -1736,6 +1745,7 @@ fn full_run_records_graph_id_in_sync_id_exchange_graph_with_padding() {
|
|||||||
event_body_format: vandelay::exchange_graph::types::EventBodyFormat::Text,
|
event_body_format: vandelay::exchange_graph::types::EventBodyFormat::Text,
|
||||||
graph_connections: 2,
|
graph_connections: 2,
|
||||||
top: 100,
|
top: 100,
|
||||||
|
exception_window_years: 5,
|
||||||
allow_source_change: false,
|
allow_source_change: false,
|
||||||
};
|
};
|
||||||
drop(tmp);
|
drop(tmp);
|
||||||
@@ -1806,6 +1816,7 @@ fn archive_mailbox_kind_encodes_synthetic_suffix_in_account_id() {
|
|||||||
event_body_format: vandelay::exchange_graph::types::EventBodyFormat::Text,
|
event_body_format: vandelay::exchange_graph::types::EventBodyFormat::Text,
|
||||||
graph_connections: 2,
|
graph_connections: 2,
|
||||||
top: 100,
|
top: 100,
|
||||||
|
exception_window_years: 5,
|
||||||
allow_source_change: false,
|
allow_source_change: false,
|
||||||
};
|
};
|
||||||
drop(tmp);
|
drop(tmp);
|
||||||
@@ -1871,6 +1882,7 @@ fn allow_source_change_permits_overwriting_a_different_account() {
|
|||||||
event_body_format: vandelay::exchange_graph::types::EventBodyFormat::Text,
|
event_body_format: vandelay::exchange_graph::types::EventBodyFormat::Text,
|
||||||
graph_connections: 2,
|
graph_connections: 2,
|
||||||
top: 100,
|
top: 100,
|
||||||
|
exception_window_years: 5,
|
||||||
allow_source_change: true,
|
allow_source_change: true,
|
||||||
};
|
};
|
||||||
let result = vandelay::sync::import_exchange_graph::run(common, config);
|
let result = vandelay::sync::import_exchange_graph::run(common, config);
|
||||||
@@ -1952,6 +1964,7 @@ fn dry_run_makes_no_per_item_get_and_no_sqlite_writes() {
|
|||||||
event_body_format: vandelay::exchange_graph::types::EventBodyFormat::Text,
|
event_body_format: vandelay::exchange_graph::types::EventBodyFormat::Text,
|
||||||
graph_connections: 2,
|
graph_connections: 2,
|
||||||
top: 100,
|
top: 100,
|
||||||
|
exception_window_years: 5,
|
||||||
allow_source_change: false,
|
allow_source_change: false,
|
||||||
};
|
};
|
||||||
drop(tmp);
|
drop(tmp);
|
||||||
@@ -2096,7 +2109,7 @@ fn folder_enumeration_failure_skips_vanished_deletion() {
|
|||||||
.create();
|
.create();
|
||||||
}
|
}
|
||||||
let _enum_fail = server
|
let _enum_fail = server
|
||||||
.mock("GET", "/me/mailFolders/FMAIL/messages?$top=100&$select=id")
|
.mock("GET", "/me/mailFolders/FMAIL/messages?$top=100&$select=id,isRead,isDraft,isReadReceiptRequested,flag,categories")
|
||||||
.with_status(503)
|
.with_status(503)
|
||||||
.with_body("transient outage")
|
.with_body("transient outage")
|
||||||
.create();
|
.create();
|
||||||
@@ -2120,6 +2133,7 @@ fn folder_enumeration_failure_skips_vanished_deletion() {
|
|||||||
event_body_format: vandelay::exchange_graph::types::EventBodyFormat::Text,
|
event_body_format: vandelay::exchange_graph::types::EventBodyFormat::Text,
|
||||||
graph_connections: 2,
|
graph_connections: 2,
|
||||||
top: 100,
|
top: 100,
|
||||||
|
exception_window_years: 5,
|
||||||
allow_source_change: true,
|
allow_source_change: true,
|
||||||
};
|
};
|
||||||
let _ = vandelay::sync::import_exchange_graph::run(common, config).unwrap();
|
let _ = vandelay::sync::import_exchange_graph::run(common, config).unwrap();
|
||||||
@@ -2147,7 +2161,14 @@ fn stub_contacts_surface(server: &mut Server) {
|
|||||||
.mock("GET", "/me/contactFolders?$top=100")
|
.mock("GET", "/me/contactFolders?$top=100")
|
||||||
.with_status(200)
|
.with_status(200)
|
||||||
.with_header("content-type", "application/json")
|
.with_header("content-type", "application/json")
|
||||||
.with_body(r#"{"value":[{"id":"CON1","displayName":"Contacts"}]}"#)
|
.with_body(r#"{"value":[]}"#)
|
||||||
|
.expect_at_least(0)
|
||||||
|
.create();
|
||||||
|
server
|
||||||
|
.mock("GET", "/me/contactFolders/contacts")
|
||||||
|
.with_status(200)
|
||||||
|
.with_header("content-type", "application/json")
|
||||||
|
.with_body(r#"{"id":"CON1","displayName":"Contacts","wellKnownName":"contacts"}"#)
|
||||||
.expect_at_least(0)
|
.expect_at_least(0)
|
||||||
.create();
|
.create();
|
||||||
server
|
server
|
||||||
@@ -2199,7 +2220,7 @@ fn stub_mail_surface(server: &mut Server) {
|
|||||||
.create();
|
.create();
|
||||||
stub_well_known_folders(server, "FMAIL");
|
stub_well_known_folders(server, "FMAIL");
|
||||||
server
|
server
|
||||||
.mock("GET", "/me/mailFolders/FMAIL/messages?$top=100&$select=id")
|
.mock("GET", "/me/mailFolders/FMAIL/messages?$top=100&$select=id,isRead,isDraft,isReadReceiptRequested,flag,categories")
|
||||||
.with_status(200)
|
.with_status(200)
|
||||||
.with_header("content-type", "application/json")
|
.with_header("content-type", "application/json")
|
||||||
.with_body(r#"{"value":[{"id":"MSG-S"}]}"#)
|
.with_body(r#"{"value":[{"id":"MSG-S"}]}"#)
|
||||||
@@ -2342,3 +2363,375 @@ fn mail_surface_imports_only_mailboxes_and_emails() {
|
|||||||
assert_eq!(row_count(&conn, "calendars"), 0);
|
assert_eq!(row_count(&conn, "calendars"), 0);
|
||||||
assert_eq!(row_count(&conn, "calendar_events"), 0);
|
assert_eq!(row_count(&conn, "calendar_events"), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn stub_principal(server: &mut Server) {
|
||||||
|
server
|
||||||
|
.mock("GET", Matcher::Regex(r"^/me\?\$select=id".to_owned()))
|
||||||
|
.with_status(200)
|
||||||
|
.with_header("content-type", "application/json")
|
||||||
|
.with_body(r#"{"id":"uid","userPrincipalName":"[email protected]"}"#)
|
||||||
|
.expect_at_least(0)
|
||||||
|
.create();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn json_mock(server: &mut Server, path: &str, body: &str) {
|
||||||
|
server
|
||||||
|
.mock("GET", path)
|
||||||
|
.with_status(200)
|
||||||
|
.with_header("content-type", "application/json")
|
||||||
|
.with_body(body)
|
||||||
|
.expect_at_least(0)
|
||||||
|
.create();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_contact_folder_is_imported_although_contactfolders_omits_it() {
|
||||||
|
let mut server = Server::new();
|
||||||
|
stub_principal(&mut server);
|
||||||
|
json_mock(
|
||||||
|
&mut server,
|
||||||
|
"/me/contactFolders?$top=100",
|
||||||
|
r#"{"value":[]}"#,
|
||||||
|
);
|
||||||
|
json_mock(
|
||||||
|
&mut server,
|
||||||
|
"/me/contactFolders/contacts",
|
||||||
|
r#"{"id":"DEFAULT","displayName":"Contacts","wellKnownName":"contacts"}"#,
|
||||||
|
);
|
||||||
|
json_mock(
|
||||||
|
&mut server,
|
||||||
|
"/me/contactFolders/DEFAULT/childFolders?$top=100",
|
||||||
|
r#"{"value":[]}"#,
|
||||||
|
);
|
||||||
|
json_mock(
|
||||||
|
&mut server,
|
||||||
|
"/me/contactFolders/DEFAULT/contacts?$top=100&$select=id",
|
||||||
|
r#"{"value":[{"id":"C1"},{"id":"C2"}]}"#,
|
||||||
|
);
|
||||||
|
json_mock(
|
||||||
|
&mut server,
|
||||||
|
"/me/contacts/C1",
|
||||||
|
r#"{"id":"C1","displayName":"Alice"}"#,
|
||||||
|
);
|
||||||
|
json_mock(
|
||||||
|
&mut server,
|
||||||
|
"/me/contacts/C2",
|
||||||
|
r#"{"id":"C2","displayName":"Bob"}"#,
|
||||||
|
);
|
||||||
|
|
||||||
|
let base = server.url();
|
||||||
|
let archive = tempfile::NamedTempFile::new().unwrap().path().to_owned();
|
||||||
|
let summary = vandelay::sync::import_exchange_graph::run(
|
||||||
|
make_common(archive.clone()),
|
||||||
|
make_config(base, None, surfaces("contacts")),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let count = |name: &str| {
|
||||||
|
summary
|
||||||
|
.per_type
|
||||||
|
.iter()
|
||||||
|
.find(|(t, _)| *t == name)
|
||||||
|
.map(|(_, c)| c.created)
|
||||||
|
.unwrap_or(0)
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
count("addressbook"),
|
||||||
|
1,
|
||||||
|
"the default Contacts folder must become an address book even though \
|
||||||
|
/me/contactFolders never lists it"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
count("contactcard"),
|
||||||
|
2,
|
||||||
|
"both default-folder contacts import"
|
||||||
|
);
|
||||||
|
|
||||||
|
let conn = vandelay::db::init::open(&archive).unwrap();
|
||||||
|
let is_default: i64 = conn
|
||||||
|
.query_row("SELECT is_default FROM address_books", [], |row| row.get(0))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(is_default, 1, "the well-known folder is the default book");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_contact_folder_falls_back_to_parent_of_an_existing_contact() {
|
||||||
|
let mut server = Server::new();
|
||||||
|
stub_principal(&mut server);
|
||||||
|
json_mock(
|
||||||
|
&mut server,
|
||||||
|
"/me/contactFolders?$top=100",
|
||||||
|
r#"{"value":[]}"#,
|
||||||
|
);
|
||||||
|
server
|
||||||
|
.mock("GET", "/me/contactFolders/contacts")
|
||||||
|
.with_status(404)
|
||||||
|
.with_body(r#"{"error":{"code":"ErrorItemNotFound"}}"#)
|
||||||
|
.expect_at_least(1)
|
||||||
|
.create();
|
||||||
|
json_mock(
|
||||||
|
&mut server,
|
||||||
|
"/me/contacts?$top=1&$select=id,parentFolderId",
|
||||||
|
r#"{"value":[{"id":"C1","parentFolderId":"DERIVED"}]}"#,
|
||||||
|
);
|
||||||
|
json_mock(
|
||||||
|
&mut server,
|
||||||
|
"/me/contactFolders/DERIVED",
|
||||||
|
r#"{"id":"DERIVED","displayName":"Kontakte"}"#,
|
||||||
|
);
|
||||||
|
json_mock(
|
||||||
|
&mut server,
|
||||||
|
"/me/contactFolders/DERIVED/childFolders?$top=100",
|
||||||
|
r#"{"value":[]}"#,
|
||||||
|
);
|
||||||
|
json_mock(
|
||||||
|
&mut server,
|
||||||
|
"/me/contactFolders/DERIVED/contacts?$top=100&$select=id",
|
||||||
|
r#"{"value":[{"id":"C1"}]}"#,
|
||||||
|
);
|
||||||
|
json_mock(
|
||||||
|
&mut server,
|
||||||
|
"/me/contacts/C1",
|
||||||
|
r#"{"id":"C1","displayName":"Alice"}"#,
|
||||||
|
);
|
||||||
|
|
||||||
|
let base = server.url();
|
||||||
|
let archive = tempfile::NamedTempFile::new().unwrap().path().to_owned();
|
||||||
|
vandelay::sync::import_exchange_graph::run(
|
||||||
|
make_common(archive.clone()),
|
||||||
|
make_config(base, None, surfaces("contacts")),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let conn = vandelay::db::init::open(&archive).unwrap();
|
||||||
|
let name: String = conn
|
||||||
|
.query_row("SELECT name FROM address_books", [], |row| row.get(0))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
name, "Kontakte",
|
||||||
|
"when the well-known name is unavailable the folder is derived from a contact's parent"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn contact_folder_reachable_by_two_paths_is_inserted_once() {
|
||||||
|
let mut server = Server::new();
|
||||||
|
stub_principal(&mut server);
|
||||||
|
json_mock(
|
||||||
|
&mut server,
|
||||||
|
"/me/contactFolders?$top=100",
|
||||||
|
r#"{"value":[{"id":"CHILD","displayName":"Work","parentFolderId":"DEFAULT"}]}"#,
|
||||||
|
);
|
||||||
|
json_mock(
|
||||||
|
&mut server,
|
||||||
|
"/me/contactFolders/contacts",
|
||||||
|
r#"{"id":"DEFAULT","displayName":"Contacts","wellKnownName":"contacts"}"#,
|
||||||
|
);
|
||||||
|
json_mock(
|
||||||
|
&mut server,
|
||||||
|
"/me/contactFolders/DEFAULT/childFolders?$top=100",
|
||||||
|
r#"{"value":[{"id":"CHILD","displayName":"Work","parentFolderId":"DEFAULT"}]}"#,
|
||||||
|
);
|
||||||
|
json_mock(
|
||||||
|
&mut server,
|
||||||
|
"/me/contactFolders/CHILD/childFolders?$top=100",
|
||||||
|
r#"{"value":[]}"#,
|
||||||
|
);
|
||||||
|
for folder in ["DEFAULT", "CHILD"] {
|
||||||
|
json_mock(
|
||||||
|
&mut server,
|
||||||
|
&format!("/me/contactFolders/{folder}/contacts?$top=100&$select=id"),
|
||||||
|
r#"{"value":[]}"#,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let base = server.url();
|
||||||
|
let archive = tempfile::NamedTempFile::new().unwrap().path().to_owned();
|
||||||
|
let summary = vandelay::sync::import_exchange_graph::run(
|
||||||
|
make_common(archive.clone()),
|
||||||
|
make_config(base, None, surfaces("contacts")),
|
||||||
|
)
|
||||||
|
.expect("a folder reachable by two paths must not violate the id-mapping uniqueness");
|
||||||
|
|
||||||
|
let created = summary
|
||||||
|
.per_type
|
||||||
|
.iter()
|
||||||
|
.find(|(t, _)| *t == "addressbook")
|
||||||
|
.map(|(_, c)| c.created)
|
||||||
|
.unwrap_or(0);
|
||||||
|
assert_eq!(
|
||||||
|
created, 2,
|
||||||
|
"the default folder and its one child, each once"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn message_state_becomes_jmap_keywords() {
|
||||||
|
let mut server = Server::new();
|
||||||
|
stub_principal(&mut server);
|
||||||
|
json_mock(
|
||||||
|
&mut server,
|
||||||
|
"/me/mailFolders?$top=100&includeHiddenFolders=true",
|
||||||
|
r#"{"value":[{"id":"F1","displayName":"Inbox","isHidden":false}]}"#,
|
||||||
|
);
|
||||||
|
json_mock(
|
||||||
|
&mut server,
|
||||||
|
"/me/mailFolders/F1/childFolders?$top=100&includeHiddenFolders=true",
|
||||||
|
r#"{"value":[]}"#,
|
||||||
|
);
|
||||||
|
for name in [
|
||||||
|
"inbox",
|
||||||
|
"drafts",
|
||||||
|
"sentitems",
|
||||||
|
"deleteditems",
|
||||||
|
"junkemail",
|
||||||
|
"archive",
|
||||||
|
] {
|
||||||
|
server
|
||||||
|
.mock("GET", format!("/me/mailFolders/{name}?$select=id").as_str())
|
||||||
|
.with_status(404)
|
||||||
|
.expect_at_least(0)
|
||||||
|
.create();
|
||||||
|
}
|
||||||
|
json_mock(
|
||||||
|
&mut server,
|
||||||
|
"/me/mailFolders/F1/messages?$top=100&$select=id,isRead,isDraft,isReadReceiptRequested,flag,categories",
|
||||||
|
r#"{"value":[
|
||||||
|
{"id":"M1","isRead":true,"isDraft":false,"isReadReceiptRequested":true,
|
||||||
|
"flag":{"flagStatus":"flagged"},"categories":["Red Category","VIP"]},
|
||||||
|
{"id":"M2","isRead":false,"isDraft":true,
|
||||||
|
"flag":{"flagStatus":"notFlagged"},"categories":[]}
|
||||||
|
]}"#,
|
||||||
|
);
|
||||||
|
for id in ["M1", "M2"] {
|
||||||
|
server
|
||||||
|
.mock("GET", format!("/me/messages/{id}/$value").as_str())
|
||||||
|
.with_status(200)
|
||||||
|
.with_header("content-type", "text/plain")
|
||||||
|
.with_body(format!(
|
||||||
|
"From: [email protected]\r\nTo: [email protected]\r\nSubject: {id}\r\nMessage-ID: <{id}@x>\r\n\r\nbody\r\n"
|
||||||
|
))
|
||||||
|
.expect_at_least(0)
|
||||||
|
.create();
|
||||||
|
}
|
||||||
|
|
||||||
|
let base = server.url();
|
||||||
|
let archive = tempfile::NamedTempFile::new().unwrap().path().to_owned();
|
||||||
|
vandelay::sync::import_exchange_graph::run(
|
||||||
|
make_common(archive.clone()),
|
||||||
|
make_config(base, None, surfaces("mail")),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let conn = vandelay::db::init::open(&archive).unwrap();
|
||||||
|
let mut stmt = conn
|
||||||
|
.prepare("SELECT keywords FROM emails ORDER BY id")
|
||||||
|
.unwrap();
|
||||||
|
let rows: Vec<String> = stmt
|
||||||
|
.query_map([], |row| row.get::<_, String>(0))
|
||||||
|
.unwrap()
|
||||||
|
.map(|r| r.unwrap())
|
||||||
|
.collect();
|
||||||
|
let all = rows.join(" ");
|
||||||
|
for expected in [
|
||||||
|
"$seen",
|
||||||
|
"$notified",
|
||||||
|
"$flagged",
|
||||||
|
"red category",
|
||||||
|
"vip",
|
||||||
|
"$draft",
|
||||||
|
] {
|
||||||
|
assert!(all.contains(expected), "missing {expected} in {all}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn drive_items_import_as_file_nodes_and_skip_facetless_items() {
|
||||||
|
let mut server = Server::new();
|
||||||
|
stub_principal(&mut server);
|
||||||
|
json_mock(
|
||||||
|
&mut server,
|
||||||
|
"/me/drive/root?$select=id,name",
|
||||||
|
r#"{"id":"ROOT","name":"root"}"#,
|
||||||
|
);
|
||||||
|
let select = "id,name,size,folder,file,package,remoteItem,createdDateTime,lastModifiedDateTime";
|
||||||
|
json_mock(
|
||||||
|
&mut server,
|
||||||
|
&format!("/me/drive/items/ROOT/children?$top=100&$select={select}"),
|
||||||
|
r#"{"value":[
|
||||||
|
{"id":"D1","name":"Docs","folder":{"childCount":1},
|
||||||
|
"createdDateTime":"2026-01-01T00:00:00Z","lastModifiedDateTime":"2026-01-02T00:00:00Z"},
|
||||||
|
{"id":"V1","name":"Personal Vault","remoteItem":{},
|
||||||
|
"createdDateTime":"2026-01-01T00:00:00Z"},
|
||||||
|
{"id":"F1","name":"top.txt","size":5,"file":{"mimeType":"text/plain"},
|
||||||
|
"createdDateTime":"2026-01-01T00:00:00Z","lastModifiedDateTime":"2026-01-02T00:00:00Z"}
|
||||||
|
]}"#,
|
||||||
|
);
|
||||||
|
json_mock(
|
||||||
|
&mut server,
|
||||||
|
&format!("/me/drive/items/D1/children?$top=100&$select={select}"),
|
||||||
|
r#"{"value":[
|
||||||
|
{"id":"F2","name":"inner.bin","size":3,"file":{"mimeType":"application/octet-stream"},
|
||||||
|
"createdDateTime":"2026-01-01T00:00:00Z"}
|
||||||
|
]}"#,
|
||||||
|
);
|
||||||
|
for (id, body) in [("F1", "hello"), ("F2", "abc")] {
|
||||||
|
server
|
||||||
|
.mock("GET", format!("/me/drive/items/{id}/content").as_str())
|
||||||
|
.with_status(200)
|
||||||
|
.with_header("content-type", "application/octet-stream")
|
||||||
|
.with_body(body)
|
||||||
|
.expect_at_least(0)
|
||||||
|
.create();
|
||||||
|
}
|
||||||
|
|
||||||
|
let base = server.url();
|
||||||
|
let archive = tempfile::NamedTempFile::new().unwrap().path().to_owned();
|
||||||
|
let summary = vandelay::sync::import_exchange_graph::run(
|
||||||
|
make_common(archive.clone()),
|
||||||
|
make_config(base, None, surfaces("files")),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let created = summary
|
||||||
|
.per_type
|
||||||
|
.iter()
|
||||||
|
.find(|(t, _)| *t == "filenode")
|
||||||
|
.map(|(_, c)| c.created)
|
||||||
|
.unwrap_or(0);
|
||||||
|
assert_eq!(
|
||||||
|
created, 3,
|
||||||
|
"one directory and two files; the remoteItem has no file or folder facet and is skipped"
|
||||||
|
);
|
||||||
|
|
||||||
|
let conn = vandelay::db::init::open(&archive).unwrap();
|
||||||
|
let vault: i64 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT count(*) FROM file_nodes WHERE name = 'Personal Vault'",
|
||||||
|
[],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(vault, 0, "a facetless drive item must never become a node");
|
||||||
|
|
||||||
|
let nested: i64 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT count(*) FROM file_nodes child JOIN file_nodes parent
|
||||||
|
ON child.parent_id = parent.id
|
||||||
|
WHERE child.name = 'inner.bin' AND parent.name = 'Docs'",
|
||||||
|
[],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(nested, 1, "child files hang off their directory");
|
||||||
|
|
||||||
|
let body: Vec<u8> = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT b.data FROM file_nodes f JOIN blobs b ON b.id = f.blob_id
|
||||||
|
WHERE f.name = 'top.txt'",
|
||||||
|
[],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(body, b"hello", "file content is stored verbatim");
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user