EWS fixes - part 2
This commit is contained in:
@@ -92,35 +92,52 @@ pub fn to_jscontact(raw: &ContactItemRaw) -> Value {
|
|||||||
Value::Object(map_singleton("1", json!({"@type": "Title", "name": t}))),
|
Value::Object(map_singleton("1", json!({"@type": "Title", "name": t}))),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if !raw.emails.is_empty() {
|
{
|
||||||
let mut map = Map::new();
|
let mut map = Map::new();
|
||||||
for (i, (key, addr)) in raw.emails.iter().enumerate() {
|
for (i, (key, addr)) in raw
|
||||||
let entry_id = format!("{}", i + 1);
|
.emails
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, v)| !v.trim().is_empty())
|
||||||
|
.enumerate()
|
||||||
|
{
|
||||||
let mut entry = Map::new();
|
let mut entry = Map::new();
|
||||||
entry.insert("@type".to_owned(), Value::String("EmailAddress".to_owned()));
|
entry.insert("@type".to_owned(), Value::String("EmailAddress".to_owned()));
|
||||||
entry.insert("address".to_owned(), Value::String(addr.clone()));
|
entry.insert("address".to_owned(), Value::String(addr.clone()));
|
||||||
entry.insert("contexts".to_owned(), email_contexts(key));
|
entry.insert("contexts".to_owned(), email_contexts(key));
|
||||||
map.insert(entry_id, Value::Object(entry));
|
map.insert((i + 1).to_string(), Value::Object(entry));
|
||||||
}
|
}
|
||||||
|
if !map.is_empty() {
|
||||||
card.insert("emails".to_owned(), Value::Object(map));
|
card.insert("emails".to_owned(), Value::Object(map));
|
||||||
}
|
}
|
||||||
if !raw.phones.is_empty() {
|
}
|
||||||
|
{
|
||||||
let mut map = Map::new();
|
let mut map = Map::new();
|
||||||
for (i, (key, num)) in raw.phones.iter().enumerate() {
|
for (i, (key, num)) in raw
|
||||||
let entry_id = format!("{}", i + 1);
|
.phones
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, v)| !v.trim().is_empty())
|
||||||
|
.enumerate()
|
||||||
|
{
|
||||||
let mut entry = Map::new();
|
let mut entry = Map::new();
|
||||||
entry.insert("@type".to_owned(), Value::String("Phone".to_owned()));
|
entry.insert("@type".to_owned(), Value::String("Phone".to_owned()));
|
||||||
entry.insert("number".to_owned(), Value::String(num.clone()));
|
entry.insert("number".to_owned(), Value::String(num.clone()));
|
||||||
entry.insert("contexts".to_owned(), phone_contexts(key));
|
entry.insert("contexts".to_owned(), phone_contexts(key));
|
||||||
entry.insert("features".to_owned(), phone_features(key));
|
entry.insert("features".to_owned(), phone_features(key));
|
||||||
map.insert(entry_id, Value::Object(entry));
|
map.insert((i + 1).to_string(), Value::Object(entry));
|
||||||
}
|
}
|
||||||
|
if !map.is_empty() {
|
||||||
card.insert("phones".to_owned(), Value::Object(map));
|
card.insert("phones".to_owned(), Value::Object(map));
|
||||||
}
|
}
|
||||||
if !raw.ims.is_empty() {
|
}
|
||||||
|
{
|
||||||
let mut map = Map::new();
|
let mut map = Map::new();
|
||||||
for (i, (key, addr)) in raw.ims.iter().enumerate() {
|
for (i, (key, addr)) in raw
|
||||||
let entry_id = format!("{}", i + 1);
|
.ims
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, v)| !v.trim().is_empty())
|
||||||
|
.enumerate()
|
||||||
|
{
|
||||||
|
let entry_id = (i + 1).to_string();
|
||||||
let mut entry = Map::new();
|
let mut entry = Map::new();
|
||||||
entry.insert(
|
entry.insert(
|
||||||
"@type".to_owned(),
|
"@type".to_owned(),
|
||||||
@@ -132,8 +149,10 @@ pub fn to_jscontact(raw: &ContactItemRaw) -> Value {
|
|||||||
}
|
}
|
||||||
map.insert(entry_id, Value::Object(entry));
|
map.insert(entry_id, Value::Object(entry));
|
||||||
}
|
}
|
||||||
|
if !map.is_empty() {
|
||||||
card.insert("onlineServices".to_owned(), Value::Object(map));
|
card.insert("onlineServices".to_owned(), Value::Object(map));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if !raw.addresses.is_empty() {
|
if !raw.addresses.is_empty() {
|
||||||
let pref_key = raw
|
let pref_key = raw
|
||||||
.postal_address_index
|
.postal_address_index
|
||||||
@@ -185,7 +204,7 @@ pub fn to_jscontact(raw: &ContactItemRaw) -> Value {
|
|||||||
}
|
}
|
||||||
card.insert("keywords".to_owned(), Value::Object(keywords));
|
card.insert("keywords".to_owned(), Value::Object(keywords));
|
||||||
}
|
}
|
||||||
if let Some(notes) = raw.notes.as_ref() {
|
if let Some(notes) = raw.notes.as_ref().filter(|n| !n.trim().is_empty()) {
|
||||||
card.insert(
|
card.insert(
|
||||||
"notes".to_owned(),
|
"notes".to_owned(),
|
||||||
Value::Object(map_singleton("1", json!({"@type": "Note", "note": notes}))),
|
Value::Object(map_singleton("1", json!({"@type": "Note", "note": notes}))),
|
||||||
@@ -341,6 +360,29 @@ mod tests {
|
|||||||
assert_eq!(v["phones"]["1"]["contexts"]["work"], true);
|
assert_eq!(v["phones"]["1"]["contexts"]["work"], true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_body_and_empty_im_entry_are_omitted() {
|
||||||
|
let raw = ContactItemRaw {
|
||||||
|
display_name: Some("Random Person".to_owned()),
|
||||||
|
notes: Some(String::new()),
|
||||||
|
ims: vec![("ImAddress1".to_owned(), String::new())],
|
||||||
|
emails: vec![("EmailAddress1".to_owned(), " ".to_owned())],
|
||||||
|
phones: vec![("MobilePhone".to_owned(), "+17801234567".to_owned())],
|
||||||
|
..ContactItemRaw::default()
|
||||||
|
};
|
||||||
|
let v = to_jscontact(&raw);
|
||||||
|
assert!(v.get("notes").is_none(), "empty body must not emit notes");
|
||||||
|
assert!(
|
||||||
|
v.get("onlineServices").is_none(),
|
||||||
|
"empty IM entry must not emit onlineServices"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
v.get("emails").is_none(),
|
||||||
|
"whitespace-only email must not emit emails"
|
||||||
|
);
|
||||||
|
assert_eq!(v["phones"]["1"]["number"], "+17801234567");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn synthetic_uid_is_stable() {
|
fn synthetic_uid_is_stable() {
|
||||||
let u1 = synthetic_uid("ITEM-1");
|
let u1 = synthetic_uid("ITEM-1");
|
||||||
|
|||||||
@@ -956,6 +956,7 @@ pub fn parse_message_item(inner_xml: &str) -> Result<MessageItem, EwsError> {
|
|||||||
let mut mime_charset: Option<String> = None;
|
let mut mime_charset: Option<String> = None;
|
||||||
let mut category_collecting = false;
|
let mut category_collecting = false;
|
||||||
let mut in_flag = false;
|
let mut in_flag = false;
|
||||||
|
let mut in_flagstatus_ext = false;
|
||||||
let mut cur = String::new();
|
let mut cur = String::new();
|
||||||
loop {
|
loop {
|
||||||
buf.clear();
|
buf.clear();
|
||||||
@@ -999,6 +1000,13 @@ pub fn parse_message_item(inner_xml: &str) -> Result<MessageItem, EwsError> {
|
|||||||
in_flag = true;
|
in_flag = true;
|
||||||
} else if in_flag && local.eq_ignore_ascii_case(b"FlagStatus") {
|
} else if in_flag && local.eq_ignore_ascii_case(b"FlagStatus") {
|
||||||
current = Some("flagStatus");
|
current = Some("flagStatus");
|
||||||
|
} else if local.eq_ignore_ascii_case(b"ExtendedFieldURI") {
|
||||||
|
if let Some(tag) = attr_value(e, b"PropertyTag") {
|
||||||
|
let t = tag.trim().to_ascii_lowercase();
|
||||||
|
in_flagstatus_ext = t == "0x1090" || t == "4240";
|
||||||
|
}
|
||||||
|
} else if in_flagstatus_ext && local.eq_ignore_ascii_case(b"Value") {
|
||||||
|
current = Some("flagStatusExt");
|
||||||
} else {
|
} else {
|
||||||
current = None;
|
current = None;
|
||||||
}
|
}
|
||||||
@@ -1027,6 +1035,9 @@ pub fn parse_message_item(inner_xml: &str) -> Result<MessageItem, EwsError> {
|
|||||||
}
|
}
|
||||||
"category" => item.categories.push(text),
|
"category" => item.categories.push(text),
|
||||||
"flagStatus" => item.flag_status = Some(text),
|
"flagStatus" => item.flag_status = Some(text),
|
||||||
|
"flagStatusExt" if text.trim() == "2" => {
|
||||||
|
item.flag_status = Some("Flagged".to_owned());
|
||||||
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1035,6 +1046,8 @@ pub fn parse_message_item(inner_xml: &str) -> Result<MessageItem, EwsError> {
|
|||||||
category_collecting = false;
|
category_collecting = false;
|
||||||
} else if lower == b"flag" {
|
} else if lower == b"flag" {
|
||||||
in_flag = false;
|
in_flag = false;
|
||||||
|
} else if lower == b"extendedproperty" {
|
||||||
|
in_flagstatus_ext = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Event::Text(ref t) => {
|
Event::Text(ref t) => {
|
||||||
@@ -2248,6 +2261,64 @@ mod tests {
|
|||||||
assert!(matches!(r[1].response_code, ResponseCode::ItemNotFound));
|
assert!(matches!(r[1].response_code, ResponseCode::ItemNotFound));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pid_tag_flag_status_extended_property_marks_message_flagged() {
|
||||||
|
fn parse_one(message_xml: &str) -> MessageItem {
|
||||||
|
let body = format!(
|
||||||
|
"<m:GetItemResponse{NS}><m:ResponseMessages>\
|
||||||
|
<m:GetItemResponseMessage ResponseClass=\"Success\">\
|
||||||
|
<m:ResponseCode>NoError</m:ResponseCode>\
|
||||||
|
<m:Items>{message_xml}</m:Items>\
|
||||||
|
</m:GetItemResponseMessage></m:ResponseMessages></m:GetItemResponse>"
|
||||||
|
);
|
||||||
|
let r = parse_response_messages(body.as_bytes(), b"GetItemResponseMessage").unwrap();
|
||||||
|
parse_message_item(&r[0].inner_xml).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
let flagged = parse_one(
|
||||||
|
"<t:Message><t:ItemId Id=\"F1\" ChangeKey=\"K\"/>\
|
||||||
|
<t:Subject>Flagged</t:Subject>\
|
||||||
|
<t:ExtendedProperty>\
|
||||||
|
<t:ExtendedFieldURI PropertyTag=\"0x1090\" PropertyType=\"Integer\"/>\
|
||||||
|
<t:Value>2</t:Value>\
|
||||||
|
</t:ExtendedProperty>\
|
||||||
|
<t:IsRead>true</t:IsRead></t:Message>",
|
||||||
|
);
|
||||||
|
assert_eq!(flagged.flag_status.as_deref(), Some("Flagged"));
|
||||||
|
assert_eq!(flagged.is_read, Some(true));
|
||||||
|
|
||||||
|
let cleared = parse_one(
|
||||||
|
"<t:Message><t:ItemId Id=\"F2\" ChangeKey=\"K\"/>\
|
||||||
|
<t:ExtendedProperty>\
|
||||||
|
<t:ExtendedFieldURI PropertyTag=\"0x1090\" PropertyType=\"Integer\"/>\
|
||||||
|
<t:Value>0</t:Value>\
|
||||||
|
</t:ExtendedProperty></t:Message>",
|
||||||
|
);
|
||||||
|
assert_eq!(cleared.flag_status, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn native_item_flag_still_parses_on_exchange_2013_plus() {
|
||||||
|
let body = format!(
|
||||||
|
"<m:GetItemResponse{NS}><m:ResponseMessages>\
|
||||||
|
<m:GetItemResponseMessage ResponseClass=\"Success\">\
|
||||||
|
<m:ResponseCode>NoError</m:ResponseCode>\
|
||||||
|
<m:Items><t:Message><t:ItemId Id=\"M\" ChangeKey=\"K\"/>\
|
||||||
|
<t:Subject>Native</t:Subject>\
|
||||||
|
<t:Flag><t:FlagStatus>Flagged</t:FlagStatus></t:Flag>\
|
||||||
|
<t:IsRead>false</t:IsRead></t:Message></m:Items>\
|
||||||
|
</m:GetItemResponseMessage></m:ResponseMessages></m:GetItemResponse>"
|
||||||
|
);
|
||||||
|
let r = parse_response_messages(body.as_bytes(), b"GetItemResponseMessage").unwrap();
|
||||||
|
let p = parse_message_item(&r[0].inner_xml).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
p.flag_status.as_deref(),
|
||||||
|
Some("Flagged"),
|
||||||
|
"the 2013+ native item:Flag path must be unaffected by the pre-2013 extended-property fallback"
|
||||||
|
);
|
||||||
|
assert_eq!(p.is_read, Some(false));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn sync_folder_items_emits_create_delete_readflag() {
|
fn sync_folder_items_emits_create_delete_readflag() {
|
||||||
let body = format!(
|
let body = format!(
|
||||||
|
|||||||
@@ -92,10 +92,11 @@ pub fn to_jscalendar_rule(raw: &RawRecurrence) -> Option<Value> {
|
|||||||
match raw.range.as_ref() {
|
match raw.range.as_ref() {
|
||||||
Some(RecurrenceRange::NoEnd { .. }) | None => {}
|
Some(RecurrenceRange::NoEnd { .. }) | None => {}
|
||||||
Some(RecurrenceRange::EndDate { end_date, .. }) => {
|
Some(RecurrenceRange::EndDate { end_date, .. }) => {
|
||||||
let local = if end_date.contains('T') {
|
let trimmed = end_date.trim().trim_end_matches('Z');
|
||||||
end_date.clone()
|
let local = if trimmed.contains('T') {
|
||||||
|
trimmed.to_owned()
|
||||||
} else {
|
} else {
|
||||||
format!("{end_date}T23:59:59")
|
format!("{trimmed}T23:59:59")
|
||||||
};
|
};
|
||||||
rule.insert("until".to_owned(), Value::String(local));
|
rule.insert("until".to_owned(), Value::String(local));
|
||||||
}
|
}
|
||||||
@@ -345,6 +346,19 @@ mod tests {
|
|||||||
assert_eq!(rule["bySetPosition"], json!([-1]));
|
assert_eq!(rule["bySetPosition"], json!([-1]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn end_date_with_trailing_z_yields_valid_local_until() {
|
||||||
|
let raw = RawRecurrence {
|
||||||
|
pattern: Some(RecurrencePattern::Daily { interval: 2 }),
|
||||||
|
range: Some(RecurrenceRange::EndDate {
|
||||||
|
start_date: "2026-07-01Z".to_owned(),
|
||||||
|
end_date: "2026-08-01Z".to_owned(),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
let rule = to_jscalendar_rule(&raw).unwrap();
|
||||||
|
assert_eq!(rule["until"], "2026-08-01T23:59:59");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn weekly_every_weekday_expands() {
|
fn weekly_every_weekday_expands() {
|
||||||
let raw = RawRecurrence {
|
let raw = RawRecurrence {
|
||||||
|
|||||||
@@ -148,6 +148,23 @@ impl ServerVersion {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SKIPPED_CONTAINER_CLASSES: [&str; 8] = [
|
||||||
|
"ipf.task",
|
||||||
|
"ipf.journal",
|
||||||
|
"ipf.stickynote",
|
||||||
|
"ipf.configuration",
|
||||||
|
"ipf.storeitem",
|
||||||
|
"ipf.skypeteams",
|
||||||
|
"ipf.files",
|
||||||
|
"ipf.note.outlookhomepage",
|
||||||
|
];
|
||||||
|
|
||||||
|
fn class_matches(lower_class: &str, lower_prefix: &str) -> bool {
|
||||||
|
lower_class == lower_prefix
|
||||||
|
|| (lower_class.starts_with(lower_prefix)
|
||||||
|
&& lower_class[lower_prefix.len()..].starts_with('.'))
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum FolderClass {
|
pub enum FolderClass {
|
||||||
Mail,
|
Mail,
|
||||||
@@ -158,18 +175,25 @@ pub enum FolderClass {
|
|||||||
|
|
||||||
impl FolderClass {
|
impl FolderClass {
|
||||||
pub fn from_ipf(class: &str) -> FolderClass {
|
pub fn from_ipf(class: &str) -> FolderClass {
|
||||||
if class.eq_ignore_ascii_case("IPF.Note") {
|
let lower = class.trim().to_ascii_lowercase();
|
||||||
FolderClass::Mail
|
if class_matches(&lower, "ipf.appointment") {
|
||||||
} else if class.eq_ignore_ascii_case("IPF.Appointment")
|
|
||||||
|| class.starts_with("IPF.Appointment.")
|
|
||||||
{
|
|
||||||
FolderClass::Calendar
|
FolderClass::Calendar
|
||||||
} else if class.eq_ignore_ascii_case("IPF.Contact") || class.starts_with("IPF.Contact.") {
|
} else if class_matches(&lower, "ipf.contact") {
|
||||||
FolderClass::Contacts
|
FolderClass::Contacts
|
||||||
} else {
|
} else if SKIPPED_CONTAINER_CLASSES
|
||||||
|
.iter()
|
||||||
|
.any(|p| class_matches(&lower, p))
|
||||||
|
{
|
||||||
FolderClass::Skipped
|
FolderClass::Skipped
|
||||||
|
} else {
|
||||||
|
FolderClass::Mail
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn is_mail_fallback(class: &str) -> bool {
|
||||||
|
let lower = class.trim().to_ascii_lowercase();
|
||||||
|
FolderClass::from_ipf(class) == FolderClass::Mail && !class_matches(&lower, "ipf.note")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
@@ -300,6 +324,34 @@ mod tests {
|
|||||||
assert_eq!(FolderClass::from_ipf("IPF.Task"), FolderClass::Skipped);
|
assert_eq!(FolderClass::from_ipf("IPF.Task"), FolderClass::Skipped);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn absent_or_unknown_folder_class_falls_back_to_mail() {
|
||||||
|
assert_eq!(FolderClass::from_ipf(""), FolderClass::Mail);
|
||||||
|
assert_eq!(FolderClass::from_ipf(" "), FolderClass::Mail);
|
||||||
|
assert_eq!(FolderClass::from_ipf("IPF.SomethingNew"), FolderClass::Mail);
|
||||||
|
assert!(FolderClass::is_mail_fallback(""));
|
||||||
|
assert!(FolderClass::is_mail_fallback("IPF.SomethingNew"));
|
||||||
|
assert!(!FolderClass::is_mail_fallback("IPF.Note"));
|
||||||
|
assert!(!FolderClass::is_mail_fallback("IPF.Note.OutlookHomepage"));
|
||||||
|
assert!(!FolderClass::is_mail_fallback("IPF.Task"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn internal_and_out_of_scope_classes_are_skipped() {
|
||||||
|
for c in [
|
||||||
|
"IPF.Task",
|
||||||
|
"IPF.Journal",
|
||||||
|
"IPF.StickyNote",
|
||||||
|
"IPF.Configuration",
|
||||||
|
"IPF.StoreItem.PdpProfileV2Secured",
|
||||||
|
"IPF.SkypeTeams.Message",
|
||||||
|
"IPF.Files",
|
||||||
|
"IPF.Note.OutlookHomepage",
|
||||||
|
] {
|
||||||
|
assert_eq!(FolderClass::from_ipf(c), FolderClass::Skipped, "{c}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn server_version_from_build() {
|
fn server_version_from_build() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
@@ -147,6 +147,10 @@ pub fn get_item_body(shape: ItemShape, ids: &[ItemId], version: ServerVersion) -
|
|||||||
out.push_str("<t:FieldURI FieldURI=\"item:ParentFolderId\"/>");
|
out.push_str("<t:FieldURI FieldURI=\"item:ParentFolderId\"/>");
|
||||||
if version >= ServerVersion::Exchange2013 {
|
if version >= ServerVersion::Exchange2013 {
|
||||||
out.push_str("<t:FieldURI FieldURI=\"item:Flag\"/>");
|
out.push_str("<t:FieldURI FieldURI=\"item:Flag\"/>");
|
||||||
|
} else {
|
||||||
|
out.push_str(
|
||||||
|
"<t:ExtendedFieldURI PropertyTag=\"0x1090\" PropertyType=\"Integer\"/>",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
out.push_str("<t:FieldURI FieldURI=\"message:IsReadReceiptRequested\"/>");
|
out.push_str("<t:FieldURI FieldURI=\"message:IsReadReceiptRequested\"/>");
|
||||||
out.push_str("</t:AdditionalProperties>");
|
out.push_str("</t:AdditionalProperties>");
|
||||||
@@ -329,6 +333,14 @@ mod tests {
|
|||||||
!legacy.contains("item:Flag"),
|
!legacy.contains("item:Flag"),
|
||||||
"item:Flag is an Exchange 2013 schema addition; must be omitted on 2010"
|
"item:Flag is an Exchange 2013 schema addition; must be omitted on 2010"
|
||||||
);
|
);
|
||||||
|
assert!(
|
||||||
|
legacy.contains("PropertyTag=\"0x1090\""),
|
||||||
|
"on pre-2013 the follow-up flag must be fetched via the PidTagFlagStatus extended property"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!modern.contains("0x1090"),
|
||||||
|
"the extended-property fallback is only used when item:Flag is unavailable"
|
||||||
|
);
|
||||||
assert!(legacy.contains("item:DateTimeReceived"));
|
assert!(legacy.contains("item:DateTimeReceived"));
|
||||||
assert!(legacy.contains("message:IsReadReceiptRequested"));
|
assert!(legacy.contains("message:IsReadReceiptRequested"));
|
||||||
assert!(
|
assert!(
|
||||||
|
|||||||
@@ -126,9 +126,14 @@ pub fn run(common: CommonConfig, config: EwsImportConfig) -> Result<Summary, Err
|
|||||||
&mut conn,
|
&mut conn,
|
||||||
source_id,
|
source_id,
|
||||||
&plan,
|
&plan,
|
||||||
&mut mailbox_counts,
|
&mut folders::ReconcileCounts {
|
||||||
&mut calendar_counts,
|
mailbox: &mut mailbox_counts,
|
||||||
&mut addressbook_counts,
|
calendar: &mut calendar_counts,
|
||||||
|
addressbook: &mut addressbook_counts,
|
||||||
|
email: &mut email_counts,
|
||||||
|
calendar_event: &mut calendar_event_counts,
|
||||||
|
contact: &mut contact_counts,
|
||||||
|
},
|
||||||
logger,
|
logger,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ use crate::exchange_ews::types::{DistinguishedFolderId, FolderClass, MailboxKind
|
|||||||
use crate::exchange_ews::xml::{
|
use crate::exchange_ews::xml::{
|
||||||
FolderRef, FolderShape, Traversal, find_folder_body, get_folder_body,
|
FolderRef, FolderShape, Traversal, find_folder_body, get_folder_body,
|
||||||
};
|
};
|
||||||
use crate::logging::{LEVEL_PROGRESS, Logger};
|
use crate::logging::{LEVEL_DEFAULT, LEVEL_PROGRESS, Logger};
|
||||||
use crate::sync::TypeCounts;
|
use crate::sync::TypeCounts;
|
||||||
|
|
||||||
pub struct FolderPlan {
|
pub struct FolderPlan {
|
||||||
@@ -55,6 +55,20 @@ pub fn plan_folders(
|
|||||||
let mut contacts = Vec::new();
|
let mut contacts = Vec::new();
|
||||||
for folder in parsed.folders {
|
for folder in parsed.folders {
|
||||||
let class = FolderClass::from_ipf(&folder.folder_class);
|
let class = FolderClass::from_ipf(&folder.folder_class);
|
||||||
|
if class == FolderClass::Mail
|
||||||
|
&& FolderClass::is_mail_fallback(&folder.folder_class)
|
||||||
|
&& logger.enabled(LEVEL_DEFAULT)
|
||||||
|
{
|
||||||
|
let shown = if folder.folder_class.trim().is_empty() {
|
||||||
|
"no"
|
||||||
|
} else {
|
||||||
|
"unrecognized"
|
||||||
|
};
|
||||||
|
eprintln!(
|
||||||
|
"warning: EWS folder {:?} has {shown} FolderClass ({:?}); importing as mail",
|
||||||
|
folder.display_name, folder.folder_class
|
||||||
|
);
|
||||||
|
}
|
||||||
match (mailbox_kind, class) {
|
match (mailbox_kind, class) {
|
||||||
(MailboxKind::Archive, FolderClass::Mail) => {
|
(MailboxKind::Archive, FolderClass::Mail) => {
|
||||||
mail.push(ClassifiedFolder {
|
mail.push(ClassifiedFolder {
|
||||||
@@ -163,13 +177,26 @@ const WELL_KNOWN_ROLES: &[(DistinguishedFolderId, Option<&'static str>)] = &[
|
|||||||
(DistinguishedFolderId::ConversationHistory, None),
|
(DistinguishedFolderId::ConversationHistory, None),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
struct DeleteTarget<'a> {
|
||||||
|
type_name: &'a str,
|
||||||
|
table: &'a str,
|
||||||
|
counts: &'a mut TypeCounts,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ReconcileCounts<'a> {
|
||||||
|
pub mailbox: &'a mut TypeCounts,
|
||||||
|
pub calendar: &'a mut TypeCounts,
|
||||||
|
pub addressbook: &'a mut TypeCounts,
|
||||||
|
pub email: &'a mut TypeCounts,
|
||||||
|
pub calendar_event: &'a mut TypeCounts,
|
||||||
|
pub contact: &'a mut TypeCounts,
|
||||||
|
}
|
||||||
|
|
||||||
pub fn reconcile(
|
pub fn reconcile(
|
||||||
conn: &mut Connection,
|
conn: &mut Connection,
|
||||||
source_id: i64,
|
source_id: i64,
|
||||||
plan: &FolderPlan,
|
plan: &FolderPlan,
|
||||||
mailbox_counts: &mut TypeCounts,
|
counts: &mut ReconcileCounts<'_>,
|
||||||
calendar_counts: &mut TypeCounts,
|
|
||||||
addressbook_counts: &mut TypeCounts,
|
|
||||||
_logger: Logger,
|
_logger: Logger,
|
||||||
) -> Result<(), Error> {
|
) -> Result<(), Error> {
|
||||||
let local_mailbox: HashMap<String, exchange_ews_ids::FolderRow> =
|
let local_mailbox: HashMap<String, exchange_ews_ids::FolderRow> =
|
||||||
@@ -210,7 +237,7 @@ pub fn reconcile(
|
|||||||
source_id,
|
source_id,
|
||||||
id_to_local: &mut id_to_local,
|
id_to_local: &mut id_to_local,
|
||||||
local: &local_mailbox,
|
local: &local_mailbox,
|
||||||
counts: mailbox_counts,
|
counts: &mut *counts.mailbox,
|
||||||
root_folder_id: &plan.root_folder_id,
|
root_folder_id: &plan.root_folder_id,
|
||||||
};
|
};
|
||||||
upsert_mailbox(&mut ctx, folder, role)?;
|
upsert_mailbox(&mut ctx, folder, role)?;
|
||||||
@@ -222,7 +249,7 @@ pub fn reconcile(
|
|||||||
source_id,
|
source_id,
|
||||||
id_to_local: &mut id_to_local,
|
id_to_local: &mut id_to_local,
|
||||||
local: &local_calendar,
|
local: &local_calendar,
|
||||||
counts: calendar_counts,
|
counts: &mut *counts.calendar,
|
||||||
root_folder_id: &plan.root_folder_id,
|
root_folder_id: &plan.root_folder_id,
|
||||||
};
|
};
|
||||||
upsert_calendar(&mut ctx, folder)?;
|
upsert_calendar(&mut ctx, folder)?;
|
||||||
@@ -234,7 +261,7 @@ pub fn reconcile(
|
|||||||
source_id,
|
source_id,
|
||||||
id_to_local: &mut id_to_local,
|
id_to_local: &mut id_to_local,
|
||||||
local: &local_addressbook,
|
local: &local_addressbook,
|
||||||
counts: addressbook_counts,
|
counts: &mut *counts.addressbook,
|
||||||
root_folder_id: &plan.root_folder_id,
|
root_folder_id: &plan.root_folder_id,
|
||||||
};
|
};
|
||||||
upsert_address_book(&mut ctx, folder)?;
|
upsert_address_book(&mut ctx, folder)?;
|
||||||
@@ -243,29 +270,50 @@ pub fn reconcile(
|
|||||||
delete_vanished(
|
delete_vanished(
|
||||||
conn,
|
conn,
|
||||||
source_id,
|
source_id,
|
||||||
exchange_ews_ids::MAILBOX,
|
|
||||||
"mailboxes",
|
|
||||||
&local_mailbox,
|
&local_mailbox,
|
||||||
&server_ids_mail,
|
&server_ids_mail,
|
||||||
mailbox_counts,
|
DeleteTarget {
|
||||||
|
type_name: exchange_ews_ids::MAILBOX,
|
||||||
|
table: "mailboxes",
|
||||||
|
counts: counts.mailbox,
|
||||||
|
},
|
||||||
|
Some(DeleteTarget {
|
||||||
|
type_name: exchange_ews_ids::EMAIL,
|
||||||
|
table: "emails",
|
||||||
|
counts: counts.email,
|
||||||
|
}),
|
||||||
)?;
|
)?;
|
||||||
delete_vanished(
|
delete_vanished(
|
||||||
conn,
|
conn,
|
||||||
source_id,
|
source_id,
|
||||||
exchange_ews_ids::CALENDAR,
|
|
||||||
"calendars",
|
|
||||||
&local_calendar,
|
&local_calendar,
|
||||||
&server_ids_calendar,
|
&server_ids_calendar,
|
||||||
calendar_counts,
|
DeleteTarget {
|
||||||
|
type_name: exchange_ews_ids::CALENDAR,
|
||||||
|
table: "calendars",
|
||||||
|
counts: counts.calendar,
|
||||||
|
},
|
||||||
|
Some(DeleteTarget {
|
||||||
|
type_name: exchange_ews_ids::CALENDAR_EVENT,
|
||||||
|
table: "calendar_events",
|
||||||
|
counts: counts.calendar_event,
|
||||||
|
}),
|
||||||
)?;
|
)?;
|
||||||
delete_vanished(
|
delete_vanished(
|
||||||
conn,
|
conn,
|
||||||
source_id,
|
source_id,
|
||||||
exchange_ews_ids::ADDRESS_BOOK,
|
|
||||||
"address_books",
|
|
||||||
&local_addressbook,
|
&local_addressbook,
|
||||||
&server_ids_addressbook,
|
&server_ids_addressbook,
|
||||||
addressbook_counts,
|
DeleteTarget {
|
||||||
|
type_name: exchange_ews_ids::ADDRESS_BOOK,
|
||||||
|
table: "address_books",
|
||||||
|
counts: counts.addressbook,
|
||||||
|
},
|
||||||
|
Some(DeleteTarget {
|
||||||
|
type_name: exchange_ews_ids::CONTACT_CARD,
|
||||||
|
table: "contact_cards",
|
||||||
|
counts: counts.contact,
|
||||||
|
}),
|
||||||
)?;
|
)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -487,11 +535,10 @@ fn parent_local_id(
|
|||||||
fn delete_vanished(
|
fn delete_vanished(
|
||||||
conn: &mut Connection,
|
conn: &mut Connection,
|
||||||
source_id: i64,
|
source_id: i64,
|
||||||
type_name: &str,
|
|
||||||
table: &str,
|
|
||||||
local: &HashMap<String, exchange_ews_ids::FolderRow>,
|
local: &HashMap<String, exchange_ews_ids::FolderRow>,
|
||||||
server_ids: &[String],
|
server_ids: &[String],
|
||||||
counts: &mut TypeCounts,
|
target: DeleteTarget<'_>,
|
||||||
|
mut child: Option<DeleteTarget<'_>>,
|
||||||
) -> Result<(), Error> {
|
) -> Result<(), Error> {
|
||||||
let server_set: std::collections::HashSet<&str> =
|
let server_set: std::collections::HashSet<&str> =
|
||||||
server_ids.iter().map(String::as_str).collect();
|
server_ids.iter().map(String::as_str).collect();
|
||||||
@@ -500,27 +547,62 @@ fn delete_vanished(
|
|||||||
.filter(|(id, _)| !server_set.contains(id.as_str()))
|
.filter(|(id, _)| !server_set.contains(id.as_str()))
|
||||||
.map(|(id, row)| (id.as_str(), row))
|
.map(|(id, row)| (id.as_str(), row))
|
||||||
.collect();
|
.collect();
|
||||||
if type_name == exchange_ews_ids::MAILBOX {
|
if target.type_name == exchange_ews_ids::MAILBOX {
|
||||||
vanished.sort_by_key(|(_, row)| std::cmp::Reverse(folder_depth(row, local)));
|
vanished.sort_by_key(|(_, row)| std::cmp::Reverse(folder_depth(row, local)));
|
||||||
}
|
}
|
||||||
for (item_id, row) in vanished {
|
for (item_id, row) in vanished {
|
||||||
|
if let Some(c) = child.as_mut() {
|
||||||
|
delete_folder_children(conn, source_id, item_id, c)?;
|
||||||
|
}
|
||||||
let tx = conn
|
let tx = conn
|
||||||
.unchecked_transaction()
|
.unchecked_transaction()
|
||||||
.map_err(|e| Error::Partial(e.to_string()))?;
|
.map_err(|e| Error::Partial(e.to_string()))?;
|
||||||
let result = tx.execute(
|
let result = tx.execute(
|
||||||
&format!("DELETE FROM {table} WHERE id = ?1"),
|
&format!("DELETE FROM {} WHERE id = ?1", target.table),
|
||||||
params![row.local_id],
|
params![row.local_id],
|
||||||
);
|
);
|
||||||
match result {
|
match result {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
exchange_ews_ids::delete_item(&tx, source_id, type_name, item_id)
|
exchange_ews_ids::delete_item(&tx, source_id, target.type_name, item_id)
|
||||||
.map_err(|e| Error::Partial(e.to_string()))?;
|
.map_err(|e| Error::Partial(e.to_string()))?;
|
||||||
tx.commit().map_err(|e| Error::Partial(e.to_string()))?;
|
tx.commit().map_err(|e| Error::Partial(e.to_string()))?;
|
||||||
counts.deleted += 1;
|
target.counts.deleted += 1;
|
||||||
}
|
}
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
let _ = tx.rollback();
|
let _ = tx.rollback();
|
||||||
counts.failed += 1;
|
target.counts.failed += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delete_folder_children(
|
||||||
|
conn: &mut Connection,
|
||||||
|
source_id: i64,
|
||||||
|
folder_item_id: &str,
|
||||||
|
child: &mut DeleteTarget<'_>,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
let kids = exchange_ews_ids::items_in_folder(conn, source_id, child.type_name, folder_item_id)
|
||||||
|
.map_err(|e| Error::Partial(e.to_string()))?;
|
||||||
|
for kid in kids {
|
||||||
|
let tx = conn
|
||||||
|
.unchecked_transaction()
|
||||||
|
.map_err(|e| Error::Partial(e.to_string()))?;
|
||||||
|
let deleted = tx.execute(
|
||||||
|
&format!("DELETE FROM {} WHERE id = ?1", child.table),
|
||||||
|
params![kid.local_id],
|
||||||
|
);
|
||||||
|
match deleted {
|
||||||
|
Ok(_) => {
|
||||||
|
exchange_ews_ids::delete_item(&tx, source_id, child.type_name, &kid.item_id)
|
||||||
|
.map_err(|e| Error::Partial(e.to_string()))?;
|
||||||
|
tx.commit().map_err(|e| Error::Partial(e.to_string()))?;
|
||||||
|
child.counts.deleted += 1;
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
let _ = tx.rollback();
|
||||||
|
child.counts.failed += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -550,6 +632,98 @@ fn folder_depth(
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn vanished_folder_cascades_deletion_of_its_child_items() {
|
||||||
|
let conn = rusqlite::Connection::open_in_memory().unwrap();
|
||||||
|
crate::db::init::apply_schema(&conn).unwrap();
|
||||||
|
let sid = crate::db::sources::upsert_source(
|
||||||
|
&conn,
|
||||||
|
&crate::db::sources::SourceKey {
|
||||||
|
kind: "exchange_ews".to_owned(),
|
||||||
|
session_url: "https://x/EWS/Exchange.asmx".to_owned(),
|
||||||
|
account_id: "u@d".to_owned(),
|
||||||
|
},
|
||||||
|
Some("u"),
|
||||||
|
"u@d",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO blobs (id, hash, data) VALUES (1, X'00', X'01')",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
conn.execute("INSERT INTO mailboxes (id, name) VALUES (10, 'Tasks')", [])
|
||||||
|
.unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO emails (id, blob_id, received_at, mailbox_ids) VALUES (20, 1, '', '[10]')",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
exchange_ews_ids::insert(
|
||||||
|
&conn,
|
||||||
|
sid,
|
||||||
|
exchange_ews_ids::MAILBOX,
|
||||||
|
"",
|
||||||
|
"FOLDER",
|
||||||
|
"CK",
|
||||||
|
10,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
exchange_ews_ids::insert(
|
||||||
|
&conn,
|
||||||
|
sid,
|
||||||
|
exchange_ews_ids::EMAIL,
|
||||||
|
"FOLDER",
|
||||||
|
"MAIL",
|
||||||
|
"CK",
|
||||||
|
20,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let mut conn = conn;
|
||||||
|
let local =
|
||||||
|
exchange_ews_ids::folders_of_type(&conn, sid, exchange_ews_ids::MAILBOX).unwrap();
|
||||||
|
let mut folder_counts = TypeCounts::default();
|
||||||
|
let mut email_counts = TypeCounts::default();
|
||||||
|
delete_vanished(
|
||||||
|
&mut conn,
|
||||||
|
sid,
|
||||||
|
&local,
|
||||||
|
&[],
|
||||||
|
DeleteTarget {
|
||||||
|
type_name: exchange_ews_ids::MAILBOX,
|
||||||
|
table: "mailboxes",
|
||||||
|
counts: &mut folder_counts,
|
||||||
|
},
|
||||||
|
Some(DeleteTarget {
|
||||||
|
type_name: exchange_ews_ids::EMAIL,
|
||||||
|
table: "emails",
|
||||||
|
counts: &mut email_counts,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(folder_counts.deleted, 1);
|
||||||
|
assert_eq!(
|
||||||
|
email_counts.deleted, 1,
|
||||||
|
"child email must be cascade-deleted"
|
||||||
|
);
|
||||||
|
let mailboxes: i64 = conn
|
||||||
|
.query_row("SELECT count(*) FROM mailboxes", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
let emails: i64 = conn
|
||||||
|
.query_row("SELECT count(*) FROM emails", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
let orphan_idmap: i64 = conn
|
||||||
|
.query_row("SELECT count(*) FROM sync_id_exchange_ews", [], |r| {
|
||||||
|
r.get(0)
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(mailboxes, 0);
|
||||||
|
assert_eq!(emails, 0, "no orphaned email row may remain");
|
||||||
|
assert_eq!(orphan_idmap, 0, "both idmap rows must be removed");
|
||||||
|
}
|
||||||
|
|
||||||
fn row(id: &str, parent_ews_id: &str, local_id: i64) -> exchange_ews_ids::FolderRow {
|
fn row(id: &str, parent_ews_id: &str, local_id: i64) -> exchange_ews_ids::FolderRow {
|
||||||
exchange_ews_ids::FolderRow {
|
exchange_ews_ids::FolderRow {
|
||||||
item_id: id.to_owned(),
|
item_id: id.to_owned(),
|
||||||
|
|||||||
Reference in New Issue
Block a user