MS Exchange Graph: Import the default Contacts folder, recover series exceptions (fixes #39)

This commit is contained in:
Maurus Decimus
2026-08-27 19:15:03 +02:00
parent 423b761617
commit e8d98abccb
20 changed files with 1370 additions and 120 deletions
+1
View File
@@ -7,6 +7,7 @@
pub mod calendar;
pub mod contacts;
pub mod coordinator;
pub mod files;
pub mod folders;
pub mod messages;
+130 -11
View File
@@ -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"));
+8 -4
View File
@@ -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;
}
+23 -10
View File
@@ -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);
+438
View File
@@ -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"]);
}
}
+58 -10
View File
@@ -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()?;
+53 -13
View File
@@ -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,