v1.0.9
This commit is contained in:
+1
-1
@@ -127,7 +127,7 @@ fn try_treat_url_as_home_or_collection(
|
||||
let collections = match propfind_collections(client, kind, url, &body) {
|
||||
Ok(c) => c,
|
||||
Err(DiscoveryError::Transport(JmapError::HttpStatus { status, .. }))
|
||||
if (400..500).contains(&status) =>
|
||||
if (400..600).contains(&status) =>
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use crate::types::ObjectType;
|
||||
use rusqlite::{Connection, params};
|
||||
|
||||
pub fn unique_default(
|
||||
conn: &Connection,
|
||||
ty: ObjectType,
|
||||
is_default: bool,
|
||||
exclude_id: Option<i64>,
|
||||
) -> rusqlite::Result<bool> {
|
||||
if !is_default {
|
||||
return Ok(false);
|
||||
}
|
||||
let Some(table) = table_for(ty) else {
|
||||
return Ok(false);
|
||||
};
|
||||
let taken: bool = conn.query_row(
|
||||
&format!(
|
||||
"SELECT EXISTS(SELECT 1 FROM {table} \
|
||||
WHERE is_default = 1 AND (?1 IS NULL OR id != ?1))"
|
||||
),
|
||||
params![exclude_id],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
Ok(!taken)
|
||||
}
|
||||
|
||||
fn table_for(ty: ObjectType) -> Option<&'static str> {
|
||||
match ty {
|
||||
ObjectType::AddressBook => Some("address_books"),
|
||||
ObjectType::Calendar => Some("calendars"),
|
||||
ObjectType::ParticipantIdentity => Some("participant_identities"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::init;
|
||||
|
||||
fn mem() -> Connection {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
init::apply_schema(&conn).unwrap();
|
||||
conn
|
||||
}
|
||||
|
||||
fn insert_book(conn: &Connection, name: &str, is_default: bool) -> i64 {
|
||||
conn.execute(
|
||||
"INSERT INTO address_books (name, sort_order, is_default, is_subscribed)
|
||||
VALUES (?1, 0, ?2, 1)",
|
||||
params![name, is_default as i64],
|
||||
)
|
||||
.unwrap();
|
||||
conn.last_insert_rowid()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_claimant_keeps_the_default_second_is_dropped() {
|
||||
let conn = mem();
|
||||
let first = unique_default(&conn, ObjectType::AddressBook, true, None).unwrap();
|
||||
assert!(first);
|
||||
insert_book(&conn, "Contacts", first);
|
||||
|
||||
assert!(
|
||||
!unique_default(&conn, ObjectType::AddressBook, true, None).unwrap(),
|
||||
"an archive must never hold two default address books"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_default_stays_non_default() {
|
||||
let conn = mem();
|
||||
assert!(!unique_default(&conn, ObjectType::AddressBook, false, None).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_type_has_its_own_default() {
|
||||
let conn = mem();
|
||||
insert_book(&conn, "Contacts", true);
|
||||
assert!(
|
||||
unique_default(&conn, ObjectType::Calendar, true, None).unwrap(),
|
||||
"a default address book must not block a default calendar"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_excludes_its_own_row() {
|
||||
let conn = mem();
|
||||
let id = insert_book(&conn, "Contacts", true);
|
||||
assert!(unique_default(&conn, ObjectType::AddressBook, true, Some(id)).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_detects_another_holder() {
|
||||
let conn = mem();
|
||||
insert_book(&conn, "Contacts", true);
|
||||
let other = insert_book(&conn, "GAL", false);
|
||||
assert!(!unique_default(&conn, ObjectType::AddressBook, true, Some(other)).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn types_without_a_default_column_are_never_default() {
|
||||
let conn = mem();
|
||||
assert!(!unique_default(&conn, ObjectType::Mailbox, true, None).unwrap());
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
pub mod blobs;
|
||||
pub mod dav_ids;
|
||||
pub mod defaults;
|
||||
pub mod exchange_ews_ids;
|
||||
pub mod exchange_graph_ids;
|
||||
pub mod ids;
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
const ATEXT_SYMBOLS: &[u8] = b"!#$%&'*+-/=?^_`{|}~";
|
||||
const MAX_LOCAL_PART: usize = 64;
|
||||
const MAX_DOMAIN: usize = 255;
|
||||
const MAX_LABEL: usize = 63;
|
||||
|
||||
pub fn as_smtp_address(raw: &str) -> Option<String> {
|
||||
let candidate = normalise(raw)?;
|
||||
is_addr_spec(&candidate).then_some(candidate)
|
||||
}
|
||||
|
||||
pub fn extract_smtp_address(raw: &str) -> Option<String> {
|
||||
let candidate = normalise(raw)?;
|
||||
if !is_addr_spec(&candidate) {
|
||||
return None;
|
||||
}
|
||||
let domain = candidate.split('@').next_back()?;
|
||||
psl::suffix(domain.as_bytes())
|
||||
.is_some_and(|s| s.is_known())
|
||||
.then_some(candidate)
|
||||
}
|
||||
|
||||
fn normalise(raw: &str) -> Option<String> {
|
||||
let mut value = raw.trim();
|
||||
if let Some(open) = value.rfind('<')
|
||||
&& let Some(close) = value.rfind('>')
|
||||
&& open < close
|
||||
{
|
||||
value = value[open + 1..close].trim();
|
||||
}
|
||||
for prefix in ["mailto:", "smtp:"] {
|
||||
if value.len() > prefix.len() && value[..prefix.len()].eq_ignore_ascii_case(prefix) {
|
||||
value = value[prefix.len()..].trim();
|
||||
}
|
||||
}
|
||||
(!value.is_empty()).then(|| value.to_owned())
|
||||
}
|
||||
|
||||
fn is_addr_spec(value: &str) -> bool {
|
||||
if !value.is_ascii() || value.starts_with('/') {
|
||||
return false;
|
||||
}
|
||||
let Some((local, domain)) = value.split_once('@') else {
|
||||
return false;
|
||||
};
|
||||
!domain.contains('@') && is_local_part(local) && is_domain(domain)
|
||||
}
|
||||
|
||||
fn is_local_part(local: &str) -> bool {
|
||||
if local.is_empty()
|
||||
|| local.len() > MAX_LOCAL_PART
|
||||
|| local.starts_with('.')
|
||||
|| local.ends_with('.')
|
||||
|| local.contains("..")
|
||||
{
|
||||
return false;
|
||||
}
|
||||
local
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || b == b'.' || ATEXT_SYMBOLS.contains(&b))
|
||||
}
|
||||
|
||||
fn is_domain(domain: &str) -> bool {
|
||||
if domain.is_empty() || domain.len() > MAX_DOMAIN {
|
||||
return false;
|
||||
}
|
||||
domain.split('.').all(|label| {
|
||||
!label.is_empty()
|
||||
&& label.len() <= MAX_LABEL
|
||||
&& !label.starts_with('-')
|
||||
&& !label.ends_with('-')
|
||||
&& label
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const EXCHANGE_DN: &str = "/o=ExchangeLabs/ou=Exchange Administrative Group \
|
||||
(FYDIBOHF23SPDLT)/cn=Recipients/cn=bdc77b18152647a29d28ce1188376dc9-kristina";
|
||||
|
||||
#[test]
|
||||
fn plain_addresses_are_accepted() {
|
||||
assert_eq!(
|
||||
as_smtp_address("[email protected]").as_deref(),
|
||||
Some("[email protected]")
|
||||
);
|
||||
assert_eq!(
|
||||
as_smtp_address(" [email protected] ").as_deref(),
|
||||
Some("[email protected]")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn internal_only_tlds_are_kept_when_the_server_says_smtp() {
|
||||
assert_eq!(
|
||||
as_smtp_address("[email protected]").as_deref(),
|
||||
Some("[email protected]")
|
||||
);
|
||||
assert!(
|
||||
extract_smtp_address("[email protected]").is_none(),
|
||||
"an unknown suffix is not enough to override the routing type"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn routing_prefixes_and_display_names_are_stripped() {
|
||||
assert_eq!(
|
||||
as_smtp_address("SMTP:[email protected]").as_deref(),
|
||||
Some("[email protected]")
|
||||
);
|
||||
assert_eq!(
|
||||
as_smtp_address("mailto:[email protected]").as_deref(),
|
||||
Some("[email protected]")
|
||||
);
|
||||
assert_eq!(
|
||||
as_smtp_address("Jane Doe <[email protected]>").as_deref(),
|
||||
Some("[email protected]")
|
||||
);
|
||||
assert_eq!(
|
||||
extract_smtp_address("SMTP:[email protected]").as_deref(),
|
||||
Some("[email protected]")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entra_guest_upns_are_accepted() {
|
||||
let guest = "bob_contoso.com#EXT#@fabrikam.onmicrosoft.com";
|
||||
assert_eq!(as_smtp_address(guest).as_deref(), Some(guest));
|
||||
assert_eq!(extract_smtp_address(guest).as_deref(), Some(guest));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_exchange_distinguished_names_are_rejected() {
|
||||
assert!(as_smtp_address(EXCHANGE_DN).is_none());
|
||||
assert!(extract_smtp_address(EXCHANGE_DN).is_none());
|
||||
assert!(
|
||||
as_smtp_address(
|
||||
"/O=HOSTING/OU=EXCHANGE ADMINISTRATIVE GROUP (FYDIBOHF23SPDLT)/CN=RECIPIENTS/CN=abc"
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_values_are_rejected() {
|
||||
for bad in [
|
||||
"",
|
||||
" ",
|
||||
"no-at-sign",
|
||||
"@example.com",
|
||||
"user@",
|
||||
"user@@example.com",
|
||||
"two [email protected]",
|
||||
"[email protected]",
|
||||
"[email protected]",
|
||||
"[email protected]",
|
||||
"[email protected]",
|
||||
"[email protected]",
|
||||
"ü[email protected]",
|
||||
] {
|
||||
assert!(as_smtp_address(bad).is_none(), "must reject {bad:?}");
|
||||
assert!(extract_smtp_address(bad).is_none(), "must reject {bad:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn over_long_parts_are_rejected() {
|
||||
let long_local = format!("{}@example.com", "a".repeat(65));
|
||||
assert!(as_smtp_address(&long_local).is_none());
|
||||
let long_label = format!("user@{}.com", "a".repeat(64));
|
||||
assert!(as_smtp_address(&long_label).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dotless_domains_are_kept_when_the_server_says_smtp() {
|
||||
assert_eq!(
|
||||
as_smtp_address("postmaster@localhost").as_deref(),
|
||||
Some("postmaster@localhost"),
|
||||
"RFC 5321 allows a bare domain and old on-prem setups use them"
|
||||
);
|
||||
assert!(extract_smtp_address("postmaster@localhost").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_suffix_is_required_only_by_the_strict_extractor() {
|
||||
assert!(extract_smtp_address("[email protected]").is_some());
|
||||
assert!(extract_smtp_address("[email protected]").is_some());
|
||||
assert!(extract_smtp_address("[email protected]").is_none());
|
||||
assert!(as_smtp_address("[email protected]").is_some());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
const END_OF_DAY: &str = "23:59:59";
|
||||
|
||||
pub fn recurrence_until(raw: &str) -> Option<String> {
|
||||
let trimmed = raw.trim();
|
||||
let date = trimmed.get(..10).filter(|d| is_calendar_date(d))?;
|
||||
let time = match trimmed.get(10..) {
|
||||
Some(rest) if rest.starts_with('T') || rest.starts_with('t') => {
|
||||
clock_time(&rest[1..]).unwrap_or_else(|| END_OF_DAY.to_owned())
|
||||
}
|
||||
_ => END_OF_DAY.to_owned(),
|
||||
};
|
||||
Some(format!("{date}T{time}"))
|
||||
}
|
||||
|
||||
fn is_calendar_date(value: &str) -> bool {
|
||||
let b = value.as_bytes();
|
||||
b.len() == 10
|
||||
&& b[..4].iter().all(u8::is_ascii_digit)
|
||||
&& b[4] == b'-'
|
||||
&& b[5..7].iter().all(u8::is_ascii_digit)
|
||||
&& b[7] == b'-'
|
||||
&& b[8..10].iter().all(u8::is_ascii_digit)
|
||||
}
|
||||
|
||||
fn clock_time(rest: &str) -> Option<String> {
|
||||
let end = rest
|
||||
.find(['Z', 'z', '+'])
|
||||
.or_else(|| rest.find('-'))
|
||||
.unwrap_or(rest.len());
|
||||
let raw = &rest[..end];
|
||||
let mut parts = raw.split(':');
|
||||
let hour = two_digits(parts.next()?)?;
|
||||
let minute = two_digits(parts.next().unwrap_or("00"))?;
|
||||
let second = match parts.next() {
|
||||
Some(s) => two_digits(s.split('.').next().unwrap_or("00"))?,
|
||||
None => "00".to_owned(),
|
||||
};
|
||||
Some(format!("{hour}:{minute}:{second}"))
|
||||
}
|
||||
|
||||
fn two_digits(value: &str) -> Option<String> {
|
||||
let v = value.trim();
|
||||
if v.len() == 2 && v.bytes().all(|b| b.is_ascii_digit()) {
|
||||
Some(v.to_owned())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn bare_date_gets_end_of_day() {
|
||||
assert_eq!(
|
||||
recurrence_until("2026-08-01").as_deref(),
|
||||
Some("2026-08-01T23:59:59")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn utc_designator_is_dropped() {
|
||||
assert_eq!(
|
||||
recurrence_until("2026-08-01Z").as_deref(),
|
||||
Some("2026-08-01T23:59:59")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numeric_offset_is_dropped_not_embedded() {
|
||||
assert_eq!(
|
||||
recurrence_until("2021-09-30-06:00").as_deref(),
|
||||
Some("2021-09-30T23:59:59")
|
||||
);
|
||||
assert_eq!(
|
||||
recurrence_until("2021-09-30+02:00").as_deref(),
|
||||
Some("2021-09-30T23:59:59")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn date_time_keeps_its_clock_time() {
|
||||
assert_eq!(
|
||||
recurrence_until("2021-09-30T15:15:00").as_deref(),
|
||||
Some("2021-09-30T15:15:00")
|
||||
);
|
||||
assert_eq!(
|
||||
recurrence_until("2021-09-30T15:15:00Z").as_deref(),
|
||||
Some("2021-09-30T15:15:00")
|
||||
);
|
||||
assert_eq!(
|
||||
recurrence_until("2021-09-30T15:15:00-06:00").as_deref(),
|
||||
Some("2021-09-30T15:15:00")
|
||||
);
|
||||
assert_eq!(
|
||||
recurrence_until("2021-09-30T15:15:00.123Z").as_deref(),
|
||||
Some("2021-09-30T15:15:00")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_clock_time_is_padded_to_seconds() {
|
||||
assert_eq!(
|
||||
recurrence_until("2021-09-30T15:15").as_deref(),
|
||||
Some("2021-09-30T15:15:00")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn surrounding_whitespace_is_ignored() {
|
||||
assert_eq!(
|
||||
recurrence_until(" 2026-08-01Z \n").as_deref(),
|
||||
Some("2026-08-01T23:59:59")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unusable_input_yields_none() {
|
||||
assert_eq!(recurrence_until(""), None);
|
||||
assert_eq!(recurrence_until("not-a-date"), None);
|
||||
assert_eq!(recurrence_until("2026-8-1"), None);
|
||||
}
|
||||
}
|
||||
@@ -4,5 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
pub mod address;
|
||||
pub mod date;
|
||||
pub mod jscalendar;
|
||||
pub mod tz;
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use crate::exchange::address::{as_smtp_address, extract_smtp_address};
|
||||
use crate::exchange::jscalendar::{
|
||||
drop_calendar_address_dependents, is_override_ignored, synthetic_attendee_address,
|
||||
};
|
||||
@@ -245,8 +246,13 @@ fn resolve_calendar_address(
|
||||
name: Option<&str>,
|
||||
) -> Option<(String, Option<String>)> {
|
||||
if let Some(addr) = address.filter(|a| !a.trim().is_empty()) {
|
||||
if is_smtp_routing(routing_type) {
|
||||
return Some((format!("mailto:{addr}"), Some(addr.to_owned())));
|
||||
let smtp = if is_smtp_routing(routing_type) {
|
||||
as_smtp_address(addr).or_else(|| extract_smtp_address(addr))
|
||||
} else {
|
||||
extract_smtp_address(addr)
|
||||
};
|
||||
if let Some(smtp) = smtp {
|
||||
return Some((format!("mailto:{smtp}"), Some(smtp)));
|
||||
}
|
||||
return Some((synthetic_attendee_address(addr), None));
|
||||
}
|
||||
@@ -868,6 +874,83 @@ mod tests {
|
||||
assert_eq!(v["showWithoutTime"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ex_routing_with_a_real_address_keeps_the_address() {
|
||||
let raw = CalendarItemRaw {
|
||||
id: crate::exchange_ews::types::ItemId::new("M", ""),
|
||||
uid: Some("uid-ex".to_owned()),
|
||||
start: Some("2025-06-15T14:00:00Z".to_owned()),
|
||||
end: Some("2025-06-15T15:00:00Z".to_owned()),
|
||||
organizer_smtp: Some("[email protected]".to_owned()),
|
||||
organizer_routing_type: Some("EX".to_owned()),
|
||||
required_attendees: vec![crate::exchange_ews::parse::RawAttendee {
|
||||
email: Some("SMTP:[email protected]".to_owned()),
|
||||
routing_type: Some("EX".to_owned()),
|
||||
name: None,
|
||||
response_type: Some("Accept".to_owned()),
|
||||
}],
|
||||
..CalendarItemRaw::default()
|
||||
};
|
||||
let v = to_jscalendar(&raw).data;
|
||||
assert_eq!(
|
||||
v["organizerCalendarAddress"], "mailto:[email protected]",
|
||||
"a usable address must not be discarded because RoutingType says EX"
|
||||
);
|
||||
let participants = v["participants"].as_object().unwrap();
|
||||
assert!(
|
||||
participants
|
||||
.values()
|
||||
.any(|p| p["calendarAddress"] == "mailto:[email protected]"),
|
||||
"the SMTP: prefix must be stripped, got {participants:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_distinguished_name_still_becomes_a_synthetic_address() {
|
||||
let dn = "/o=ExchangeLabs/ou=Exchange Administrative Group \
|
||||
(FYDIBOHF23SPDLT)/cn=Recipients/cn=bdc77b18152647a29d28ce1188376dc9-kristina";
|
||||
let raw = CalendarItemRaw {
|
||||
id: crate::exchange_ews::types::ItemId::new("M", ""),
|
||||
uid: Some("uid-dn".to_owned()),
|
||||
start: Some("2025-06-15T14:00:00Z".to_owned()),
|
||||
end: Some("2025-06-15T15:00:00Z".to_owned()),
|
||||
organizer_smtp: Some(dn.to_owned()),
|
||||
organizer_routing_type: Some("EX".to_owned()),
|
||||
..CalendarItemRaw::default()
|
||||
};
|
||||
let v = to_jscalendar(&raw).data;
|
||||
let addr = v["organizerCalendarAddress"].as_str().unwrap();
|
||||
assert!(
|
||||
addr.starts_with("urn:x-vandelay:attendee:"),
|
||||
"an unresolvable directory reference stays synthetic, got {addr}"
|
||||
);
|
||||
let participants = v["participants"].as_object().unwrap();
|
||||
assert!(
|
||||
participants.values().all(|p| p.get("email").is_none()),
|
||||
"no email property is invented for a directory reference"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smtp_routing_with_an_unusable_address_does_not_produce_a_broken_mailto() {
|
||||
let raw = CalendarItemRaw {
|
||||
id: crate::exchange_ews::types::ItemId::new("M", ""),
|
||||
uid: Some("uid-bad".to_owned()),
|
||||
start: Some("2025-06-15T14:00:00Z".to_owned()),
|
||||
end: Some("2025-06-15T15:00:00Z".to_owned()),
|
||||
organizer_smtp: Some("Kristina Morgental".to_owned()),
|
||||
organizer_routing_type: Some("SMTP".to_owned()),
|
||||
..CalendarItemRaw::default()
|
||||
};
|
||||
let v = to_jscalendar(&raw).data;
|
||||
assert!(
|
||||
v["organizerCalendarAddress"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.starts_with("urn:x-vandelay:attendee:")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attendees_get_required_or_optional_role() {
|
||||
let raw = CalendarItemRaw {
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use crate::exchange::date::recurrence_until;
|
||||
use crate::exchange_ews::parse::{RawRecurrence, RecurrencePattern, RecurrenceRange};
|
||||
|
||||
pub fn to_jscalendar_rule(raw: &RawRecurrence) -> Option<Value> {
|
||||
@@ -92,13 +93,9 @@ pub fn to_jscalendar_rule(raw: &RawRecurrence) -> Option<Value> {
|
||||
match raw.range.as_ref() {
|
||||
Some(RecurrenceRange::NoEnd { .. }) | None => {}
|
||||
Some(RecurrenceRange::EndDate { end_date, .. }) => {
|
||||
let trimmed = end_date.trim().trim_end_matches('Z');
|
||||
let local = if trimmed.contains('T') {
|
||||
trimmed.to_owned()
|
||||
} else {
|
||||
format!("{trimmed}T23:59:59")
|
||||
};
|
||||
rule.insert("until".to_owned(), Value::String(local));
|
||||
if let Some(local) = recurrence_until(end_date) {
|
||||
rule.insert("until".to_owned(), Value::String(local));
|
||||
}
|
||||
}
|
||||
Some(RecurrenceRange::Numbered {
|
||||
number_of_occurrences,
|
||||
@@ -287,6 +284,35 @@ mod tests {
|
||||
assert_eq!(rule["byMonthDay"][0], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn end_date_with_numeric_offset_yields_valid_local_until() {
|
||||
let raw = RawRecurrence {
|
||||
pattern: Some(RecurrencePattern::Weekly {
|
||||
interval: 2,
|
||||
days_of_week: vec!["Wednesday".to_owned()],
|
||||
}),
|
||||
range: Some(RecurrenceRange::EndDate {
|
||||
start_date: "2021-08-04-06:00".to_owned(),
|
||||
end_date: "2021-09-30-06:00".to_owned(),
|
||||
}),
|
||||
};
|
||||
let rule = to_jscalendar_rule(&raw).expect("rule");
|
||||
assert_eq!(rule["until"], "2021-09-30T23:59:59");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unusable_end_date_omits_until_rather_than_emitting_garbage() {
|
||||
let raw = RawRecurrence {
|
||||
pattern: Some(RecurrencePattern::Daily { interval: 1 }),
|
||||
range: Some(RecurrenceRange::EndDate {
|
||||
start_date: String::new(),
|
||||
end_date: "not-a-date".to_owned(),
|
||||
}),
|
||||
};
|
||||
let rule = to_jscalendar_rule(&raw).expect("rule");
|
||||
assert!(rule.get("until").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_end_range_emits_no_until_or_count() {
|
||||
let raw = RawRecurrence {
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use crate::exchange::address::{as_smtp_address, extract_smtp_address};
|
||||
use crate::exchange::jscalendar::{drop_calendar_address_dependents, synthetic_attendee_address};
|
||||
use crate::exchange::tz::resolve_to_iana;
|
||||
use crate::exchange_graph::error::GraphError;
|
||||
@@ -356,8 +357,10 @@ fn non_empty(email: &Value, key: &str) -> Option<String> {
|
||||
|
||||
fn resolve_calendar_address(email: &Value) -> Option<(String, Option<String>)> {
|
||||
if let Some(addr) = non_empty(email, "address") {
|
||||
let cal_addr = format!("mailto:{addr}");
|
||||
return Some((cal_addr, Some(addr)));
|
||||
if let Some(smtp) = as_smtp_address(&addr).or_else(|| extract_smtp_address(&addr)) {
|
||||
return Some((format!("mailto:{smtp}"), Some(smtp)));
|
||||
}
|
||||
return Some((synthetic_attendee_address(&addr), None));
|
||||
}
|
||||
non_empty(email, "name").map(|name| (synthetic_attendee_address(&name), None))
|
||||
}
|
||||
@@ -663,6 +666,64 @@ mod tests {
|
||||
assert_eq!(participants["att-1"]["roles"]["required"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hybrid_x500_address_is_not_emitted_as_a_mailto() {
|
||||
let mut v = sample();
|
||||
v["attendees"] = json!([
|
||||
{
|
||||
"emailAddress": {
|
||||
"name": "Kristina Morgental",
|
||||
"address": "/o=ExchangeLabs/ou=Exchange Administrative Group \
|
||||
(FYDIBOHF23SPDLT)/cn=Recipients/cn=bdc77b18152647a29d28ce1188376dc9-kristina"
|
||||
},
|
||||
"type": "required",
|
||||
"status": {"response": "none"}
|
||||
}
|
||||
]);
|
||||
let conv = convert_event(&v, None).unwrap();
|
||||
let p = &conv.data["participants"]["att-1"];
|
||||
let addr = p["calendarAddress"].as_str().unwrap();
|
||||
assert!(
|
||||
addr.starts_with("urn:x-vandelay:attendee:"),
|
||||
"a hybrid X500 reference must not become a malformed mailto, got {addr}"
|
||||
);
|
||||
assert!(
|
||||
p.get("email").is_none(),
|
||||
"an X500 reference is not an email address"
|
||||
);
|
||||
assert_eq!(p["name"], "Kristina Morgental", "the display name is kept");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_prefixed_or_bracketed_address_is_normalised() {
|
||||
let mut v = sample();
|
||||
v["attendees"] = json!([
|
||||
{
|
||||
"emailAddress": {"name": "Bob", "address": "SMTP:[email protected]"},
|
||||
"type": "required",
|
||||
"status": {"response": "none"}
|
||||
},
|
||||
{
|
||||
"emailAddress": {"name": "Eve", "address": "Eve <[email protected]>"},
|
||||
"type": "required",
|
||||
"status": {"response": "none"}
|
||||
}
|
||||
]);
|
||||
let conv = convert_event(&v, None).unwrap();
|
||||
assert_eq!(
|
||||
conv.data["participants"]["att-1"]["calendarAddress"],
|
||||
"mailto:[email protected]"
|
||||
);
|
||||
assert_eq!(
|
||||
conv.data["participants"]["att-1"]["email"],
|
||||
"[email protected]"
|
||||
);
|
||||
assert_eq!(
|
||||
conv.data["participants"]["att-2"]["calendarAddress"],
|
||||
"mailto:[email protected]"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_name_only_attendee_gets_a_stable_synthetic_calendar_address() {
|
||||
let mut v = sample();
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::exchange::date::recurrence_until;
|
||||
use crate::exchange_graph::error::GraphError;
|
||||
|
||||
pub fn convert_patterned_recurrence(pr: &Value) -> Result<Value, GraphError> {
|
||||
@@ -80,12 +81,11 @@ pub fn convert_patterned_recurrence_rule(
|
||||
let range_type = range.get("type").and_then(Value::as_str).unwrap_or("noEnd");
|
||||
match range_type {
|
||||
"endDate" => {
|
||||
if let Some(end) = range.get("endDate").and_then(Value::as_str) {
|
||||
let local = if end.contains('T') {
|
||||
end.to_owned()
|
||||
} else {
|
||||
format!("{end}T23:59:59")
|
||||
};
|
||||
if let Some(local) = range
|
||||
.get("endDate")
|
||||
.and_then(Value::as_str)
|
||||
.and_then(recurrence_until)
|
||||
{
|
||||
rule.insert("until".to_owned(), Value::from(local));
|
||||
}
|
||||
}
|
||||
|
||||
+47
-2
@@ -33,8 +33,11 @@ pub fn decode_mailbox_name_with(input: &str, utf8_accept: bool) -> Result<String
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
if bytes[i] != b'&' {
|
||||
out.push(bytes[i] as char);
|
||||
i += 1;
|
||||
let start = i;
|
||||
while i < bytes.len() && bytes[i] != b'&' {
|
||||
i += 1;
|
||||
}
|
||||
out.push_str(&input[start..i]);
|
||||
continue;
|
||||
}
|
||||
if i + 1 < bytes.len() && bytes[i + 1] == b'-' {
|
||||
@@ -65,6 +68,16 @@ pub fn decode_mailbox_name_with(input: &str, utf8_accept: bool) -> Result<String
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn alternate_mailbox_name(input: &str, utf8_accept: bool) -> Option<String> {
|
||||
let primary = encode_mailbox_name_with(input, utf8_accept);
|
||||
let alternate = encode_mailbox_name_with(input, !utf8_accept);
|
||||
if alternate == primary {
|
||||
None
|
||||
} else {
|
||||
Some(alternate)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn encode_mailbox_name(input: &str) -> String {
|
||||
encode_mailbox_name_with(input, false)
|
||||
}
|
||||
@@ -221,6 +234,38 @@ mod tests {
|
||||
assert_eq!(canonicalise_inbox("Inbox/Sub"), "Inbox/Sub");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_utf8_name_survives_mutf7_decode_untouched() {
|
||||
assert_eq!(decode_mailbox_name("Envoyés").unwrap(), "Envoyés");
|
||||
assert_eq!(
|
||||
decode_mailbox_name("L/Le Vent Se Lève").unwrap(),
|
||||
"L/Le Vent Se Lève"
|
||||
);
|
||||
assert_eq!(decode_mailbox_name("R&-D é").unwrap(), "R&D é");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_utf8_name_round_trips_through_encode() {
|
||||
for s in ["Envoyés", "Gönderilmiş Postalar", "Çöp kutusu"] {
|
||||
assert_eq!(decode_mailbox_name(&encode_mailbox_name(s)).unwrap(), s);
|
||||
assert_eq!(decode_mailbox_name(s).unwrap(), s);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alternate_encoding_offered_only_for_non_ascii_names() {
|
||||
assert_eq!(
|
||||
alternate_mailbox_name("Envoyés", false).as_deref(),
|
||||
Some("Envoyés")
|
||||
);
|
||||
assert_eq!(
|
||||
alternate_mailbox_name("Envoyés", true).as_deref(),
|
||||
Some("Envoy&AOk-s")
|
||||
);
|
||||
assert_eq!(alternate_mailbox_name("INBOX", false), None);
|
||||
assert_eq!(alternate_mailbox_name("Sent Items", true), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn utf8_accept_skips_mutf7_decode_so_ampersand_passes_through() {
|
||||
assert_eq!(decode_mailbox_name_with("R&D", true).unwrap(), "R&D");
|
||||
|
||||
+42
-4
@@ -533,23 +533,26 @@ impl<'r, R: BufRead> Parser<'r, R> {
|
||||
|
||||
fn parse_quoted(&mut self) -> Result<Value, ImapError> {
|
||||
self.expect(b'"')?;
|
||||
let mut s = String::new();
|
||||
let mut raw: Vec<u8> = Vec::new();
|
||||
while let Some(b) = self.peek() {
|
||||
match b {
|
||||
b'"' => {
|
||||
self.bump();
|
||||
return Ok(Value::Str(s));
|
||||
return Ok(match String::from_utf8(raw) {
|
||||
Ok(s) => Value::Str(s),
|
||||
Err(e) => Value::Bytes(e.into_bytes()),
|
||||
});
|
||||
}
|
||||
b'\\' => {
|
||||
self.bump();
|
||||
let next = self
|
||||
.peek()
|
||||
.ok_or_else(|| ImapError::Parse("trailing \\ in quoted".into()))?;
|
||||
s.push(next as char);
|
||||
raw.push(next);
|
||||
self.bump();
|
||||
}
|
||||
_ => {
|
||||
s.push(b as char);
|
||||
raw.push(b);
|
||||
self.bump();
|
||||
}
|
||||
}
|
||||
@@ -830,6 +833,41 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quoted_mailbox_name_with_utf8_bytes_is_decoded_as_utf8() {
|
||||
let r = parse("* LIST (\\HasNoChildren) \"/\" \"Envoy\u{e9}s\"\r\n".as_bytes());
|
||||
match r {
|
||||
Response::Untagged(Untagged::List { name, .. }) => assert_eq!(name, "Envoyés"),
|
||||
other => panic!("expected List, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quoted_mailbox_name_with_modified_utf7_is_left_untouched() {
|
||||
let r =
|
||||
parse(b"* LIST (\\HasNoChildren) \"/\" \"[Gmail]/G&APY-nderilmi&AV8- Postalar\"\r\n");
|
||||
match r {
|
||||
Response::Untagged(Untagged::List { name, .. }) => {
|
||||
assert_eq!(name, "[Gmail]/G&APY-nderilmi&AV8- Postalar");
|
||||
}
|
||||
other => panic!("expected List, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quoted_mailbox_name_with_invalid_utf8_falls_back_to_lossy() {
|
||||
let mut line: Vec<u8> = b"* LIST (\\HasNoChildren) \"/\" \"bad".to_vec();
|
||||
line.push(0xff);
|
||||
line.extend_from_slice(b"name\"\r\n");
|
||||
let r = parse(&line);
|
||||
match r {
|
||||
Response::Untagged(Untagged::List { name, .. }) => {
|
||||
assert!(name.starts_with("bad") && name.ends_with("name"));
|
||||
}
|
||||
other => panic!("expected List, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn untagged_list_with_special_use() {
|
||||
let r = parse(b"* LIST (\\HasNoChildren \\Sent) \"/\" \"Sent Mail\"\r\n");
|
||||
|
||||
@@ -39,9 +39,58 @@ where
|
||||
Ok(Option::<bool>::deserialize(d)?.unwrap_or(true))
|
||||
}
|
||||
|
||||
pub fn lenient_utc_date<'de, D>(d: D) -> Result<Option<UtcDate>, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
Ok(Option::<String>::deserialize(d)?
|
||||
.as_deref()
|
||||
.and_then(parse_utc_date))
|
||||
}
|
||||
|
||||
pub fn parse_utc_date(raw: &str) -> Option<UtcDate> {
|
||||
use time::format_description::well_known::Rfc3339;
|
||||
let trimmed = raw.trim();
|
||||
if let Ok(dt) = UtcDate::parse(trimmed, &Rfc3339) {
|
||||
return Some(dt);
|
||||
}
|
||||
let separated = trimmed.replacen(' ', "T", 1);
|
||||
if let Ok(dt) = UtcDate::parse(&separated, &Rfc3339) {
|
||||
return Some(dt);
|
||||
}
|
||||
UtcDate::parse(&format!("{separated}Z"), &Rfc3339).ok()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EmailAddress {
|
||||
pub name: Option<String>,
|
||||
pub email: String,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn strict_rfc3339_is_accepted() {
|
||||
let dt = parse_utc_date("2026-08-22T09:42:52Z").expect("parsed");
|
||||
assert_eq!(dt.year(), 2026);
|
||||
assert!(parse_utc_date("2026-08-22T09:42:52.123+02:00").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn space_separator_and_missing_zone_are_salvaged() {
|
||||
assert!(parse_utc_date("2026-08-22 09:42:52Z").is_some());
|
||||
assert!(parse_utc_date("2026-08-22T09:42:52").is_some());
|
||||
assert!(parse_utc_date(" 2026-08-22T09:42:52Z ").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unrepresentable_dates_yield_none_instead_of_an_error() {
|
||||
assert!(parse_utc_date("30828-09-14T02:48:05Z").is_none());
|
||||
assert!(parse_utc_date("10000-01-01T00:00:00Z").is_none());
|
||||
assert!(parse_utc_date("").is_none());
|
||||
assert!(parse_utc_date("not a date").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
use indexmap::IndexMap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::common::lenient_utc_date;
|
||||
use super::{JmapId, UtcDate};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -17,8 +18,13 @@ pub struct Email {
|
||||
|
||||
pub blob_id: JmapId,
|
||||
|
||||
#[serde(with = "time::serde::rfc3339")]
|
||||
pub received_at: UtcDate,
|
||||
#[serde(
|
||||
default,
|
||||
deserialize_with = "lenient_utc_date",
|
||||
serialize_with = "time::serde::rfc3339::option::serialize",
|
||||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
pub received_at: Option<UtcDate>,
|
||||
|
||||
pub mailbox_ids: IndexMap<JmapId, bool>,
|
||||
|
||||
|
||||
@@ -4,10 +4,9 @@
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::common::bool_or_true;
|
||||
use super::{JmapId, UtcDate};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -18,7 +17,8 @@ pub struct FileNode {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub parent_id: Option<JmapId>,
|
||||
|
||||
pub node_type: NodeType,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub node_type: Option<NodeType>,
|
||||
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub blob_id: Option<JmapId>,
|
||||
@@ -48,6 +48,17 @@ pub struct FileNode {
|
||||
pub role: Option<String>,
|
||||
}
|
||||
|
||||
impl FileNode {
|
||||
pub fn effective_node_type(&self) -> NodeType {
|
||||
match self.node_type {
|
||||
Some(t) => t,
|
||||
None if self.blob_id.is_some() => NodeType::File,
|
||||
None if self.target.is_some() => NodeType::Symlink,
|
||||
None => NodeType::Directory,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum NodeType {
|
||||
|
||||
+22
-8
@@ -48,6 +48,9 @@ pub fn reconcile(
|
||||
};
|
||||
|
||||
let targets = target_get_all(net, ty).map_err(Error::from)?;
|
||||
let mut default_claimed = targets
|
||||
.iter()
|
||||
.any(|t| t.get("isDefault").and_then(Value::as_bool).unwrap_or(false));
|
||||
let mut tmatched = std::collections::HashSet::new();
|
||||
|
||||
let mut to_create: Vec<(i64, bool)> = Vec::new();
|
||||
@@ -99,14 +102,25 @@ pub fn reconcile(
|
||||
maps.insert(ty, local, JmapId(id.clone()));
|
||||
counts.created += 1;
|
||||
if to_create.iter().any(|(l, d)| *l == local && *d) {
|
||||
let mut req = crate::jmap::request::Request::new();
|
||||
req.call(
|
||||
format!("{}/set", ty.jmap_name()),
|
||||
json!({ "accountId": net.account, "onSuccessSetIsDefault": id }),
|
||||
"d",
|
||||
);
|
||||
if let Err(e) = req.send(&net.client, &net.api) {
|
||||
logger.warn(&format!("{} isDefault not set: {e}", ty.jmap_name()));
|
||||
if default_claimed {
|
||||
logger.warn(&format!(
|
||||
"{} {}: the target account already has a default, leaving it alone",
|
||||
ty.jmap_name(),
|
||||
id
|
||||
));
|
||||
} else {
|
||||
let mut req = crate::jmap::request::Request::new();
|
||||
req.call(
|
||||
format!("{}/set", ty.jmap_name()),
|
||||
json!({ "accountId": net.account, "onSuccessSetIsDefault": id }),
|
||||
"d",
|
||||
);
|
||||
match req.send(&net.client, &net.api) {
|
||||
Ok(_) => default_claimed = true,
|
||||
Err(e) => {
|
||||
logger.warn(&format!("{} isDefault not set: {e}", ty.jmap_name()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -442,7 +442,7 @@ fn build_create(
|
||||
.map_err(Error::from)?;
|
||||
let mut wire = fnrow.wire;
|
||||
if let (crate::jmap::wire::file_node::NodeType::File, Some(blob_local)) =
|
||||
(&wire.node_type, fnrow.blob_local_id)
|
||||
(wire.effective_node_type(), fnrow.blob_local_id)
|
||||
{
|
||||
let ct = wire
|
||||
.media_type
|
||||
|
||||
@@ -20,7 +20,7 @@ use crate::error::Error;
|
||||
use crate::imap::client::{ConnectMode, ImapClient};
|
||||
use crate::imap::command;
|
||||
use crate::imap::error::ImapError;
|
||||
use crate::imap::name::encode_mailbox_name_with;
|
||||
use crate::imap::name::{alternate_mailbox_name, encode_mailbox_name_with};
|
||||
use crate::imap::response::{NamespaceEntry, Untagged};
|
||||
use crate::imap::retry::{
|
||||
BackoffState, Disposition, RetryPolicy, classify, is_negotiation_failure,
|
||||
@@ -111,6 +111,33 @@ pub(super) fn control_run_collect(
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn select_folder(
|
||||
client: &mut ImapClient,
|
||||
ctx: &ControlCtx,
|
||||
folder: &str,
|
||||
) -> Result<crate::imap::CollectedResponse, Error> {
|
||||
let utf8_accept = client.utf8_accept();
|
||||
let wire_name = encode_mailbox_name_with(folder, utf8_accept);
|
||||
let first = control_run_collect(client, ctx, &command::select(&wire_name));
|
||||
let Err(Error::Partial(_)) = &first else {
|
||||
return first;
|
||||
};
|
||||
let Some(alternate) = alternate_mailbox_name(folder, utf8_accept) else {
|
||||
return first;
|
||||
};
|
||||
match control_run_collect(client, ctx, &command::select(&alternate)) {
|
||||
Ok(r) => {
|
||||
log_at(
|
||||
ctx.logger,
|
||||
LEVEL_DEFAULT,
|
||||
&format!("folder {folder:?}: selected with the alternate mailbox-name encoding"),
|
||||
);
|
||||
Ok(r)
|
||||
}
|
||||
Err(_) => first,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn call_with_retry<F, T>(
|
||||
client: &mut ImapClient,
|
||||
ctx: &ControlCtx,
|
||||
@@ -713,8 +740,7 @@ fn reconcile_folder(
|
||||
}
|
||||
}
|
||||
|
||||
let wire_name = encode_mailbox_name_with(&folder.name, client.utf8_accept());
|
||||
let resp = control_run_collect(client, control_ctx, &command::select(&wire_name))?;
|
||||
let resp = select_folder(client, control_ctx, &folder.name)?;
|
||||
let mut uidvalidity: u32 = 0;
|
||||
let mut uidnext: u32 = 0;
|
||||
for u in &resp.untagged {
|
||||
@@ -1123,8 +1149,7 @@ fn dry_run_summary(
|
||||
}
|
||||
|
||||
for folder in folders {
|
||||
let wire = encode_mailbox_name_with(&folder.name, client.utf8_accept());
|
||||
let select_resp = match control_run_collect(client, control_ctx, &command::select(&wire)) {
|
||||
let select_resp = match select_folder(client, control_ctx, &folder.name) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
log_at(
|
||||
|
||||
@@ -13,7 +13,7 @@ use crossbeam_channel::{Receiver, Sender, unbounded};
|
||||
use crate::imap::client::{ConnectMode, ImapClient};
|
||||
use crate::imap::command;
|
||||
use crate::imap::error::ImapError;
|
||||
use crate::imap::name::encode_mailbox_name_with;
|
||||
use crate::imap::name::{alternate_mailbox_name, encode_mailbox_name_with};
|
||||
use crate::imap::response::Untagged;
|
||||
use crate::imap::retry::{BackoffState, Disposition, RetryPolicy, classify};
|
||||
use crate::imap::transport::Connector;
|
||||
@@ -231,8 +231,16 @@ fn run_one_job(
|
||||
event_tx: &Sender<FetchEvent>,
|
||||
) -> Result<(), ImapError> {
|
||||
if current_folder.as_deref() != Some(job.folder.as_str()) {
|
||||
let wire = encode_mailbox_name_with(&job.folder, client.utf8_accept());
|
||||
client.run_collect(&command::select(&wire))?;
|
||||
let utf8_accept = client.utf8_accept();
|
||||
let wire = encode_mailbox_name_with(&job.folder, utf8_accept);
|
||||
if let Err(e) = client.run_collect(&command::select(&wire)) {
|
||||
match alternate_mailbox_name(&job.folder, utf8_accept) {
|
||||
Some(alternate) if matches!(e, ImapError::No(_)) => {
|
||||
client.run_collect(&command::select(&alternate))?;
|
||||
}
|
||||
_ => return Err(e),
|
||||
}
|
||||
}
|
||||
*current_folder = Some(job.folder.clone());
|
||||
}
|
||||
let set = command::format_uid_set(&job.uids, true);
|
||||
|
||||
+23
-7
@@ -29,6 +29,7 @@ use crate::jmap::http::{Auth, HttpClient};
|
||||
use crate::jmap::request::{get_all, get_changes, get_objects, get_state, query_all_ids};
|
||||
use crate::jmap::session::{Limits, Session};
|
||||
use crate::jmap::wire::JmapId;
|
||||
use crate::jmap::wire::common::parse_utc_date;
|
||||
use crate::jmap::wire::email::Email;
|
||||
use crate::jmap::wire::file_node::{FileNode, NodeType};
|
||||
use crate::jmap::wire::sieve_script::SieveScript;
|
||||
@@ -478,7 +479,7 @@ fn insert_objects(
|
||||
continue;
|
||||
}
|
||||
};
|
||||
match insert_one(&tx, ty, source_id, obj, &blobs) {
|
||||
match insert_one(&tx, ty, source_id, obj, &blobs, logger) {
|
||||
Ok(local_id) => {
|
||||
db::ids::insert(&tx, source_id, ty, &jmap_id, local_id)
|
||||
.map_err(|e| Error::Partial(e.to_string()))?;
|
||||
@@ -649,6 +650,7 @@ fn insert_one(
|
||||
source_id: i64,
|
||||
obj: &Value,
|
||||
blobs: &HashMap<String, Vec<u8>>,
|
||||
logger: &Logger,
|
||||
) -> Result<i64, JmapError> {
|
||||
let resolver = DbResolver { conn, source_id };
|
||||
match ty {
|
||||
@@ -673,14 +675,28 @@ fn insert_one(
|
||||
mapping::insert_participant_identity(conn, &w)
|
||||
}
|
||||
ObjectType::Email => {
|
||||
let w: Email = serde_json::from_value(obj.clone())?;
|
||||
let mut w: Email = serde_json::from_value(obj.clone())?;
|
||||
let data = blobs
|
||||
.get(&w.blob_id.0)
|
||||
.ok_or_else(|| JmapError::malformed("email blob missing"))?;
|
||||
let blob_local = db::blobs::intern_blob(conn, data)?;
|
||||
let mm = crate::sync::keys::index_to_json(
|
||||
&crate::sync::emailmeta::email_index_from_blob(data),
|
||||
);
|
||||
let (index, date_header) = crate::sync::emailmeta::email_meta_from_blob(data);
|
||||
let mm = crate::sync::keys::index_to_json(&index);
|
||||
if w.received_at.is_none() {
|
||||
w.received_at = date_header.as_deref().and_then(parse_utc_date);
|
||||
let id = obj.get("id").and_then(Value::as_str).unwrap_or("?");
|
||||
let raw = obj
|
||||
.get("receivedAt")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("<missing>");
|
||||
logger.warn(&format!(
|
||||
"Email {id}: unusable receivedAt {raw:?}; falling back to {}",
|
||||
match &w.received_at {
|
||||
Some(_) => "the Date header",
|
||||
None => "1970-01-01T00:00:00Z",
|
||||
}
|
||||
));
|
||||
}
|
||||
mapping::insert_email(conn, &w, blob_local, &mm, &resolver)
|
||||
}
|
||||
ObjectType::SieveScript => {
|
||||
@@ -693,7 +709,7 @@ fn insert_one(
|
||||
}
|
||||
ObjectType::FileNode => {
|
||||
let w: FileNode = serde_json::from_value(obj.clone())?;
|
||||
let blob_local = match (&w.node_type, &w.blob_id) {
|
||||
let blob_local = match (w.effective_node_type(), &w.blob_id) {
|
||||
(NodeType::File, Some(b)) => {
|
||||
let data = blobs
|
||||
.get(&b.0)
|
||||
@@ -934,7 +950,7 @@ fn update_one(
|
||||
}
|
||||
ObjectType::FileNode => {
|
||||
let w: FileNode = serde_json::from_value(obj.clone())?;
|
||||
let blob_local = match (&w.node_type, &w.blob_id) {
|
||||
let blob_local = match (w.effective_node_type(), &w.blob_id) {
|
||||
(NodeType::File, Some(b)) => {
|
||||
let data = blobs
|
||||
.get(&b.0)
|
||||
|
||||
@@ -8,6 +8,7 @@ use indexmap::IndexMap;
|
||||
use rusqlite::{Connection, Row, params};
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use crate::db::defaults::unique_default;
|
||||
use crate::jmap::blob::{BlobWalkError, InlineShape, import_blob_ids, inline_blob_data_uris};
|
||||
use crate::jmap::error::JmapError;
|
||||
use crate::jmap::wire::JmapId;
|
||||
@@ -168,7 +169,7 @@ pub fn insert_email(
|
||||
VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
params![
|
||||
blob_local_id,
|
||||
format_utc(&wire.received_at)?,
|
||||
format_utc(&wire.received_at.unwrap_or(time::OffsetDateTime::UNIX_EPOCH))?,
|
||||
id_array_json(&mailbox_locals),
|
||||
Value::Array(keywords.iter().map(|k| Value::from(k.as_str())).collect()).to_string(),
|
||||
message_match
|
||||
@@ -248,6 +249,7 @@ pub fn insert_sieve_script(
|
||||
pub const SIEVE_SELECT: &str = "SELECT id, name, is_active, blob_id FROM sieve_scripts";
|
||||
|
||||
pub fn insert_address_book(conn: &Connection, wire: &AddressBook) -> Result<i64, JmapError> {
|
||||
let is_default = unique_default(conn, ObjectType::AddressBook, wire.is_default, None)?;
|
||||
conn.execute(
|
||||
"INSERT INTO address_books (name, description, sort_order, is_default, is_subscribed)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
@@ -255,7 +257,7 @@ pub fn insert_address_book(conn: &Connection, wire: &AddressBook) -> Result<i64,
|
||||
wire.name,
|
||||
wire.description,
|
||||
wire.sort_order,
|
||||
wire.is_default as i64,
|
||||
is_default as i64,
|
||||
wire.is_subscribed as i64
|
||||
],
|
||||
)?;
|
||||
@@ -266,6 +268,7 @@ pub const ADDRESS_BOOK_SELECT: &str =
|
||||
"SELECT id, name, description, sort_order, is_default, is_subscribed FROM address_books";
|
||||
|
||||
pub fn insert_calendar(conn: &Connection, wire: &Calendar) -> Result<i64, JmapError> {
|
||||
let is_default = unique_default(conn, ObjectType::Calendar, wire.is_default, None)?;
|
||||
conn.execute(
|
||||
"INSERT INTO calendars (name, description, color, sort_order, is_subscribed, is_visible,
|
||||
is_default, include_in_availability, default_alerts_with_time,
|
||||
@@ -278,7 +281,7 @@ pub fn insert_calendar(conn: &Connection, wire: &Calendar) -> Result<i64, JmapEr
|
||||
wire.sort_order,
|
||||
wire.is_subscribed as i64,
|
||||
wire.is_visible as i64,
|
||||
wire.is_default as i64,
|
||||
is_default as i64,
|
||||
wire.include_in_availability,
|
||||
opt_json(&wire.default_alerts_with_time)?,
|
||||
opt_json(&wire.default_alerts_without_time)?,
|
||||
@@ -296,10 +299,11 @@ pub fn insert_participant_identity(
|
||||
conn: &Connection,
|
||||
wire: &ParticipantIdentity,
|
||||
) -> Result<i64, JmapError> {
|
||||
let is_default = unique_default(conn, ObjectType::ParticipantIdentity, wire.is_default, None)?;
|
||||
conn.execute(
|
||||
"INSERT INTO participant_identities (name, calendar_address, is_default)
|
||||
VALUES (?1, ?2, ?3)",
|
||||
params![wire.name, wire.calendar_address, wire.is_default as i64],
|
||||
params![wire.name, wire.calendar_address, is_default as i64],
|
||||
)?;
|
||||
Ok(conn.last_insert_rowid())
|
||||
}
|
||||
@@ -314,7 +318,7 @@ pub fn insert_file_node(
|
||||
resolver: &impl LocalResolver,
|
||||
) -> Result<i64, JmapError> {
|
||||
let parent = opt_parent(resolver, ObjectType::FileNode, &wire.parent_id);
|
||||
let node_type = serde_json::to_value(wire.node_type)?
|
||||
let node_type = serde_json::to_value(wire.effective_node_type())?
|
||||
.as_str()
|
||||
.unwrap_or("file")
|
||||
.to_owned();
|
||||
@@ -495,6 +499,12 @@ pub fn update_address_book(
|
||||
local_id: i64,
|
||||
wire: &AddressBook,
|
||||
) -> Result<bool, JmapError> {
|
||||
let is_default = unique_default(
|
||||
conn,
|
||||
ObjectType::AddressBook,
|
||||
wire.is_default,
|
||||
Some(local_id),
|
||||
)?;
|
||||
let n = conn.execute(
|
||||
"UPDATE address_books SET name = ?1, description = ?2, sort_order = ?3, is_default = ?4,
|
||||
is_subscribed = ?5
|
||||
@@ -504,7 +514,7 @@ pub fn update_address_book(
|
||||
wire.name,
|
||||
wire.description,
|
||||
wire.sort_order,
|
||||
wire.is_default as i64,
|
||||
is_default as i64,
|
||||
wire.is_subscribed as i64,
|
||||
local_id
|
||||
],
|
||||
@@ -517,6 +527,7 @@ pub fn update_calendar(
|
||||
local_id: i64,
|
||||
wire: &Calendar,
|
||||
) -> Result<bool, JmapError> {
|
||||
let is_default = unique_default(conn, ObjectType::Calendar, wire.is_default, Some(local_id))?;
|
||||
let n = conn.execute(
|
||||
"UPDATE calendars SET name = ?1, description = ?2, color = ?3, sort_order = ?4,
|
||||
is_subscribed = ?5, is_visible = ?6, is_default = ?7, include_in_availability = ?8,
|
||||
@@ -533,7 +544,7 @@ pub fn update_calendar(
|
||||
wire.sort_order,
|
||||
wire.is_subscribed as i64,
|
||||
wire.is_visible as i64,
|
||||
wire.is_default as i64,
|
||||
is_default as i64,
|
||||
wire.include_in_availability,
|
||||
opt_json(&wire.default_alerts_with_time)?,
|
||||
opt_json(&wire.default_alerts_without_time)?,
|
||||
@@ -549,13 +560,19 @@ pub fn update_participant_identity(
|
||||
local_id: i64,
|
||||
wire: &ParticipantIdentity,
|
||||
) -> Result<bool, JmapError> {
|
||||
let is_default = unique_default(
|
||||
conn,
|
||||
ObjectType::ParticipantIdentity,
|
||||
wire.is_default,
|
||||
Some(local_id),
|
||||
)?;
|
||||
let n = conn.execute(
|
||||
"UPDATE participant_identities SET name = ?1, calendar_address = ?2, is_default = ?3
|
||||
WHERE id = ?4 AND (name IS NOT ?1 OR calendar_address IS NOT ?2 OR is_default IS NOT ?3)",
|
||||
params![
|
||||
wire.name,
|
||||
wire.calendar_address,
|
||||
wire.is_default as i64,
|
||||
is_default as i64,
|
||||
local_id
|
||||
],
|
||||
)?;
|
||||
@@ -570,7 +587,7 @@ pub fn update_file_node(
|
||||
resolver: &impl LocalResolver,
|
||||
) -> Result<bool, JmapError> {
|
||||
let parent = opt_parent(resolver, ObjectType::FileNode, &wire.parent_id);
|
||||
let node_type = serde_json::to_value(wire.node_type)?
|
||||
let node_type = serde_json::to_value(wire.effective_node_type())?
|
||||
.as_str()
|
||||
.unwrap_or("file")
|
||||
.to_owned();
|
||||
@@ -828,7 +845,9 @@ pub fn row_to_file_node(
|
||||
),
|
||||
None => None,
|
||||
};
|
||||
let node_type = serde_json::from_value(Value::from(row.get::<_, String>(2)?))?;
|
||||
let node_type = Some(serde_json::from_value(Value::from(
|
||||
row.get::<_, String>(2)?,
|
||||
))?);
|
||||
let target: Option<Vec<String>> = from_opt_json(row.get::<_, Option<String>>(4)?)?;
|
||||
let blob_local_id: Option<i64> = row.get(3)?;
|
||||
Ok(FileNodeRow {
|
||||
|
||||
@@ -16,7 +16,7 @@ use time::format_description::well_known::Rfc3339;
|
||||
|
||||
use crate::db;
|
||||
use crate::sync::TypeCounts;
|
||||
use crate::sync::emailmeta::email_index_from_blob;
|
||||
use crate::sync::emailmeta::email_meta_from_blob;
|
||||
use crate::sync::keys::index_to_json;
|
||||
|
||||
use super::keywords::{Translation, flags_from_filename, translate_flags, unique_id_from_filename};
|
||||
@@ -149,9 +149,10 @@ pub fn insert_new(
|
||||
if translation.has_trashed_flag && !ctx.include_deleted {
|
||||
return Ok(None);
|
||||
}
|
||||
let received_at = format_unix_rfc3339(entry.mtime_unix);
|
||||
let (index, date_header) = email_meta_from_blob(&bytes);
|
||||
let received_at = pick_received_at(&entry.filename, entry.mtime_unix, date_header.as_deref());
|
||||
let blob_id = db::blobs::intern_blob(tx, &bytes)?;
|
||||
let message_match = index_to_json(&email_index_from_blob(&bytes));
|
||||
let message_match = index_to_json(&index);
|
||||
let mailbox_ids = Value::Array(vec![Value::from(ctx.mailbox_local)]);
|
||||
let keywords = keywords_json(&translation);
|
||||
tx.execute(
|
||||
@@ -170,6 +171,37 @@ pub fn insert_new(
|
||||
Ok(Some(local_id))
|
||||
}
|
||||
|
||||
pub fn delivery_time_from_filename(filename: &str) -> Option<u64> {
|
||||
let digits: String = filename.chars().take_while(char::is_ascii_digit).collect();
|
||||
if digits.is_empty() || digits.len() > 12 {
|
||||
return None;
|
||||
}
|
||||
let secs: u64 = digits.parse().ok()?;
|
||||
if secs == 0 {
|
||||
return None;
|
||||
}
|
||||
let now = OffsetDateTime::now_utc().unix_timestamp().max(0) as u64;
|
||||
if secs > now.saturating_add(86_400) {
|
||||
return None;
|
||||
}
|
||||
Some(secs)
|
||||
}
|
||||
|
||||
pub fn pick_received_at(filename: &str, mtime_unix: u64, date_header: Option<&str>) -> String {
|
||||
if let Some(secs) = delivery_time_from_filename(filename) {
|
||||
return format_unix_rfc3339(secs);
|
||||
}
|
||||
if let Some(d) = date_header {
|
||||
return d.to_owned();
|
||||
}
|
||||
if mtime_unix > 0 {
|
||||
return format_unix_rfc3339(mtime_unix);
|
||||
}
|
||||
OffsetDateTime::now_utc()
|
||||
.format(&Rfc3339)
|
||||
.unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_owned())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PresentOutcome {
|
||||
Unchanged,
|
||||
@@ -721,4 +753,49 @@ mod tests {
|
||||
);
|
||||
tx.commit().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filename_delivery_time_wins_over_mtime() {
|
||||
let got = pick_received_at(
|
||||
"1763065233.M12345P678.host,S=1234:2,S",
|
||||
99,
|
||||
Some("2025-05-12T10:00:00+02:00"),
|
||||
);
|
||||
assert_eq!(got, "2025-11-13T20:20:33Z");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn date_header_used_when_the_filename_carries_no_timestamp() {
|
||||
let got = pick_received_at(
|
||||
"M12345P678.host:2,S",
|
||||
1763065233,
|
||||
Some("2025-05-12T10:00:00+02:00"),
|
||||
);
|
||||
assert_eq!(got, "2025-05-12T10:00:00+02:00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mtime_is_the_last_resort_before_now() {
|
||||
let got = pick_received_at("M12345P678.host:2,S", 1763065233, None);
|
||||
assert_eq!(got, "2025-11-13T20:20:33Z");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn implausible_filename_timestamps_are_rejected() {
|
||||
assert_eq!(delivery_time_from_filename("0.M1P1.host"), None);
|
||||
assert_eq!(delivery_time_from_filename("M1P1.host"), None);
|
||||
assert_eq!(
|
||||
delivery_time_from_filename("99999999999999.M1P1.host"),
|
||||
None
|
||||
);
|
||||
let far_future = (OffsetDateTime::now_utc().unix_timestamp() as u64) + 400_000;
|
||||
assert_eq!(
|
||||
delivery_time_from_filename(&format!("{far_future}.M1P1.host")),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
delivery_time_from_filename("1763065233.M12345P678.host"),
|
||||
Some(1763065233)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user