MS Exchange Graph: Import the default Contacts folder, recover series exceptions (fixes #39)
This commit is contained in:
@@ -7,6 +7,7 @@
|
||||
pub mod calendar;
|
||||
pub mod contacts;
|
||||
pub mod coordinator;
|
||||
pub mod files;
|
||||
pub mod folders;
|
||||
pub mod messages;
|
||||
|
||||
|
||||
@@ -57,7 +57,9 @@ pub fn reconcile_all(
|
||||
_ => {
|
||||
if let Some(id) = stub.get("id").and_then(Value::as_str) {
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -79,19 +81,23 @@ pub fn reconcile_all(
|
||||
let fetched = fetch_events(ctx, &want_ids);
|
||||
let mut masters: Vec<(String, ConvertedEvent)> = Vec::new();
|
||||
let mut exceptions: Vec<(String, ConvertedEvent)> = Vec::new();
|
||||
let mut any_series = false;
|
||||
for (graph_id, result) in fetched {
|
||||
match result {
|
||||
Ok(raw) => match convert_event(&raw, None) {
|
||||
Ok(c) => match c.event_type {
|
||||
EventType::Exception => exceptions.push((graph_id, c)),
|
||||
_ => masters.push((graph_id, c)),
|
||||
},
|
||||
Err(e) => {
|
||||
counts.failed += 1;
|
||||
ctx.logger
|
||||
.warn(&format!("graph event {graph_id} convert failed: {e}"));
|
||||
Ok(raw) => {
|
||||
any_series |= is_series_master(&raw);
|
||||
match convert_event(&raw, None) {
|
||||
Ok(c) => match c.event_type {
|
||||
EventType::Exception => exceptions.push((graph_id, c)),
|
||||
_ => masters.push((graph_id, c)),
|
||||
},
|
||||
Err(e) => {
|
||||
counts.failed += 1;
|
||||
ctx.logger
|
||||
.warn(&format!("graph event {graph_id} convert failed: {e}"));
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
Err(GraphError::Vanished) => counts.skipped += 1,
|
||||
Err(e) => {
|
||||
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();
|
||||
for (ex_graph_id, ex) in exceptions {
|
||||
let Some(master_graph_id) = ex.series_master_id.clone() else {
|
||||
@@ -167,6 +177,73 @@ fn fetch_events(
|
||||
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) {
|
||||
let Some(raw_key) = ex.original_start.as_deref() else {
|
||||
return;
|
||||
@@ -407,6 +484,48 @@ fn delete_vanished(
|
||||
mod tests {
|
||||
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]
|
||||
fn override_key_utc_converts_to_master_local_time() {
|
||||
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 {
|
||||
server_total.insert(id.clone());
|
||||
}
|
||||
let new_ids: Vec<String> = ids
|
||||
.into_iter()
|
||||
.filter(|id| !local.contains_key(id) && planned.insert(id.clone()))
|
||||
.collect();
|
||||
let mut new_ids: Vec<String> = Vec::new();
|
||||
for id in ids {
|
||||
if local.contains_key(&id) {
|
||||
counts.fetched += 1;
|
||||
} else if planned.insert(id.clone()) {
|
||||
new_ids.push(id);
|
||||
}
|
||||
}
|
||||
if new_ids.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ pub struct GraphImportConfig {
|
||||
pub event_body_format: EventBodyFormat,
|
||||
pub graph_connections: usize,
|
||||
pub top: usize,
|
||||
pub exception_window_years: i32,
|
||||
pub allow_source_change: bool,
|
||||
}
|
||||
|
||||
@@ -55,6 +56,7 @@ pub struct GraphCoordinator<'a> {
|
||||
pub workers: usize,
|
||||
pub logger: crate::logging::Logger,
|
||||
pub event_body_format: EventBodyFormat,
|
||||
pub exception_window_years: i32,
|
||||
}
|
||||
|
||||
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 addressbook_counts = TypeCounts::default();
|
||||
let mut contact_counts = TypeCounts::default();
|
||||
let mut file_counts = TypeCounts::default();
|
||||
|
||||
let ctx = GraphCoordinator {
|
||||
client: &client,
|
||||
@@ -138,12 +141,14 @@ pub fn run(common: CommonConfig, config: GraphImportConfig) -> Result<Summary, E
|
||||
workers: config.graph_connections.clamp(1, 16),
|
||||
logger,
|
||||
event_body_format: config.event_body_format,
|
||||
exception_window_years: config.exception_window_years,
|
||||
};
|
||||
|
||||
let primary = !matches!(config.mailbox_kind, MailboxKind::Archive);
|
||||
let want_mail = config.surfaces.mail;
|
||||
let want_calendar = config.surfaces.calendar && primary;
|
||||
let want_contacts = config.surfaces.contacts && primary;
|
||||
let want_files = config.surfaces.files && primary;
|
||||
|
||||
if want_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)?;
|
||||
}
|
||||
|
||||
if want_files {
|
||||
super::files::reconcile_all(&mut conn, &ctx, &mut file_counts)?;
|
||||
}
|
||||
|
||||
summary.per_type.push(("mailbox", mailbox_counts));
|
||||
summary.per_type.push(("email", email_counts));
|
||||
summary.per_type.push(("calendar", calendar_counts));
|
||||
summary.per_type.push(("calendarevent", event_counts));
|
||||
summary.per_type.push(("addressbook", addressbook_counts));
|
||||
summary.per_type.push(("contactcard", contact_counts));
|
||||
summary.per_type.push(("filenode", file_counts));
|
||||
|
||||
if !summary.any_failed()
|
||||
&& 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 initial = endpoints.mail_folders_root(mailbox_kind, top);
|
||||
let level = crate::exchange_graph::api::collect_all_values(client, &initial, &[])?;
|
||||
for f in &level {
|
||||
if let Some(id) = f.get("id").and_then(Value::as_str)
|
||||
&& seen.insert(id.to_owned())
|
||||
{
|
||||
for folder in level {
|
||||
let Some(id) = folder.get("id").and_then(Value::as_str) else {
|
||||
continue;
|
||||
};
|
||||
if seen.insert(id.to_owned()) {
|
||||
frontier.push(id.to_owned());
|
||||
all.push(folder);
|
||||
}
|
||||
}
|
||||
all.extend(level);
|
||||
while let Some(parent) = frontier.pop() {
|
||||
let url = endpoints.mail_folder_child_folders(&parent, top);
|
||||
let children = crate::exchange_graph::api::collect_all_values(client, &url, &[])?;
|
||||
for c in &children {
|
||||
if let Some(id) = c.get("id").and_then(Value::as_str)
|
||||
&& seen.insert(id.to_owned())
|
||||
{
|
||||
for child in children {
|
||||
let Some(id) = child.get("id").and_then(Value::as_str) else {
|
||||
continue;
|
||||
};
|
||||
if seen.insert(id.to_owned()) {
|
||||
frontier.push(id.to_owned());
|
||||
all.push(child);
|
||||
}
|
||||
}
|
||||
all.extend(children);
|
||||
}
|
||||
Ok(all)
|
||||
}
|
||||
@@ -490,6 +502,7 @@ mod tests {
|
||||
event_body_format: EventBodyFormat::Text,
|
||||
graph_connections: 4,
|
||||
top: 100,
|
||||
exception_window_years: 5,
|
||||
allow_source_change: false,
|
||||
};
|
||||
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::logging::LEVEL_PROGRESS;
|
||||
use crate::sync::TypeCounts;
|
||||
use crate::types::ObjectType;
|
||||
|
||||
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))
|
||||
.map(str::to_owned)
|
||||
.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();
|
||||
while let Some(parent) = frontier.pop() {
|
||||
let url = ctx.endpoints.contact_folder_children(&parent, ctx.top);
|
||||
let children = api::collect_all_values(ctx.client, &url, &[]).map_err(Error::from)?;
|
||||
for c in &children {
|
||||
if let Some(id) = c.get("id").and_then(Value::as_str)
|
||||
&& seen.insert(id.to_owned())
|
||||
{
|
||||
for child in children {
|
||||
let Some(id) = child.get("id").and_then(Value::as_str) else {
|
||||
continue;
|
||||
};
|
||||
if seen.insert(id.to_owned()) {
|
||||
frontier.push(id.to_owned());
|
||||
server.push(child);
|
||||
}
|
||||
}
|
||||
server.extend(children);
|
||||
}
|
||||
|
||||
let local: HashMap<String, i64> =
|
||||
@@ -272,19 +283,32 @@ pub fn reconcile_address_books(
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("Contacts")
|
||||
.to_owned();
|
||||
let is_default = default_id.as_deref() == Some(graph_id);
|
||||
let existing = local.get(graph_id).copied();
|
||||
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(
|
||||
"UPDATE address_books SET name = ?1 WHERE id = ?2",
|
||||
params![name, id],
|
||||
"UPDATE address_books SET name = ?1, is_default = ?2 WHERE id = ?3",
|
||||
params![name, is_default as i64, id],
|
||||
)?;
|
||||
counts.fetched += 1;
|
||||
id
|
||||
} else {
|
||||
let is_default = crate::db::defaults::unique_default(
|
||||
&tx,
|
||||
ObjectType::AddressBook,
|
||||
is_default,
|
||||
None,
|
||||
)?;
|
||||
tx.execute(
|
||||
"INSERT INTO address_books (name, sort_order, is_subscribed)
|
||||
VALUES (?1, 0, 1)",
|
||||
params![name],
|
||||
"INSERT INTO address_books (name, sort_order, is_default, is_subscribed)
|
||||
VALUES (?1, 0, ?2, 1)",
|
||||
params![name, is_default as i64],
|
||||
)?;
|
||||
let new_id = tx.last_insert_rowid();
|
||||
exchange_graph_ids::insert(
|
||||
@@ -317,6 +341,30 @@ pub fn reconcile_address_books(
|
||||
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> {
|
||||
let url = ctx.endpoints.mailbox_settings_timezone();
|
||||
let value = ctx.client.get_json(&url).ok()?;
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
use rusqlite::{Connection, Transaction, params};
|
||||
use serde_json::json;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::db::{blobs, exchange_graph_ids};
|
||||
use crate::error::Error;
|
||||
@@ -35,7 +35,7 @@ pub fn reconcile_all(
|
||||
|
||||
for folder in folders {
|
||||
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,
|
||||
Err(e) => {
|
||||
ctx.logger.warn(&format!(
|
||||
@@ -51,16 +51,21 @@ pub fn reconcile_all(
|
||||
eprintln!(
|
||||
"graph folder {} enumerated {} messages",
|
||||
folder.graph_id,
|
||||
ids.len()
|
||||
stubs.len()
|
||||
);
|
||||
}
|
||||
for id in &ids {
|
||||
server_total.insert(id.clone());
|
||||
let mut new_ids: Vec<(String, String)> = Vec::new();
|
||||
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() {
|
||||
continue;
|
||||
}
|
||||
@@ -82,9 +87,13 @@ fn fetch_and_insert(
|
||||
conn: &mut Connection,
|
||||
ctx: &GraphCoordinator<'_>,
|
||||
folder: &MailFolder,
|
||||
ids: &[String],
|
||||
ids: &[(String, String)],
|
||||
counts: &mut TypeCounts,
|
||||
) -> 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 endpoints: crate::exchange_graph::api::Endpoints = (*ctx.endpoints).clone();
|
||||
type FetchResult = (String, Result<Vec<u8>, GraphError>);
|
||||
@@ -95,7 +104,7 @@ fn fetch_and_insert(
|
||||
Err(e) => (id, Err(e)),
|
||||
}
|
||||
});
|
||||
for id in ids {
|
||||
for (id, _) in ids {
|
||||
pool.submit(id.clone());
|
||||
}
|
||||
let mut tx_opt: Option<Transaction<'_>> = None;
|
||||
@@ -110,7 +119,11 @@ fn fetch_and_insert(
|
||||
tx_opt = Some(conn.unchecked_transaction()?);
|
||||
}
|
||||
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;
|
||||
if in_batch >= CHUNK_SIZE {
|
||||
if let Some(t) = tx_opt.take() {
|
||||
@@ -141,13 +154,13 @@ fn apply_message_in_tx(
|
||||
folder: &MailFolder,
|
||||
graph_id: &str,
|
||||
bytes: &[u8],
|
||||
keywords: &str,
|
||||
counts: &mut TypeCounts,
|
||||
) -> Result<(), Error> {
|
||||
let (idx, date_header) = email_meta_from_blob(bytes);
|
||||
let message_match = index_to_json(&idx);
|
||||
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 keywords = "[]".to_owned();
|
||||
|
||||
let blob_id = blobs::intern_blob(tx, bytes)?;
|
||||
tx.execute(
|
||||
@@ -167,6 +180,33 @@ fn apply_message_in_tx(
|
||||
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(
|
||||
conn: &mut Connection,
|
||||
source_id: i64,
|
||||
|
||||
Reference in New Issue
Block a user