Fix MS Exchange duplicate ids issue

This commit is contained in:
Maurus Decimus
2026-06-04 13:13:02 +02:00
parent e49a7db278
commit 5197ed6a11
7 changed files with 116 additions and 5 deletions
+9
View File
@@ -2,6 +2,15 @@
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.1] - 2026-06-XX
### Added
### Changed
### Fixed
- MS Exchange duplicate ids and incorrect JSCalendar mapping issues.
## [1.0.0] - 2026-05-29 ## [1.0.0] - 2026-05-29
### Added ### Added
Generated
+1 -1
View File
@@ -2937,7 +2937,7 @@ dependencies = [
[[package]] [[package]]
name = "vandelay" name = "vandelay"
version = "1.0.0" version = "1.0.1"
dependencies = [ dependencies = [
"base64", "base64",
"blake3", "blake3",
+1 -1
View File
@@ -1,7 +1,7 @@
[package] [package]
name = "vandelay" name = "vandelay"
description = "JMAP account migration utility" description = "JMAP account migration utility"
version = "1.0.0" version = "1.0.1"
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"
+2 -1
View File
@@ -33,6 +33,7 @@ pub fn reconcile_all(
let local: HashMap<String, i64> = let local: HashMap<String, i64> =
exchange_graph_ids::ids_of_type(conn, ctx.source_id, exchange_graph_ids::CALENDAR_EVENT)?; exchange_graph_ids::ids_of_type(conn, ctx.source_id, exchange_graph_ids::CALENDAR_EVENT)?;
let mut server_total: std::collections::HashSet<String> = std::collections::HashSet::new(); let mut server_total: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut planned: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut any_failure = false; let mut any_failure = false;
for cal in calendars { for cal in calendars {
@@ -55,7 +56,7 @@ 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) { if !local.contains_key(id) && planned.insert(id.to_owned()) {
want_ids.push(id.to_owned()); want_ids.push(id.to_owned());
} }
} }
+2 -1
View File
@@ -30,6 +30,7 @@ pub fn reconcile_all(
let local: HashMap<String, i64> = let local: HashMap<String, i64> =
exchange_graph_ids::ids_of_type(conn, ctx.source_id, exchange_graph_ids::CONTACT_CARD)?; exchange_graph_ids::ids_of_type(conn, ctx.source_id, exchange_graph_ids::CONTACT_CARD)?;
let mut server_total: std::collections::HashSet<String> = std::collections::HashSet::new(); let mut server_total: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut planned: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut any_failure = false; let mut any_failure = false;
for book in books { for book in books {
@@ -60,7 +61,7 @@ pub fn reconcile_all(
} }
let new_ids: Vec<String> = ids let new_ids: Vec<String> = ids
.into_iter() .into_iter()
.filter(|id| !local.contains_key(id)) .filter(|id| !local.contains_key(id) && planned.insert(id.clone()))
.collect(); .collect();
if new_ids.is_empty() { if new_ids.is_empty() {
continue; continue;
+2 -1
View File
@@ -30,6 +30,7 @@ pub fn reconcile_all(
let local: std::collections::HashMap<String, i64> = let local: std::collections::HashMap<String, i64> =
exchange_graph_ids::ids_of_type(conn, ctx.source_id, exchange_graph_ids::EMAIL)?; exchange_graph_ids::ids_of_type(conn, ctx.source_id, exchange_graph_ids::EMAIL)?;
let mut server_total: std::collections::HashSet<String> = std::collections::HashSet::new(); let mut server_total: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut planned: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut any_failure = false; let mut any_failure = false;
for folder in folders { for folder in folders {
@@ -58,7 +59,7 @@ pub fn reconcile_all(
} }
let new_ids: Vec<String> = ids let new_ids: Vec<String> = ids
.into_iter() .into_iter()
.filter(|id| !local.contains_key(id)) .filter(|id| !local.contains_key(id) && planned.insert(id.clone()))
.collect(); .collect();
if new_ids.is_empty() { if new_ids.is_empty() {
continue; continue;
+99
View File
@@ -713,6 +713,105 @@ fn integration_full_run_mail_only_imports_mime_via_value() {
assert_eq!(emails, 1, "expected one email created, got {emails}"); assert_eq!(emails, 1, "expected one email created, got {emails}");
} }
#[test]
fn integration_duplicate_message_id_does_not_abort_run() {
let mut server = Server::new();
let _principal = 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-1","userPrincipalName":"[email protected]"}"#)
.create();
let _folders = server
.mock("GET", "/me/mailFolders?$top=100&includeHiddenFolders=true")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"value":[{"id":"FMAIL","displayName":"Inbox","isHidden":false}]}"#)
.create();
let _children = server
.mock(
"GET",
"/me/mailFolders/FMAIL/childFolders?$top=100&includeHiddenFolders=true",
)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"value":[]}"#)
.create();
let _well_known: Vec<mockito::Mock> = [
"inbox",
"drafts",
"sentitems",
"deleteditems",
"junkemail",
"archive",
]
.iter()
.map(|name| {
let path = format!("/me/mailFolders/{name}?$select=id");
server
.mock("GET", path.as_str())
.with_status(200)
.with_header("content-type", "application/json")
.with_body(if *name == "inbox" {
r#"{"id":"FMAIL"}"#
} else {
r#"{"id":"OTHER"}"#
})
.create()
})
.collect();
let _ids = server
.mock("GET", "/me/mailFolders/FMAIL/messages?$top=100&$select=id")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"value":[{"id":"MSG-1"},{"id":"MSG-1"}]}"#)
.create();
let mime = "From: a@x\r\nTo: b@x\r\nSubject: hi\r\nDate: Tue, 27 May 2026 10:00:00 +0000\r\nMessage-ID: <abc@x>\r\n\r\nhello";
let _value = server
.mock("GET", "/me/messages/MSG-1/$value")
.match_header("accept", "text/plain")
.with_status(200)
.with_header("content-type", "text/plain")
.with_body(mime)
.create();
let tmp = tempfile::NamedTempFile::new().unwrap();
let archive_path = tmp.path().to_owned();
let common = vandelay::sync::CommonConfig {
archive: archive_path.clone(),
threads: 2,
dry_run: false,
max_retries: 0,
allow_invalid_certs: false,
logger: vandelay::logging::Logger::new(0),
};
let config = vandelay::sync::import_exchange_graph::GraphImportConfig {
auth: vandelay::sync::import_exchange_graph::GraphAuth::PreAcquired {
token: make_jwt(9999999999, "[email protected]"),
},
api_base: server.url(),
user_target: None,
mailbox_kind: vandelay::exchange_graph::types::MailboxKind::Primary,
objects: Some(vec![
vandelay::types::ObjectType::Mailbox,
vandelay::types::ObjectType::Email,
]),
event_body_format: vandelay::exchange_graph::types::EventBodyFormat::Text,
graph_connections: 2,
top: 100,
allow_source_change: false,
};
drop(tmp);
let summary = vandelay::sync::import_exchange_graph::run(common, config).unwrap();
let emails = summary
.per_type
.iter()
.find(|(t, _)| *t == "email")
.map(|(_, c)| c.created)
.unwrap_or(0);
assert_eq!(emails, 1, "expected one email created, got {emails}");
}
#[test] #[test]
fn integration_full_run_is_convergent_on_second_invocation() { fn integration_full_run_is_convergent_on_second_invocation() {
let mut server = Server::new(); let mut server = Server::new();