Fix MS Exchange duplicate ids issue
This commit is contained in:
@@ -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
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user