Initial commit
This commit is contained in:
@@ -0,0 +1,286 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::exchange_graph::client::GraphClient;
|
||||
use crate::exchange_graph::error::GraphError;
|
||||
use crate::exchange_graph::types::MailboxKind;
|
||||
|
||||
pub const DEFAULT_API_BASE: &str = "https://graph.microsoft.com/v1.0";
|
||||
|
||||
pub const PREFER_TIMEZONE_UTC: &str = "outlook.timezone=\"UTC\"";
|
||||
pub const PREFER_BODY_TEXT: &str = "outlook.body-content-type=\"text\"";
|
||||
pub const PREFER_BODY_HTML: &str = "outlook.body-content-type=\"html\"";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Endpoints {
|
||||
pub api_base: String,
|
||||
pub user_path: String,
|
||||
}
|
||||
|
||||
impl Endpoints {
|
||||
pub fn for_me(api_base: &str) -> Endpoints {
|
||||
Endpoints {
|
||||
api_base: api_base.trim_end_matches('/').to_owned(),
|
||||
user_path: "/me".to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn for_user(api_base: &str, user_id: &str) -> Endpoints {
|
||||
Endpoints {
|
||||
api_base: api_base.trim_end_matches('/').to_owned(),
|
||||
user_path: format!("/users/{user_id}"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn me_or_user(&self) -> String {
|
||||
format!("{}{}", self.api_base, self.user_path)
|
||||
}
|
||||
|
||||
pub fn mail_folders_root(&self, kind: MailboxKind, top: usize) -> String {
|
||||
match kind {
|
||||
MailboxKind::Primary => {
|
||||
format!(
|
||||
"{}/mailFolders?$top={top}&includeHiddenFolders=true",
|
||||
self.me_or_user()
|
||||
)
|
||||
}
|
||||
MailboxKind::Archive => {
|
||||
format!(
|
||||
"{}/mailFolders/archive/childFolders?$top={top}&includeHiddenFolders=true",
|
||||
self.me_or_user()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn mail_folder_child_folders(&self, folder_id: &str, top: usize) -> String {
|
||||
format!(
|
||||
"{}/mailFolders/{}/childFolders?$top={top}&includeHiddenFolders=true",
|
||||
self.me_or_user(),
|
||||
url_escape(folder_id)
|
||||
)
|
||||
}
|
||||
|
||||
pub fn folder_messages_ids(&self, folder_id: &str, top: usize) -> String {
|
||||
format!(
|
||||
"{}/mailFolders/{}/messages?$top={top}&$select=id",
|
||||
self.me_or_user(),
|
||||
url_escape(folder_id)
|
||||
)
|
||||
}
|
||||
|
||||
pub fn message_mime(&self, message_id: &str) -> String {
|
||||
format!(
|
||||
"{}/messages/{}/$value",
|
||||
self.me_or_user(),
|
||||
url_escape(message_id)
|
||||
)
|
||||
}
|
||||
|
||||
pub fn well_known_folder(&self, well_known: &str) -> String {
|
||||
format!(
|
||||
"{}/mailFolders/{}?$select=id",
|
||||
self.me_or_user(),
|
||||
well_known
|
||||
)
|
||||
}
|
||||
|
||||
pub fn calendars(&self, top: usize) -> String {
|
||||
format!("{}/calendars?$top={top}", self.me_or_user())
|
||||
}
|
||||
|
||||
pub fn calendar_events_ids(&self, calendar_id: &str, top: usize) -> String {
|
||||
format!(
|
||||
"{}/calendars/{}/events?$top={top}&$select=id,type,seriesMasterId",
|
||||
self.me_or_user(),
|
||||
url_escape(calendar_id)
|
||||
)
|
||||
}
|
||||
|
||||
pub fn event(&self, event_id: &str) -> String {
|
||||
format!("{}/events/{}", self.me_or_user(), url_escape(event_id))
|
||||
}
|
||||
|
||||
pub fn contact_folders(&self, top: usize) -> String {
|
||||
format!("{}/contactFolders?$top={top}", self.me_or_user())
|
||||
}
|
||||
|
||||
pub fn contact_folder_children(&self, folder_id: &str, top: usize) -> String {
|
||||
format!(
|
||||
"{}/contactFolders/{}/childFolders?$top={top}",
|
||||
self.me_or_user(),
|
||||
url_escape(folder_id)
|
||||
)
|
||||
}
|
||||
|
||||
pub fn contact_folder_contacts_ids(&self, folder_id: &str, top: usize) -> String {
|
||||
format!(
|
||||
"{}/contactFolders/{}/contacts?$top={top}&$select=id",
|
||||
self.me_or_user(),
|
||||
url_escape(folder_id)
|
||||
)
|
||||
}
|
||||
|
||||
pub fn contact(&self, contact_id: &str) -> String {
|
||||
format!("{}/contacts/{}", self.me_or_user(), url_escape(contact_id))
|
||||
}
|
||||
|
||||
pub fn mailbox_settings_timezone(&self) -> String {
|
||||
format!("{}/mailboxSettings?$select=timeZone", self.me_or_user())
|
||||
}
|
||||
|
||||
pub fn me_select_id_upn(&self) -> String {
|
||||
format!(
|
||||
"{}?$select=id,userPrincipalName,displayName,mail",
|
||||
self.me_or_user()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn url_escape(segment: &str) -> String {
|
||||
let mut out = String::with_capacity(segment.len());
|
||||
for b in segment.bytes() {
|
||||
match b {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
|
||||
out.push(b as char);
|
||||
}
|
||||
_ => {
|
||||
use std::fmt::Write;
|
||||
let _ = write!(out, "%{b:02X}");
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn paged_collect<F>(
|
||||
client: &GraphClient,
|
||||
initial_url: &str,
|
||||
prefer: &[&str],
|
||||
mut on_page: F,
|
||||
) -> Result<(), GraphError>
|
||||
where
|
||||
F: FnMut(&Value) -> Result<(), GraphError>,
|
||||
{
|
||||
let mut url = initial_url.to_owned();
|
||||
loop {
|
||||
let body = client.get_json_with_prefer(&url, prefer)?;
|
||||
on_page(&body)?;
|
||||
match body.get("@odata.nextLink").and_then(Value::as_str) {
|
||||
Some(next) => url = next.to_owned(),
|
||||
None => return Ok(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn collect_all_ids(
|
||||
client: &GraphClient,
|
||||
initial_url: &str,
|
||||
prefer: &[&str],
|
||||
) -> Result<Vec<String>, GraphError> {
|
||||
let mut out = Vec::new();
|
||||
paged_collect(client, initial_url, prefer, |page| {
|
||||
if let Some(arr) = page.get("value").and_then(Value::as_array) {
|
||||
for item in arr {
|
||||
if let Some(id) = item.get("id").and_then(Value::as_str) {
|
||||
out.push(id.to_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn collect_all_values(
|
||||
client: &GraphClient,
|
||||
initial_url: &str,
|
||||
prefer: &[&str],
|
||||
) -> Result<Vec<Value>, GraphError> {
|
||||
let mut out = Vec::new();
|
||||
paged_collect(client, initial_url, prefer, |page| {
|
||||
if let Some(arr) = page.get("value").and_then(Value::as_array) {
|
||||
for item in arr {
|
||||
out.push(item.clone());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn primary_mail_folders_root_uses_me_with_hidden() {
|
||||
let e = Endpoints::for_me(DEFAULT_API_BASE);
|
||||
let url = e.mail_folders_root(MailboxKind::Primary, 100);
|
||||
assert!(url.starts_with("https://graph.microsoft.com/v1.0/me/mailFolders?"));
|
||||
assert!(url.contains("includeHiddenFolders=true"));
|
||||
assert!(url.contains("$top=100"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn archive_mail_folders_root_uses_archive_child_folders() {
|
||||
let e = Endpoints::for_me(DEFAULT_API_BASE);
|
||||
let url = e.mail_folders_root(MailboxKind::Archive, 50);
|
||||
assert!(
|
||||
url.starts_with(
|
||||
"https://graph.microsoft.com/v1.0/me/mailFolders/archive/childFolders?"
|
||||
)
|
||||
);
|
||||
assert!(url.contains("$top=50"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn for_user_replaces_me_segment() {
|
||||
let e = Endpoints::for_user(DEFAULT_API_BASE, "uid");
|
||||
let url = e.mail_folders_root(MailboxKind::Primary, 100);
|
||||
assert!(url.contains("/users/uid/mailFolders"));
|
||||
assert!(!url.contains("/me/"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn top_threads_through_calendar_and_contact_endpoints() {
|
||||
let e = Endpoints::for_me(DEFAULT_API_BASE);
|
||||
assert!(e.calendars(75).contains("$top=75"));
|
||||
assert!(e.contact_folders(75).contains("$top=75"));
|
||||
assert!(e.contact_folder_children("F", 75).contains("$top=75"));
|
||||
assert!(e.mail_folder_child_folders("F", 75).contains("$top=75"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_escape_encodes_unsafe_bytes_only() {
|
||||
assert_eq!(url_escape("AAkAAA="), "AAkAAA%3D");
|
||||
assert_eq!(url_escape("plain.id_123-x"), "plain.id_123-x");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn message_mime_endpoint_uses_value() {
|
||||
let e = Endpoints::for_me(DEFAULT_API_BASE);
|
||||
let url = e.message_mime("AAkA==");
|
||||
assert!(url.ends_with("/messages/AAkA%3D%3D/$value"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn well_known_folder_uses_short_name() {
|
||||
let e = Endpoints::for_me(DEFAULT_API_BASE);
|
||||
let url = e.well_known_folder("inbox");
|
||||
assert!(url.ends_with("/mailFolders/inbox?$select=id"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_ids_endpoint_requests_routing_fields() {
|
||||
let e = Endpoints::for_me(DEFAULT_API_BASE);
|
||||
let url = e.calendar_events_ids("CAL", 100);
|
||||
assert!(url.contains("$select=id,type,seriesMasterId"));
|
||||
assert!(url.contains("$top=100"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,735 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use crate::exchange::tz::windows_to_iana;
|
||||
use crate::exchange_graph::error::GraphError;
|
||||
use crate::exchange_graph::recurrence::convert_patterned_recurrence_rule;
|
||||
|
||||
pub fn graph_calendar_color_to_hex(value: &str) -> Option<&'static str> {
|
||||
match value {
|
||||
"lightBlue" => Some("#A6CEE3"),
|
||||
"lightGreen" => Some("#B2DF8A"),
|
||||
"lightOrange" => Some("#FDBF6F"),
|
||||
"lightGray" => Some("#CCCCCC"),
|
||||
"lightYellow" => Some("#FFFF99"),
|
||||
"lightTeal" => Some("#A0E7E5"),
|
||||
"lightPink" => Some("#FB9A99"),
|
||||
"lightBrown" => Some("#B15928"),
|
||||
"lightRed" => Some("#FB9A99"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn windows_or_iana_to_iana(value: &str) -> Option<String> {
|
||||
if value.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if let Some(iana) = windows_to_iana(value) {
|
||||
return Some(iana.to_owned());
|
||||
}
|
||||
if value.contains('/') {
|
||||
return Some(value.to_owned());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ConvertedEvent {
|
||||
pub uid: String,
|
||||
pub data: Value,
|
||||
pub is_draft: bool,
|
||||
pub use_default_alerts: bool,
|
||||
pub series_master_id: Option<String>,
|
||||
pub event_type: EventType,
|
||||
pub original_start: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum EventType {
|
||||
SingleInstance,
|
||||
SeriesMaster,
|
||||
Occurrence,
|
||||
Exception,
|
||||
}
|
||||
|
||||
pub fn classify_event_type(value: &Value) -> EventType {
|
||||
match value.get("type").and_then(Value::as_str).unwrap_or("") {
|
||||
"seriesMaster" => EventType::SeriesMaster,
|
||||
"occurrence" => EventType::Occurrence,
|
||||
"exception" => EventType::Exception,
|
||||
_ => EventType::SingleInstance,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn convert_event(
|
||||
graph_event: &Value,
|
||||
fallback_calendar_tz: Option<&str>,
|
||||
) -> Result<ConvertedEvent, GraphError> {
|
||||
let uid = graph_event
|
||||
.get("iCalUId")
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| graph_event.get("id").and_then(Value::as_str))
|
||||
.ok_or_else(|| GraphError::Malformed("event has neither iCalUId nor id".to_owned()))?
|
||||
.to_owned();
|
||||
|
||||
let event_type = classify_event_type(graph_event);
|
||||
let series_master_id = graph_event
|
||||
.get("seriesMasterId")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_owned);
|
||||
let original_start = graph_event
|
||||
.get("originalStart")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_owned);
|
||||
let is_draft = graph_event
|
||||
.get("isDraft")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false);
|
||||
|
||||
let mut card = Map::new();
|
||||
card.insert("@type".to_owned(), Value::from("Event"));
|
||||
card.insert("version".to_owned(), Value::from("2.0"));
|
||||
card.insert("uid".to_owned(), Value::from(uid.clone()));
|
||||
|
||||
if let Some(subject) = graph_event.get("subject").and_then(Value::as_str)
|
||||
&& !subject.is_empty()
|
||||
{
|
||||
card.insert("title".to_owned(), Value::from(subject.to_owned()));
|
||||
}
|
||||
|
||||
if let Some(body) = graph_event.get("body").and_then(Value::as_object)
|
||||
&& let Some(content) = body.get("content").and_then(Value::as_str)
|
||||
&& !content.is_empty()
|
||||
{
|
||||
card.insert("description".to_owned(), Value::from(content.to_owned()));
|
||||
}
|
||||
|
||||
let start_dt = extract_local_datetime(graph_event.get("start")).map(strip_fractional);
|
||||
let end_dt = extract_local_datetime(graph_event.get("end")).map(strip_fractional);
|
||||
|
||||
if let Some(start) = start_dt.as_deref() {
|
||||
card.insert("start".to_owned(), Value::from(start.to_owned()));
|
||||
}
|
||||
|
||||
if let (Some(start), Some(end)) = (start_dt.as_deref(), end_dt.as_deref())
|
||||
&& let Some(dur) = duration_iso8601(start, end)
|
||||
{
|
||||
card.insert("duration".to_owned(), Value::from(dur));
|
||||
}
|
||||
|
||||
let event_tz = graph_event
|
||||
.get("originalStartTimeZone")
|
||||
.and_then(Value::as_str)
|
||||
.and_then(windows_or_iana_to_iana)
|
||||
.or_else(|| {
|
||||
graph_event
|
||||
.get("start")
|
||||
.and_then(|s| s.get("timeZone"))
|
||||
.and_then(Value::as_str)
|
||||
.and_then(windows_or_iana_to_iana)
|
||||
})
|
||||
.or_else(|| fallback_calendar_tz.map(str::to_owned));
|
||||
if let Some(tz) = event_tz {
|
||||
card.insert("timeZone".to_owned(), Value::from(tz));
|
||||
}
|
||||
|
||||
if let Some(all_day) = graph_event.get("isAllDay").and_then(Value::as_bool)
|
||||
&& all_day
|
||||
{
|
||||
card.insert("showWithoutTime".to_owned(), Value::Bool(true));
|
||||
}
|
||||
|
||||
if let Some(true) = graph_event.get("isCancelled").and_then(Value::as_bool) {
|
||||
card.insert("status".to_owned(), Value::from("cancelled"));
|
||||
} else {
|
||||
card.insert("status".to_owned(), Value::from("confirmed"));
|
||||
}
|
||||
|
||||
if let Some(sens) = graph_event.get("sensitivity").and_then(Value::as_str) {
|
||||
card.insert("privacy".to_owned(), Value::from(privacy_for(sens)));
|
||||
}
|
||||
|
||||
if let Some(imp) = graph_event.get("importance").and_then(Value::as_str) {
|
||||
card.insert("priority".to_owned(), Value::from(priority_for(imp)));
|
||||
}
|
||||
|
||||
if let Some(show) = graph_event.get("showAs").and_then(Value::as_str) {
|
||||
card.insert(
|
||||
"freeBusyStatus".to_owned(),
|
||||
Value::from(free_busy_for(show)),
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(cats) = graph_event.get("categories").and_then(Value::as_array)
|
||||
&& !cats.is_empty()
|
||||
{
|
||||
let mut map = Map::new();
|
||||
for cat in cats.iter().filter_map(Value::as_str) {
|
||||
map.insert(cat.to_owned(), Value::Bool(true));
|
||||
}
|
||||
if !map.is_empty() {
|
||||
card.insert("keywords".to_owned(), Value::Object(map));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(locs) = graph_event.get("locations").and_then(Value::as_array)
|
||||
&& !locs.is_empty()
|
||||
{
|
||||
let mut locations = Map::new();
|
||||
let width = pad_width(locs.len());
|
||||
for (i, loc) in locs.iter().enumerate() {
|
||||
let key = format!("loc-{:0width$}", i + 1, width = width);
|
||||
let mut object = Map::new();
|
||||
object.insert("@type".to_owned(), Value::from("Location"));
|
||||
let display_name = loc
|
||||
.get("displayName")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|s| !s.is_empty());
|
||||
let address_str = loc
|
||||
.get("address")
|
||||
.and_then(Value::as_object)
|
||||
.map(|addr| {
|
||||
["street", "city", "state", "postalCode", "countryOrRegion"]
|
||||
.iter()
|
||||
.filter_map(|f| addr.get(*f).and_then(Value::as_str))
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
})
|
||||
.filter(|s| !s.is_empty());
|
||||
let name = match (display_name, address_str.as_deref()) {
|
||||
(Some(dn), Some(addr)) if dn != addr => Some(format!("{dn}, {addr}")),
|
||||
(Some(dn), _) => Some(dn.to_owned()),
|
||||
(None, Some(addr)) => Some(addr.to_owned()),
|
||||
(None, None) => None,
|
||||
};
|
||||
if let Some(n) = name {
|
||||
object.insert("name".to_owned(), Value::from(n));
|
||||
}
|
||||
locations.insert(key, Value::Object(object));
|
||||
}
|
||||
if !locations.is_empty() {
|
||||
card.insert("locations".to_owned(), Value::Object(locations));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(recurrence) = graph_event.get("recurrence")
|
||||
&& !recurrence.is_null()
|
||||
{
|
||||
let rule = convert_patterned_recurrence_rule(recurrence)?;
|
||||
card.insert("recurrenceRule".to_owned(), Value::Object(rule));
|
||||
}
|
||||
|
||||
let mut participants: Vec<(String, Value)> = Vec::new();
|
||||
if let Some(org) = graph_event
|
||||
.get("organizer")
|
||||
.and_then(|o| o.get("emailAddress"))
|
||||
{
|
||||
if let Some(addr) = org.get("address").and_then(Value::as_str)
|
||||
&& !addr.is_empty()
|
||||
{
|
||||
card.insert(
|
||||
"organizerCalendarAddress".to_owned(),
|
||||
Value::from(format!("mailto:{addr}")),
|
||||
);
|
||||
}
|
||||
let p = build_participant(org, &["owner", "chair"], None, None);
|
||||
participants.push(("organizer".to_owned(), p));
|
||||
}
|
||||
if let Some(attendees) = graph_event.get("attendees").and_then(Value::as_array) {
|
||||
let width = pad_width(attendees.len());
|
||||
for (i, att) in attendees.iter().enumerate() {
|
||||
let key = format!("att-{:0width$}", i + 1, width = width);
|
||||
let Some(email) = att.get("emailAddress") else {
|
||||
continue;
|
||||
};
|
||||
let role = att
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.map(attendee_type_to_role)
|
||||
.unwrap_or("required");
|
||||
let status = att
|
||||
.get("status")
|
||||
.and_then(|s| s.get("response"))
|
||||
.and_then(Value::as_str)
|
||||
.and_then(attendee_response_to_status);
|
||||
let p = build_participant(email, &[role], status, Some(true));
|
||||
participants.push((key, p));
|
||||
}
|
||||
}
|
||||
if !participants.is_empty() {
|
||||
let map: Map<String, Value> = participants.into_iter().collect();
|
||||
card.insert("participants".to_owned(), Value::Object(map));
|
||||
}
|
||||
|
||||
if let Some(created) = graph_event.get("createdDateTime").and_then(Value::as_str) {
|
||||
card.insert(
|
||||
"created".to_owned(),
|
||||
Value::from(strip_fractional_utc(created)),
|
||||
);
|
||||
}
|
||||
if let Some(updated) = graph_event
|
||||
.get("lastModifiedDateTime")
|
||||
.and_then(Value::as_str)
|
||||
{
|
||||
card.insert(
|
||||
"updated".to_owned(),
|
||||
Value::from(strip_fractional_utc(updated)),
|
||||
);
|
||||
}
|
||||
|
||||
let use_default_alerts = false;
|
||||
if let Some(true) = graph_event.get("isReminderOn").and_then(Value::as_bool)
|
||||
&& let Some(mins) = graph_event
|
||||
.get("reminderMinutesBeforeStart")
|
||||
.and_then(Value::as_i64)
|
||||
{
|
||||
let trigger = format!("-PT{}M", mins.unsigned_abs());
|
||||
let alert = json!({
|
||||
"@type": "Alert",
|
||||
"trigger": {
|
||||
"@type": "OffsetTrigger",
|
||||
"relativeTo": "start",
|
||||
"offset": trigger
|
||||
}
|
||||
});
|
||||
let mut alerts = Map::new();
|
||||
alerts.insert("alert-1".to_owned(), alert);
|
||||
card.insert("alerts".to_owned(), Value::Object(alerts));
|
||||
}
|
||||
|
||||
if let Some(cancels) = graph_event
|
||||
.get("cancelledOccurrences")
|
||||
.and_then(Value::as_array)
|
||||
&& !cancels.is_empty()
|
||||
{
|
||||
let mut overrides = Map::new();
|
||||
for entry in cancels.iter().filter_map(Value::as_str) {
|
||||
overrides.insert(entry.to_owned(), json!({"excluded": true}));
|
||||
}
|
||||
if !overrides.is_empty() {
|
||||
card.insert("recurrenceOverrides".to_owned(), Value::Object(overrides));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(ConvertedEvent {
|
||||
uid,
|
||||
data: Value::Object(card),
|
||||
is_draft,
|
||||
use_default_alerts,
|
||||
series_master_id,
|
||||
event_type,
|
||||
original_start,
|
||||
})
|
||||
}
|
||||
|
||||
fn extract_local_datetime(slot: Option<&Value>) -> Option<String> {
|
||||
let slot = slot?;
|
||||
let dt = slot.get("dateTime").and_then(Value::as_str)?;
|
||||
Some(dt.to_owned())
|
||||
}
|
||||
|
||||
fn build_participant(
|
||||
email: &Value,
|
||||
roles: &[&str],
|
||||
participation_status: Option<&'static str>,
|
||||
expect_reply: Option<bool>,
|
||||
) -> Value {
|
||||
let mut map = Map::new();
|
||||
map.insert("@type".to_owned(), Value::from("Participant"));
|
||||
if let Some(addr) = email.get("address").and_then(Value::as_str) {
|
||||
map.insert(
|
||||
"calendarAddress".to_owned(),
|
||||
Value::from(format!("mailto:{addr}")),
|
||||
);
|
||||
map.insert("email".to_owned(), Value::from(addr.to_owned()));
|
||||
}
|
||||
if let Some(name) = email.get("name").and_then(Value::as_str)
|
||||
&& !name.is_empty()
|
||||
{
|
||||
map.insert("name".to_owned(), Value::from(name.to_owned()));
|
||||
}
|
||||
let role_map: Map<String, Value> = roles
|
||||
.iter()
|
||||
.map(|r| ((*r).to_owned(), Value::Bool(true)))
|
||||
.collect();
|
||||
map.insert("roles".to_owned(), Value::Object(role_map));
|
||||
if let Some(status) = participation_status {
|
||||
map.insert(
|
||||
"participationStatus".to_owned(),
|
||||
Value::from(status.to_owned()),
|
||||
);
|
||||
}
|
||||
if let Some(b) = expect_reply {
|
||||
map.insert("expectReply".to_owned(), Value::Bool(b));
|
||||
}
|
||||
Value::Object(map)
|
||||
}
|
||||
|
||||
fn attendee_type_to_role(t: &str) -> &'static str {
|
||||
match t {
|
||||
"optional" => "optional",
|
||||
"resource" => "informational",
|
||||
_ => "required",
|
||||
}
|
||||
}
|
||||
|
||||
fn attendee_response_to_status(r: &str) -> Option<&'static str> {
|
||||
match r {
|
||||
"accepted" | "organizer" => Some("accepted"),
|
||||
"declined" => Some("declined"),
|
||||
"tentativelyAccepted" => Some("tentative"),
|
||||
"notResponded" | "none" => Some("needs-action"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn pad_width(n: usize) -> usize {
|
||||
let mut digits = 1;
|
||||
let mut k = n;
|
||||
while k >= 10 {
|
||||
digits += 1;
|
||||
k /= 10;
|
||||
}
|
||||
digits
|
||||
}
|
||||
|
||||
fn privacy_for(value: &str) -> &'static str {
|
||||
match value {
|
||||
"private" => "private",
|
||||
"confidential" => "secret",
|
||||
_ => "public",
|
||||
}
|
||||
}
|
||||
|
||||
fn priority_for(value: &str) -> i64 {
|
||||
match value {
|
||||
"high" => 1,
|
||||
"low" => 9,
|
||||
_ => 5,
|
||||
}
|
||||
}
|
||||
|
||||
fn free_busy_for(value: &str) -> &'static str {
|
||||
match value {
|
||||
"free" => "free",
|
||||
_ => "busy",
|
||||
}
|
||||
}
|
||||
|
||||
fn duration_iso8601(start: &str, end: &str) -> Option<String> {
|
||||
let s = parse_naive_seconds(start)?;
|
||||
let e = parse_naive_seconds(end)?;
|
||||
if e <= s {
|
||||
return None;
|
||||
}
|
||||
let total = e - s;
|
||||
let hours = total / 3600;
|
||||
let minutes = (total % 3600) / 60;
|
||||
let seconds = total % 60;
|
||||
let mut out = "PT".to_owned();
|
||||
use std::fmt::Write;
|
||||
if hours > 0 {
|
||||
let _ = write!(out, "{hours}H");
|
||||
}
|
||||
if minutes > 0 || (hours > 0 && seconds > 0) {
|
||||
let _ = write!(out, "{minutes}M");
|
||||
}
|
||||
if seconds > 0 || (hours == 0 && minutes == 0) {
|
||||
let _ = write!(out, "{seconds}S");
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn strip_fractional(s: String) -> String {
|
||||
if let Some(dot) = s.find('.') {
|
||||
s[..dot].to_owned()
|
||||
} else {
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
fn strip_fractional_utc(raw: &str) -> String {
|
||||
let trailing_z = raw.ends_with('Z');
|
||||
let trimmed = raw.trim_end_matches('Z');
|
||||
let base = match trimmed.find('.') {
|
||||
Some(dot) => &trimmed[..dot],
|
||||
None => trimmed,
|
||||
};
|
||||
if trailing_z {
|
||||
format!("{base}Z")
|
||||
} else {
|
||||
base.to_owned()
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_naive_seconds(s: &str) -> Option<i64> {
|
||||
let (date, time) = s.split_once('T')?;
|
||||
let mut date_parts = date.split('-');
|
||||
let year: i64 = date_parts.next()?.parse().ok()?;
|
||||
let month: i64 = date_parts.next()?.parse().ok()?;
|
||||
let day: i64 = date_parts.next()?.parse().ok()?;
|
||||
let time = time.split('.').next()?;
|
||||
let time = time.trim_end_matches('Z');
|
||||
let mut time_parts = time.split(':');
|
||||
let hh: i64 = time_parts.next()?.parse().ok()?;
|
||||
let mm: i64 = time_parts.next()?.parse().ok()?;
|
||||
let ss: i64 = time_parts.next().unwrap_or("0").parse().ok()?;
|
||||
Some(days_from_civil(year, month, day) * 86400 + hh * 3600 + mm * 60 + ss)
|
||||
}
|
||||
|
||||
fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
|
||||
let y = if m <= 2 { y - 1 } else { y };
|
||||
let era = (if y >= 0 { y } else { y - 399 }) / 400;
|
||||
let yoe = (y - era * 400) as u64;
|
||||
let m_adj: i64 = if m > 2 { m - 3 } else { m + 9 };
|
||||
let doy = ((153 * m_adj + 2) / 5 + d - 1) as u64;
|
||||
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
|
||||
era * 146097 + (doe as i64) - 719468
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn sample() -> Value {
|
||||
json!({
|
||||
"id": "AAA",
|
||||
"iCalUId": "uid-1",
|
||||
"type": "singleInstance",
|
||||
"subject": "Sync up",
|
||||
"body": {"contentType": "text", "content": "Agenda"},
|
||||
"start": {"dateTime": "2026-05-27T10:00:00.0000000", "timeZone": "UTC"},
|
||||
"end": {"dateTime": "2026-05-27T11:00:00.0000000", "timeZone": "UTC"},
|
||||
"isAllDay": false,
|
||||
"isCancelled": false,
|
||||
"sensitivity": "private",
|
||||
"importance": "high",
|
||||
"showAs": "busy",
|
||||
"categories": ["Red", "Internal"],
|
||||
"createdDateTime": "2026-05-01T08:00:00Z",
|
||||
"lastModifiedDateTime": "2026-05-02T08:00:00Z",
|
||||
"isReminderOn": true,
|
||||
"reminderMinutesBeforeStart": 15,
|
||||
"isDraft": false,
|
||||
"organizer": {"emailAddress": {"name": "Alice", "address": "[email protected]"}},
|
||||
"attendees": [
|
||||
{"emailAddress": {"name": "Bob", "address": "[email protected]"}, "type": "required"}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_event_type_dispatch() {
|
||||
assert_eq!(
|
||||
classify_event_type(&json!({"type": "seriesMaster"})),
|
||||
EventType::SeriesMaster
|
||||
);
|
||||
assert_eq!(
|
||||
classify_event_type(&json!({"type": "occurrence"})),
|
||||
EventType::Occurrence
|
||||
);
|
||||
assert_eq!(
|
||||
classify_event_type(&json!({"type": "exception"})),
|
||||
EventType::Exception
|
||||
);
|
||||
assert_eq!(classify_event_type(&json!({})), EventType::SingleInstance);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_instance_converts_to_event_value() {
|
||||
let conv = convert_event(&sample(), Some("UTC")).unwrap();
|
||||
assert_eq!(conv.uid, "uid-1");
|
||||
assert_eq!(conv.data["@type"], "Event");
|
||||
assert_eq!(conv.data["title"], "Sync up");
|
||||
assert_eq!(conv.data["description"], "Agenda");
|
||||
assert_eq!(conv.data["start"], "2026-05-27T10:00:00");
|
||||
assert_eq!(conv.data["duration"], "PT1H");
|
||||
assert_eq!(conv.data["status"], "confirmed");
|
||||
assert_eq!(conv.data["privacy"], "private");
|
||||
assert_eq!(conv.data["priority"], 1);
|
||||
assert_eq!(conv.data["freeBusyStatus"], "busy");
|
||||
let kws = conv.data["keywords"].as_object().unwrap();
|
||||
assert!(kws.contains_key("Red"));
|
||||
assert!(kws.contains_key("Internal"));
|
||||
assert!(conv.data.get("categories").is_none());
|
||||
assert_eq!(conv.data["version"], "2.0");
|
||||
let alerts = conv.data["alerts"].as_object().unwrap();
|
||||
assert_eq!(alerts["alert-1"]["trigger"]["offset"], "-PT15M");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancelled_event_status_is_cancelled() {
|
||||
let mut v = sample();
|
||||
v["isCancelled"] = Value::Bool(true);
|
||||
let conv = convert_event(&v, None).unwrap();
|
||||
assert_eq!(conv.data["status"], "cancelled");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_day_sets_show_without_time() {
|
||||
let mut v = sample();
|
||||
v["isAllDay"] = Value::Bool(true);
|
||||
let conv = convert_event(&v, None).unwrap();
|
||||
assert_eq!(conv.data["showWithoutTime"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn priority_mapping_covers_three_levels() {
|
||||
let conv = convert_event(&sample(), None).unwrap();
|
||||
assert_eq!(conv.data["priority"], 1);
|
||||
let mut v = sample();
|
||||
v["importance"] = Value::from("normal");
|
||||
assert_eq!(convert_event(&v, None).unwrap().data["priority"], 5);
|
||||
v["importance"] = Value::from("low");
|
||||
assert_eq!(convert_event(&v, None).unwrap().data["priority"], 9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_handles_subhour() {
|
||||
assert_eq!(
|
||||
duration_iso8601("2026-05-27T10:00:00", "2026-05-27T10:30:00").as_deref(),
|
||||
Some("PT30M")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_handles_multi_day() {
|
||||
assert_eq!(
|
||||
duration_iso8601("2026-05-27T10:00:00", "2026-05-28T11:30:45").as_deref(),
|
||||
Some("PT25H30M45S")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_tz_fallback_returns_iana() {
|
||||
assert_eq!(
|
||||
windows_or_iana_to_iana("Pacific Standard Time").as_deref(),
|
||||
Some("America/Los_Angeles")
|
||||
);
|
||||
assert_eq!(
|
||||
windows_or_iana_to_iana("America/New_York").as_deref(),
|
||||
Some("America/New_York")
|
||||
);
|
||||
assert!(windows_or_iana_to_iana("Made Up Zone").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn organizer_and_attendees_become_participants() {
|
||||
let conv = convert_event(&sample(), None).unwrap();
|
||||
let participants = conv.data["participants"].as_object().unwrap();
|
||||
assert!(participants.contains_key("organizer"));
|
||||
assert_eq!(participants["organizer"]["email"], "[email protected]");
|
||||
assert_eq!(
|
||||
participants["organizer"]["calendarAddress"],
|
||||
"mailto:[email protected]"
|
||||
);
|
||||
assert_eq!(participants["organizer"]["roles"]["owner"], true);
|
||||
assert_eq!(participants["organizer"]["roles"]["chair"], true);
|
||||
assert!(participants.contains_key("att-1"));
|
||||
assert_eq!(participants["att-1"]["email"], "[email protected]");
|
||||
assert_eq!(participants["att-1"]["roles"]["required"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn optional_attendee_keeps_role_and_uses_status_when_present() {
|
||||
let mut v = sample();
|
||||
v["attendees"] = json!([
|
||||
{
|
||||
"emailAddress": {"name": "Carol", "address": "[email protected]"},
|
||||
"type": "optional",
|
||||
"status": {"response": "tentativelyAccepted"}
|
||||
}
|
||||
]);
|
||||
let conv = convert_event(&v, None).unwrap();
|
||||
let p = &conv.data["participants"]["att-1"];
|
||||
assert_eq!(p["roles"]["optional"], true);
|
||||
assert!(p["roles"].get("required").is_none());
|
||||
assert_eq!(p["participationStatus"], "tentative");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_attendees_zero_pad_keys_for_stable_sort() {
|
||||
let mut v = sample();
|
||||
let mut atts = Vec::new();
|
||||
for i in 1..=11 {
|
||||
atts.push(json!({
|
||||
"emailAddress": {"address": format!("u{i}@x.com")},
|
||||
"type": "required"
|
||||
}));
|
||||
}
|
||||
v["attendees"] = Value::Array(atts);
|
||||
let conv = convert_event(&v, None).unwrap();
|
||||
let map = conv.data["participants"].as_object().unwrap();
|
||||
assert!(map.contains_key("att-01"));
|
||||
assert!(map.contains_key("att-11"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn original_start_time_zone_is_preserved() {
|
||||
let mut v = sample();
|
||||
v["originalStartTimeZone"] = Value::from("Pacific Standard Time");
|
||||
v["start"]["timeZone"] = Value::from("UTC");
|
||||
let conv = convert_event(&v, None).unwrap();
|
||||
assert_eq!(conv.data["timeZone"], "America/Los_Angeles");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_h_and_s_no_minutes_includes_zero_m() {
|
||||
assert_eq!(
|
||||
duration_iso8601("2026-05-27T10:00:00", "2026-05-27T11:00:05").as_deref(),
|
||||
Some("PT1H0M5S")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fractional_seconds_are_stripped_from_timestamps() {
|
||||
let mut v = sample();
|
||||
v["createdDateTime"] = Value::from("2026-05-01T08:00:00.0000000Z");
|
||||
v["lastModifiedDateTime"] = Value::from("2026-05-02T08:00:00.123Z");
|
||||
let conv = convert_event(&v, None).unwrap();
|
||||
assert_eq!(conv.data["created"], "2026-05-01T08:00:00Z");
|
||||
assert_eq!(conv.data["updated"], "2026-05-02T08:00:00Z");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn organizer_calendar_address_is_set_alongside_participants() {
|
||||
let conv = convert_event(&sample(), None).unwrap();
|
||||
assert_eq!(conv.data["organizerCalendarAddress"], "mailto:[email protected]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recurrence_emits_singular_recurrence_rule_object() {
|
||||
let mut v = sample();
|
||||
v["recurrence"] = json!({
|
||||
"pattern": {"type": "daily", "interval": 1},
|
||||
"range": {"type": "noEnd"}
|
||||
});
|
||||
let conv = convert_event(&v, None).unwrap();
|
||||
assert!(conv.data.get("recurrenceRules").is_none());
|
||||
let rule = &conv.data["recurrenceRule"];
|
||||
assert!(rule.is_object());
|
||||
assert_eq!(rule["@type"], "RecurrenceRule");
|
||||
assert_eq!(rule["frequency"], "daily");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn location_with_address_only_uses_name_property() {
|
||||
let mut v = sample();
|
||||
v["locations"] = json!([{
|
||||
"address": {"street": "1 Infinite Loop", "city": "Cupertino"}
|
||||
}]);
|
||||
let conv = convert_event(&v, None).unwrap();
|
||||
let locs = conv.data["locations"].as_object().unwrap();
|
||||
let only = locs.values().next().unwrap();
|
||||
assert_eq!(only["@type"], "Location");
|
||||
assert_eq!(only["name"], "1 Infinite Loop, Cupertino");
|
||||
assert!(only.get("description").is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,454 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::atomic::{AtomicU8, AtomicU64, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
use serde_json::Value;
|
||||
use ureq::Agent;
|
||||
use ureq::config::{Config, RedirectAuthHeaders};
|
||||
use ureq::tls::{RootCerts, TlsConfig};
|
||||
|
||||
use crate::exchange_graph::error::GraphError;
|
||||
use crate::exchange_graph::retry::{HttpClass, classify_http_status, is_throttled};
|
||||
use crate::jmap::http::{RetryPolicy, retry_after_header};
|
||||
use crate::jmap::retry::{self, RateLimitState};
|
||||
use crate::logging::{LEVEL_BODIES, LEVEL_DEFAULT, LEVEL_PROGRESS, Logger};
|
||||
|
||||
const MAX_BODY: u64 = 256 * 1024 * 1024;
|
||||
const LONG_RETRY_THRESHOLD: Duration = Duration::from_secs(10);
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum Accept {
|
||||
Json,
|
||||
Text,
|
||||
}
|
||||
|
||||
impl Accept {
|
||||
fn header_value(self) -> &'static str {
|
||||
match self {
|
||||
Accept::Json => "application/json",
|
||||
Accept::Text => "text/plain",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GraphResponse {
|
||||
pub status: u16,
|
||||
pub body: Vec<u8>,
|
||||
pub content_type: Option<String>,
|
||||
}
|
||||
|
||||
impl GraphResponse {
|
||||
pub fn as_str(&self) -> Result<&str, GraphError> {
|
||||
std::str::from_utf8(&self.body)
|
||||
.map_err(|e| GraphError::Malformed(format!("response is not utf-8: {e}")))
|
||||
}
|
||||
|
||||
pub fn json(&self) -> Result<Value, GraphError> {
|
||||
serde_json::from_slice(&self.body)
|
||||
.map_err(|e| GraphError::Malformed(format!("response is not valid json: {e}")))
|
||||
}
|
||||
}
|
||||
|
||||
struct Inner {
|
||||
agent: Agent,
|
||||
bearer: Mutex<String>,
|
||||
retry: RetryPolicy,
|
||||
rate_limit: RateLimitState,
|
||||
log_level: AtomicU8,
|
||||
retries_total: AtomicU64,
|
||||
retry_after_sleeps: AtomicU64,
|
||||
requests_total: AtomicU64,
|
||||
user_agent: String,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct GraphClient {
|
||||
inner: Arc<Inner>,
|
||||
}
|
||||
|
||||
enum Attempt {
|
||||
Ok {
|
||||
status: u16,
|
||||
body: Vec<u8>,
|
||||
retry_after: Option<Duration>,
|
||||
content_type: Option<String>,
|
||||
},
|
||||
Transport(GraphError),
|
||||
}
|
||||
|
||||
impl GraphClient {
|
||||
pub fn new(bearer: String, retry: RetryPolicy, allow_invalid_certs: bool) -> GraphClient {
|
||||
let config: Config = Config::builder()
|
||||
.http_status_as_error(false)
|
||||
.redirect_auth_headers(RedirectAuthHeaders::SameHost)
|
||||
.tls_config(
|
||||
TlsConfig::builder()
|
||||
.root_certs(RootCerts::PlatformVerifier)
|
||||
.disable_verification(allow_invalid_certs)
|
||||
.build(),
|
||||
)
|
||||
.build();
|
||||
GraphClient {
|
||||
inner: Arc::new(Inner {
|
||||
agent: config.new_agent(),
|
||||
bearer: Mutex::new(bearer),
|
||||
retry,
|
||||
rate_limit: RateLimitState::new(),
|
||||
log_level: AtomicU8::new(LEVEL_DEFAULT),
|
||||
retries_total: AtomicU64::new(0),
|
||||
retry_after_sleeps: AtomicU64::new(0),
|
||||
requests_total: AtomicU64::new(0),
|
||||
user_agent: format!("vandelay/{}", env!("CARGO_PKG_VERSION")),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_bearer(&self, bearer: String) {
|
||||
let mut g = match self.inner.bearer.lock() {
|
||||
Ok(g) => g,
|
||||
Err(p) => p.into_inner(),
|
||||
};
|
||||
*g = bearer;
|
||||
}
|
||||
|
||||
pub fn set_logger(&self, logger: Logger) {
|
||||
self.inner
|
||||
.log_level
|
||||
.store(logger.level(), Ordering::Relaxed);
|
||||
}
|
||||
|
||||
fn logger(&self) -> Logger {
|
||||
Logger::new(self.inner.log_level.load(Ordering::Relaxed))
|
||||
}
|
||||
|
||||
fn bearer(&self) -> String {
|
||||
match self.inner.bearer.lock() {
|
||||
Ok(g) => g.clone(),
|
||||
Err(p) => p.into_inner().clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn auth_header(&self) -> String {
|
||||
format!("Bearer {}", self.bearer())
|
||||
}
|
||||
|
||||
pub fn retries_observed(&self) -> u64 {
|
||||
self.inner.retries_total.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
pub fn retry_after_sleeps(&self) -> u64 {
|
||||
self.inner.retry_after_sleeps.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
pub fn requests_observed(&self) -> u64 {
|
||||
self.inner.requests_total.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
pub fn rate_limit(&self) -> &RateLimitState {
|
||||
&self.inner.rate_limit
|
||||
}
|
||||
|
||||
pub fn get(&self, url: &str, accept: Accept) -> Result<GraphResponse, GraphError> {
|
||||
self.execute("GET", url, accept, &[])
|
||||
}
|
||||
|
||||
pub fn get_with_prefer(
|
||||
&self,
|
||||
url: &str,
|
||||
accept: Accept,
|
||||
prefer: &[&str],
|
||||
) -> Result<GraphResponse, GraphError> {
|
||||
self.execute("GET", url, accept, prefer)
|
||||
}
|
||||
|
||||
pub fn get_json(&self, url: &str) -> Result<Value, GraphError> {
|
||||
self.get(url, Accept::Json).and_then(|r| r.json())
|
||||
}
|
||||
|
||||
pub fn get_json_with_prefer(&self, url: &str, prefer: &[&str]) -> Result<Value, GraphError> {
|
||||
self.get_with_prefer(url, Accept::Json, prefer)
|
||||
.and_then(|r| r.json())
|
||||
}
|
||||
|
||||
fn execute(
|
||||
&self,
|
||||
method: &str,
|
||||
url: &str,
|
||||
accept: Accept,
|
||||
extra_prefer: &[&str],
|
||||
) -> Result<GraphResponse, GraphError> {
|
||||
let policy = self.inner.retry;
|
||||
let logger = self.logger();
|
||||
let mut attempt: u32 = 0;
|
||||
loop {
|
||||
self.inner.rate_limit.cooldown().wait();
|
||||
self.inner.requests_total.fetch_add(1, Ordering::Relaxed);
|
||||
let outcome = self.one_attempt(method, url, accept, extra_prefer);
|
||||
match outcome {
|
||||
Attempt::Ok {
|
||||
status,
|
||||
body,
|
||||
retry_after,
|
||||
content_type,
|
||||
} => {
|
||||
let class = classify_http_status(status);
|
||||
match class {
|
||||
HttpClass::Success => {
|
||||
self.inner.rate_limit.on_success();
|
||||
return Ok(GraphResponse {
|
||||
status,
|
||||
body,
|
||||
content_type,
|
||||
});
|
||||
}
|
||||
HttpClass::Vanished => {
|
||||
return Err(GraphError::Vanished);
|
||||
}
|
||||
HttpClass::Auth => {
|
||||
return Err(GraphError::Auth(format!(
|
||||
"server returned {status}: {}",
|
||||
truncate(&body)
|
||||
)));
|
||||
}
|
||||
HttpClass::Fatal => {
|
||||
return Err(GraphError::HttpStatus {
|
||||
status,
|
||||
body: truncate(&body),
|
||||
});
|
||||
}
|
||||
HttpClass::Retryable => {
|
||||
attempt += 1;
|
||||
self.inner.retries_total.fetch_add(1, Ordering::Relaxed);
|
||||
if attempt > policy.max_retries {
|
||||
return Err(GraphError::RetriesExhausted(format!(
|
||||
"{method} {url} kept returning http {status}"
|
||||
)));
|
||||
}
|
||||
if retry_after.is_some() {
|
||||
self.inner
|
||||
.retry_after_sleeps
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
let delay = if is_throttled(status) {
|
||||
self.inner.rate_limit.on_throttle(&policy, retry_after)
|
||||
} else {
|
||||
match retry_after {
|
||||
Some(d) => d,
|
||||
None => retry::backoff_delay(&policy, attempt),
|
||||
}
|
||||
};
|
||||
if delay >= LONG_RETRY_THRESHOLD {
|
||||
logger.warn(&format!(
|
||||
"graph rate-limited (http {status}); waiting {} before retry {}/{} (shared level {})",
|
||||
format_retry_wait(delay),
|
||||
attempt,
|
||||
policy.max_retries,
|
||||
self.inner.rate_limit.level(),
|
||||
));
|
||||
}
|
||||
self.log_retry(
|
||||
&logger,
|
||||
RetryLog {
|
||||
method,
|
||||
url,
|
||||
attempt,
|
||||
delay,
|
||||
status,
|
||||
body: &body,
|
||||
},
|
||||
);
|
||||
std::thread::sleep(delay);
|
||||
}
|
||||
}
|
||||
}
|
||||
Attempt::Transport(err) => {
|
||||
if matches!(err, GraphError::Connect(_)) {
|
||||
return Err(err);
|
||||
}
|
||||
attempt += 1;
|
||||
self.inner.retries_total.fetch_add(1, Ordering::Relaxed);
|
||||
if attempt > policy.max_retries {
|
||||
return Err(GraphError::RetriesExhausted(format!(
|
||||
"{method} {url}: {err}"
|
||||
)));
|
||||
}
|
||||
let delay = retry::backoff_delay(&policy, attempt);
|
||||
if delay >= LONG_RETRY_THRESHOLD {
|
||||
logger.warn(&format!(
|
||||
"transient transport failure ({err}); waiting {} before retry {}/{}",
|
||||
format_retry_wait(delay),
|
||||
attempt,
|
||||
policy.max_retries
|
||||
));
|
||||
}
|
||||
std::thread::sleep(delay);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn one_attempt(
|
||||
&self,
|
||||
method: &str,
|
||||
url: &str,
|
||||
accept: Accept,
|
||||
extra_prefer: &[&str],
|
||||
) -> Attempt {
|
||||
let mut req = match method {
|
||||
"GET" => self.inner.agent.get(url),
|
||||
other => {
|
||||
return Attempt::Transport(GraphError::Connect(format!(
|
||||
"unsupported method {other} (graph importer is read-only)"
|
||||
)));
|
||||
}
|
||||
};
|
||||
req = req
|
||||
.header("Authorization", self.auth_header())
|
||||
.header("Accept", accept.header_value())
|
||||
.header("User-Agent", self.inner.user_agent.as_str());
|
||||
req = req.header("Prefer", "IdType=\"ImmutableId\"");
|
||||
for value in extra_prefer {
|
||||
req = req.header("Prefer", *value);
|
||||
}
|
||||
match req.call() {
|
||||
Ok(mut resp) => {
|
||||
let status = resp.status().as_u16();
|
||||
let retry_after = resp
|
||||
.headers()
|
||||
.get("retry-after")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(retry_after_header);
|
||||
let content_type = resp
|
||||
.headers()
|
||||
.get("content-type")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(str::to_owned);
|
||||
match resp.body_mut().with_config().limit(MAX_BODY).read_to_vec() {
|
||||
Ok(bytes) => Attempt::Ok {
|
||||
status,
|
||||
body: bytes,
|
||||
retry_after,
|
||||
content_type,
|
||||
},
|
||||
Err(e) => Attempt::Transport(GraphError::Transport(format!(
|
||||
"reading response body: {e}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
Err(e) => Attempt::Transport(map_ureq_error(e)),
|
||||
}
|
||||
}
|
||||
|
||||
fn log_retry(&self, logger: &Logger, info: RetryLog<'_>) {
|
||||
let RetryLog {
|
||||
method,
|
||||
url,
|
||||
attempt,
|
||||
delay,
|
||||
status,
|
||||
body,
|
||||
} = info;
|
||||
if logger.enabled(LEVEL_BODIES) {
|
||||
eprintln!(
|
||||
"retry {}/{} {} {} after {:?} (http {status}) body={}",
|
||||
attempt,
|
||||
self.inner.retry.max_retries,
|
||||
method,
|
||||
url,
|
||||
delay,
|
||||
truncate(body)
|
||||
);
|
||||
} else if logger.enabled(LEVEL_PROGRESS) {
|
||||
eprintln!(
|
||||
"retry {}/{} (http {status})",
|
||||
attempt, self.inner.retry.max_retries
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct RetryLog<'a> {
|
||||
method: &'a str,
|
||||
url: &'a str,
|
||||
attempt: u32,
|
||||
delay: Duration,
|
||||
status: u16,
|
||||
body: &'a [u8],
|
||||
}
|
||||
|
||||
fn map_ureq_error(err: ureq::Error) -> GraphError {
|
||||
match err {
|
||||
ureq::Error::Io(e) => GraphError::Transport(format!("io: {e}")),
|
||||
ureq::Error::Timeout(t) => GraphError::Transport(format!("timeout: {t}")),
|
||||
ureq::Error::HostNotFound => GraphError::Transport("host not found".to_owned()),
|
||||
ureq::Error::ConnectionFailed => GraphError::Transport("connection failed".to_owned()),
|
||||
ureq::Error::BodyStalled => GraphError::Transport("body stalled".to_owned()),
|
||||
ureq::Error::Tls(m) => GraphError::Transport(format!("tls: {m}")),
|
||||
ureq::Error::TooManyRedirects => GraphError::Connect("too many redirects".to_owned()),
|
||||
ureq::Error::RedirectFailed => GraphError::Connect("redirect failed".to_owned()),
|
||||
ureq::Error::TlsRequired => {
|
||||
GraphError::Connect("server requires TLS but transport is unsecured".to_owned())
|
||||
}
|
||||
other => GraphError::Connect(other.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn truncate(body: &[u8]) -> String {
|
||||
let text = String::from_utf8_lossy(body);
|
||||
if text.len() <= 512 {
|
||||
return text.into_owned();
|
||||
}
|
||||
let end = text.floor_char_boundary(512);
|
||||
format!("{}...", &text[..end])
|
||||
}
|
||||
|
||||
fn format_retry_wait(d: Duration) -> String {
|
||||
let total = d.as_secs();
|
||||
if total < 60 {
|
||||
format!("{total}s")
|
||||
} else if total < 3600 {
|
||||
format!("{}m{:02}s", total / 60, total % 60)
|
||||
} else {
|
||||
format!(
|
||||
"{}h{:02}m{:02}s",
|
||||
total / 3600,
|
||||
(total % 3600) / 60,
|
||||
total % 60
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn defaults_construct() {
|
||||
let c = GraphClient::new("token".to_owned(), RetryPolicy::new(3), false);
|
||||
assert_eq!(c.retries_observed(), 0);
|
||||
assert_eq!(c.retry_after_sleeps(), 0);
|
||||
assert_eq!(c.requests_observed(), 0);
|
||||
assert_eq!(c.auth_header(), "Bearer token");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bearer_can_be_swapped_at_runtime() {
|
||||
let c = GraphClient::new("old".to_owned(), RetryPolicy::new(0), false);
|
||||
c.set_bearer("new".to_owned());
|
||||
assert_eq!(c.auth_header(), "Bearer new");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accept_header_values_are_what_the_spec_requires() {
|
||||
assert_eq!(Accept::Json.header_value(), "application/json");
|
||||
assert_eq!(Accept::Text.header_value(), "text/plain");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,586 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use crate::exchange_graph::error::GraphError;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ConvertedContact {
|
||||
pub uid: String,
|
||||
pub data: Value,
|
||||
}
|
||||
|
||||
pub fn convert_contact(graph_contact: &Value) -> Result<ConvertedContact, GraphError> {
|
||||
let graph_id = graph_contact
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| GraphError::Malformed("contact has no id".to_owned()))?;
|
||||
let uid = synthetic_uid(graph_id);
|
||||
|
||||
let mut card = Map::new();
|
||||
card.insert("@type".to_owned(), Value::from("Card"));
|
||||
card.insert("version".to_owned(), Value::from("1.0"));
|
||||
card.insert("uid".to_owned(), Value::from(uid.clone()));
|
||||
card.insert("kind".to_owned(), Value::from("individual"));
|
||||
|
||||
let mut name_object = Map::new();
|
||||
name_object.insert("@type".to_owned(), Value::from("Name"));
|
||||
if let Some(display) = graph_contact.get("displayName").and_then(Value::as_str)
|
||||
&& !display.is_empty()
|
||||
{
|
||||
name_object.insert("full".to_owned(), Value::from(display.to_owned()));
|
||||
}
|
||||
let name_slots = [
|
||||
("title", graph_contact.get("title").and_then(Value::as_str)),
|
||||
(
|
||||
"given",
|
||||
graph_contact.get("givenName").and_then(Value::as_str),
|
||||
),
|
||||
(
|
||||
"given2",
|
||||
graph_contact.get("middleName").and_then(Value::as_str),
|
||||
),
|
||||
(
|
||||
"surname",
|
||||
graph_contact.get("surname").and_then(Value::as_str),
|
||||
),
|
||||
(
|
||||
"generation",
|
||||
graph_contact.get("generation").and_then(Value::as_str),
|
||||
),
|
||||
];
|
||||
let mut components: Vec<Value> = Vec::new();
|
||||
let mut slot_index: std::collections::HashMap<&'static str, usize> =
|
||||
std::collections::HashMap::new();
|
||||
for (kind, value) in name_slots {
|
||||
let Some(v) = value else { continue };
|
||||
if v.is_empty() {
|
||||
continue;
|
||||
}
|
||||
slot_index.insert(kind, components.len());
|
||||
components.push(json!({"@type": "NameComponent", "kind": kind, "value": v}));
|
||||
}
|
||||
if !components.is_empty() {
|
||||
name_object.insert("components".to_owned(), Value::Array(components));
|
||||
}
|
||||
if !name_object.is_empty() {
|
||||
card.insert("name".to_owned(), Value::Object(name_object));
|
||||
}
|
||||
|
||||
if let Some(nick) = graph_contact.get("nickName").and_then(Value::as_str)
|
||||
&& !nick.is_empty()
|
||||
{
|
||||
let mut nicks = Map::new();
|
||||
nicks.insert(
|
||||
"nick-1".to_owned(),
|
||||
json!({"@type": "Nickname", "name": nick}),
|
||||
);
|
||||
card.insert("nicknames".to_owned(), Value::Object(nicks));
|
||||
}
|
||||
|
||||
let mut emails = Map::new();
|
||||
if let Some(addrs) = graph_contact
|
||||
.get("emailAddresses")
|
||||
.and_then(Value::as_array)
|
||||
{
|
||||
for (i, e) in addrs.iter().enumerate() {
|
||||
let Some(addr) = e.get("address").and_then(Value::as_str) else {
|
||||
continue;
|
||||
};
|
||||
if addr.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let key = format!("email-{}", i + 1);
|
||||
let mut entry = Map::new();
|
||||
entry.insert("@type".to_owned(), Value::from("EmailAddress"));
|
||||
entry.insert("address".to_owned(), Value::from(addr.to_owned()));
|
||||
if i == 0 {
|
||||
entry.insert("pref".to_owned(), Value::from(1));
|
||||
}
|
||||
emails.insert(key, Value::Object(entry));
|
||||
}
|
||||
}
|
||||
if !emails.is_empty() {
|
||||
card.insert("emails".to_owned(), Value::Object(emails));
|
||||
}
|
||||
|
||||
let mut phones = Map::new();
|
||||
push_phones(
|
||||
&mut phones,
|
||||
graph_contact
|
||||
.get("businessPhones")
|
||||
.and_then(Value::as_array),
|
||||
"work",
|
||||
);
|
||||
push_phones(
|
||||
&mut phones,
|
||||
graph_contact.get("homePhones").and_then(Value::as_array),
|
||||
"private",
|
||||
);
|
||||
if let Some(mobile) = graph_contact.get("mobilePhone").and_then(Value::as_str)
|
||||
&& !mobile.is_empty()
|
||||
{
|
||||
let key = format!("phone-{}", phones.len() + 1);
|
||||
phones.insert(
|
||||
key,
|
||||
json!({
|
||||
"@type": "Phone",
|
||||
"number": mobile,
|
||||
"features": {"mobile": true, "voice": true}
|
||||
}),
|
||||
);
|
||||
}
|
||||
if !phones.is_empty() {
|
||||
card.insert("phones".to_owned(), Value::Object(phones));
|
||||
}
|
||||
|
||||
let mut addresses = Map::new();
|
||||
push_address(&mut addresses, graph_contact.get("homeAddress"), "private");
|
||||
push_address(&mut addresses, graph_contact.get("businessAddress"), "work");
|
||||
push_address(&mut addresses, graph_contact.get("otherAddress"), "other");
|
||||
if !addresses.is_empty() {
|
||||
card.insert("addresses".to_owned(), Value::Object(addresses));
|
||||
}
|
||||
|
||||
let mut organizations = Map::new();
|
||||
let has_company = graph_contact
|
||||
.get("companyName")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|s| !s.is_empty());
|
||||
let has_dept = graph_contact
|
||||
.get("department")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|s| !s.is_empty());
|
||||
let has_office = graph_contact
|
||||
.get("officeLocation")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|s| !s.is_empty());
|
||||
let has_title = graph_contact
|
||||
.get("jobTitle")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|s| !s.is_empty());
|
||||
if has_company || has_dept || has_office {
|
||||
let mut org = Map::new();
|
||||
org.insert("@type".to_owned(), Value::from("Organization"));
|
||||
if let Some(name) = graph_contact.get("companyName").and_then(Value::as_str)
|
||||
&& !name.is_empty()
|
||||
{
|
||||
org.insert("name".to_owned(), Value::from(name.to_owned()));
|
||||
}
|
||||
let mut units: Vec<Value> = Vec::new();
|
||||
if let Some(dept) = graph_contact.get("department").and_then(Value::as_str)
|
||||
&& !dept.is_empty()
|
||||
{
|
||||
units.push(json!({"@type": "OrgUnit", "name": dept}));
|
||||
}
|
||||
if let Some(office) = graph_contact.get("officeLocation").and_then(Value::as_str)
|
||||
&& !office.is_empty()
|
||||
{
|
||||
units.push(json!({"@type": "OrgUnit", "name": office}));
|
||||
}
|
||||
if !units.is_empty() {
|
||||
org.insert("units".to_owned(), Value::Array(units));
|
||||
}
|
||||
organizations.insert("org-1".to_owned(), Value::Object(org));
|
||||
}
|
||||
if has_title
|
||||
&& let Some(title) = graph_contact.get("jobTitle").and_then(Value::as_str)
|
||||
&& !title.is_empty()
|
||||
{
|
||||
let mut titles = Map::new();
|
||||
let mut title_obj = Map::new();
|
||||
title_obj.insert("@type".to_owned(), Value::from("Title"));
|
||||
title_obj.insert("name".to_owned(), Value::from(title.to_owned()));
|
||||
if organizations.contains_key("org-1") {
|
||||
title_obj.insert("organizationId".to_owned(), Value::from("org-1"));
|
||||
}
|
||||
titles.insert("title-1".to_owned(), Value::Object(title_obj));
|
||||
card.insert("titles".to_owned(), Value::Object(titles));
|
||||
}
|
||||
let has_org_one = organizations.contains_key("org-1");
|
||||
if !organizations.is_empty() {
|
||||
card.insert("organizations".to_owned(), Value::Object(organizations));
|
||||
}
|
||||
|
||||
if let Some(notes) = graph_contact.get("personalNotes").and_then(Value::as_str)
|
||||
&& !notes.is_empty()
|
||||
{
|
||||
let mut map = Map::new();
|
||||
map.insert("note-1".to_owned(), json!({"@type": "Note", "note": notes}));
|
||||
card.insert("notes".to_owned(), Value::Object(map));
|
||||
}
|
||||
|
||||
let mut localizations = Map::new();
|
||||
let mut ja = Map::new();
|
||||
if let Some(name) = graph_contact.get("yomiCompanyName").and_then(Value::as_str)
|
||||
&& !name.is_empty()
|
||||
&& has_org_one
|
||||
{
|
||||
ja.insert(
|
||||
"organizations/org-1/name".to_owned(),
|
||||
Value::from(name.to_owned()),
|
||||
);
|
||||
}
|
||||
if let Some(name) = graph_contact.get("yomiGivenName").and_then(Value::as_str)
|
||||
&& !name.is_empty()
|
||||
&& let Some(idx) = slot_index.get("given")
|
||||
{
|
||||
ja.insert(
|
||||
format!("name/components/{idx}/value"),
|
||||
Value::from(name.to_owned()),
|
||||
);
|
||||
}
|
||||
if let Some(name) = graph_contact.get("yomiSurname").and_then(Value::as_str)
|
||||
&& !name.is_empty()
|
||||
&& let Some(idx) = slot_index.get("surname")
|
||||
{
|
||||
ja.insert(
|
||||
format!("name/components/{idx}/value"),
|
||||
Value::from(name.to_owned()),
|
||||
);
|
||||
}
|
||||
if !ja.is_empty() {
|
||||
localizations.insert("ja".to_owned(), Value::Object(ja));
|
||||
}
|
||||
if !localizations.is_empty() {
|
||||
card.insert("localizations".to_owned(), Value::Object(localizations));
|
||||
}
|
||||
|
||||
if let Some(birthday) = graph_contact.get("birthday").and_then(Value::as_str)
|
||||
&& let Some(date) = parse_partial_date(birthday)
|
||||
{
|
||||
let mut anniversaries = Map::new();
|
||||
anniversaries.insert(
|
||||
"anniversary-1".to_owned(),
|
||||
json!({
|
||||
"@type": "Anniversary",
|
||||
"kind": "birth",
|
||||
"date": date,
|
||||
}),
|
||||
);
|
||||
card.insert("anniversaries".to_owned(), Value::Object(anniversaries));
|
||||
}
|
||||
|
||||
let mut related = Map::new();
|
||||
if let Some(m) = graph_contact.get("manager").and_then(Value::as_str)
|
||||
&& !m.is_empty()
|
||||
{
|
||||
related.insert(
|
||||
m.to_owned(),
|
||||
json!({"@type": "Relation", "relation": {"co-worker": true}}),
|
||||
);
|
||||
}
|
||||
if let Some(s) = graph_contact.get("spouseName").and_then(Value::as_str)
|
||||
&& !s.is_empty()
|
||||
{
|
||||
related.insert(
|
||||
s.to_owned(),
|
||||
json!({"@type": "Relation", "relation": {"spouse": true}}),
|
||||
);
|
||||
}
|
||||
if let Some(kids) = graph_contact.get("children").and_then(Value::as_array) {
|
||||
for kid in kids.iter().filter_map(Value::as_str) {
|
||||
related.insert(
|
||||
kid.to_owned(),
|
||||
json!({"@type": "Relation", "relation": {"child": true}}),
|
||||
);
|
||||
}
|
||||
}
|
||||
if !related.is_empty() {
|
||||
card.insert("relatedTo".to_owned(), Value::Object(related));
|
||||
}
|
||||
|
||||
if let Some(link) = graph_contact
|
||||
.get("businessHomePage")
|
||||
.and_then(Value::as_str)
|
||||
&& !link.is_empty()
|
||||
{
|
||||
let mut links = Map::new();
|
||||
links.insert(
|
||||
"link-1".to_owned(),
|
||||
json!({"@type": "Link", "uri": link, "contexts": {"work": true}}),
|
||||
);
|
||||
card.insert("links".to_owned(), Value::Object(links));
|
||||
}
|
||||
|
||||
if let Some(created) = graph_contact.get("createdDateTime").and_then(Value::as_str) {
|
||||
card.insert("created".to_owned(), Value::from(created.to_owned()));
|
||||
}
|
||||
if let Some(updated) = graph_contact
|
||||
.get("lastModifiedDateTime")
|
||||
.and_then(Value::as_str)
|
||||
{
|
||||
card.insert("updated".to_owned(), Value::from(updated.to_owned()));
|
||||
}
|
||||
|
||||
Ok(ConvertedContact {
|
||||
uid,
|
||||
data: Value::Object(card),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn synthetic_uid(graph_id: &str) -> String {
|
||||
let hash = blake3::hash(graph_id.as_bytes());
|
||||
format!("vandelay-graph-{}", hash.to_hex())
|
||||
}
|
||||
|
||||
fn parse_partial_date(raw: &str) -> Option<Value> {
|
||||
let date_part = raw.split('T').next().unwrap_or(raw);
|
||||
let mut iter = date_part.split('-');
|
||||
let year: u64 = iter.next()?.parse().ok()?;
|
||||
let mut obj = Map::new();
|
||||
obj.insert("@type".to_owned(), Value::from("PartialDate"));
|
||||
obj.insert("year".to_owned(), Value::from(year));
|
||||
if let Some(month_str) = iter.next()
|
||||
&& let Ok(month) = month_str.parse::<u64>()
|
||||
&& (1..=12).contains(&month)
|
||||
{
|
||||
obj.insert("month".to_owned(), Value::from(month));
|
||||
if let Some(day_str) = iter.next()
|
||||
&& let Ok(day) = day_str.parse::<u64>()
|
||||
&& (1..=31).contains(&day)
|
||||
{
|
||||
obj.insert("day".to_owned(), Value::from(day));
|
||||
}
|
||||
}
|
||||
Some(Value::Object(obj))
|
||||
}
|
||||
|
||||
fn push_phones(target: &mut Map<String, Value>, list: Option<&Vec<Value>>, context: &str) {
|
||||
let Some(list) = list else { return };
|
||||
for n in list.iter().filter_map(Value::as_str) {
|
||||
if n.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let key = format!("phone-{}", target.len() + 1);
|
||||
target.insert(
|
||||
key,
|
||||
json!({
|
||||
"@type": "Phone",
|
||||
"number": n,
|
||||
"contexts": {context: true}
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn push_address(target: &mut Map<String, Value>, source: Option<&Value>, context: &str) {
|
||||
let Some(source) = source else { return };
|
||||
let Some(map) = source.as_object() else {
|
||||
return;
|
||||
};
|
||||
let street = map
|
||||
.get("street")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|s| !s.is_empty());
|
||||
let component_fields: Vec<(&str, &str)> = [
|
||||
("city", "locality"),
|
||||
("state", "region"),
|
||||
("postalCode", "postcode"),
|
||||
("countryOrRegion", "country"),
|
||||
]
|
||||
.into_iter()
|
||||
.filter_map(|(graph_key, jsc_kind)| {
|
||||
map.get(graph_key)
|
||||
.and_then(Value::as_str)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| (jsc_kind, s))
|
||||
})
|
||||
.collect();
|
||||
if street.is_none() && component_fields.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut address = Map::new();
|
||||
address.insert("@type".to_owned(), Value::from("Address"));
|
||||
address.insert("contexts".to_owned(), json!({context: true}));
|
||||
if let Some(s) = street {
|
||||
address.insert("full".to_owned(), Value::from(s.to_owned()));
|
||||
}
|
||||
if !component_fields.is_empty() {
|
||||
let components: Vec<Value> = component_fields
|
||||
.iter()
|
||||
.map(|(kind, value)| json!({"@type": "AddressComponent", "kind": kind, "value": value}))
|
||||
.collect();
|
||||
address.insert("components".to_owned(), Value::Array(components));
|
||||
}
|
||||
let key = format!("addr-{}", target.len() + 1);
|
||||
target.insert(key, Value::Object(address));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn minimal_contact_yields_card_with_synthetic_uid() {
|
||||
let c = json!({"id": "GRAPH-1", "displayName": "Alice"});
|
||||
let conv = convert_contact(&c).unwrap();
|
||||
assert!(conv.uid.starts_with("vandelay-graph-"));
|
||||
assert_eq!(conv.data["@type"], "Card");
|
||||
assert_eq!(conv.data["kind"], "individual");
|
||||
assert_eq!(conv.data["name"]["full"], "Alice");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synthetic_uid_is_stable_per_graph_id() {
|
||||
let a = synthetic_uid("AAA");
|
||||
let b = synthetic_uid("AAA");
|
||||
let c = synthetic_uid("BBB");
|
||||
assert_eq!(a, b);
|
||||
assert_ne!(a, c);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emails_map_with_first_as_pref() {
|
||||
let c = json!({
|
||||
"id": "X",
|
||||
"emailAddresses": [
|
||||
{"address": "[email protected]", "name": "Alice"},
|
||||
{"address": "[email protected]", "name": "Alice B"}
|
||||
]
|
||||
});
|
||||
let conv = convert_contact(&c).unwrap();
|
||||
let emails = conv.data["emails"].as_object().unwrap();
|
||||
assert_eq!(emails["email-1"]["address"], "[email protected]");
|
||||
assert_eq!(emails["email-1"]["pref"], 1);
|
||||
assert_eq!(emails["email-2"]["address"], "[email protected]");
|
||||
assert!(emails["email-2"].get("pref").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn phones_carry_context_per_source_field() {
|
||||
let c = json!({
|
||||
"id": "X",
|
||||
"businessPhones": ["+1-555-2000"],
|
||||
"homePhones": ["+1-555-1000"],
|
||||
"mobilePhone": "+1-555-3000"
|
||||
});
|
||||
let conv = convert_contact(&c).unwrap();
|
||||
let phones = conv.data["phones"].as_object().unwrap();
|
||||
assert_eq!(phones.len(), 3);
|
||||
let has_work = phones
|
||||
.values()
|
||||
.any(|v| v["contexts"]["work"] == Value::Bool(true));
|
||||
let has_private = phones
|
||||
.values()
|
||||
.any(|v| v["contexts"]["private"] == Value::Bool(true));
|
||||
let has_mobile = phones
|
||||
.values()
|
||||
.any(|v| v["features"]["mobile"] == Value::Bool(true));
|
||||
assert!(has_work && has_private && has_mobile);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn addresses_map_three_contexts() {
|
||||
let c = json!({
|
||||
"id": "X",
|
||||
"homeAddress": {"city": "Home City"},
|
||||
"businessAddress": {"city": "Work City"},
|
||||
"otherAddress": {"city": "Other City"}
|
||||
});
|
||||
let conv = convert_contact(&c).unwrap();
|
||||
let addresses = conv.data["addresses"].as_object().unwrap();
|
||||
assert_eq!(addresses.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn org_and_title_pair_into_separate_jscontact_objects() {
|
||||
let c = json!({
|
||||
"id": "X",
|
||||
"companyName": "Acme",
|
||||
"department": "Engineering",
|
||||
"officeLocation": "Building 9",
|
||||
"jobTitle": "Architect"
|
||||
});
|
||||
let conv = convert_contact(&c).unwrap();
|
||||
let orgs = conv.data["organizations"].as_object().unwrap();
|
||||
assert!(orgs.contains_key("org-1"));
|
||||
assert_eq!(orgs["org-1"]["name"], "Acme");
|
||||
let titles = conv.data["titles"].as_object().unwrap();
|
||||
assert_eq!(titles["title-1"]["name"], "Architect");
|
||||
assert_eq!(titles["title-1"]["organizationId"], "org-1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yomi_fields_populate_japanese_localization() {
|
||||
let c = json!({
|
||||
"id": "X",
|
||||
"displayName": "Yamada Taro",
|
||||
"givenName": "Taro",
|
||||
"surname": "Yamada",
|
||||
"companyName": "Acme",
|
||||
"yomiCompanyName": "アクメ",
|
||||
"yomiGivenName": "タロウ",
|
||||
"yomiSurname": "ヤマダ"
|
||||
});
|
||||
let conv = convert_contact(&c).unwrap();
|
||||
let ja = conv.data["localizations"]["ja"].as_object().unwrap();
|
||||
assert_eq!(ja["organizations/org-1/name"], "アクメ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn birthday_emits_partialdate_object() {
|
||||
let c = json!({"id": "X", "birthday": "1980-04-12T00:00:00Z"});
|
||||
let conv = convert_contact(&c).unwrap();
|
||||
let ann = conv.data["anniversaries"].as_object().unwrap();
|
||||
let date = &ann["anniversary-1"]["date"];
|
||||
assert_eq!(date["@type"], "PartialDate");
|
||||
assert_eq!(date["year"], 1980);
|
||||
assert_eq!(date["month"], 4);
|
||||
assert_eq!(date["day"], 12);
|
||||
assert_eq!(ann["anniversary-1"]["kind"], "birth");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mobile_phone_uses_features_not_contexts() {
|
||||
let c = json!({"id": "X", "mobilePhone": "+1-555-3000"});
|
||||
let conv = convert_contact(&c).unwrap();
|
||||
let phones = conv.data["phones"].as_object().unwrap();
|
||||
let phone = phones.values().next().unwrap();
|
||||
assert_eq!(phone["features"]["mobile"], true);
|
||||
assert!(phone.get("contexts").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generation_maps_to_generation_kind() {
|
||||
let c = json!({"id": "X", "generation": "Jr."});
|
||||
let conv = convert_contact(&c).unwrap();
|
||||
let comps = conv.data["name"]["components"].as_array().unwrap();
|
||||
assert!(
|
||||
comps
|
||||
.iter()
|
||||
.any(|c| c["kind"] == "generation" && c["value"] == "Jr.")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_jobtitle_does_not_emit_empty_organization() {
|
||||
let c = json!({"id": "X", "jobTitle": "Architect"});
|
||||
let conv = convert_contact(&c).unwrap();
|
||||
assert!(conv.data.get("organizations").is_none());
|
||||
let titles = conv.data["titles"].as_object().unwrap();
|
||||
assert_eq!(titles["title-1"]["name"], "Architect");
|
||||
assert!(titles["title-1"].get("organizationId").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manager_and_children_become_related_to() {
|
||||
let c = json!({
|
||||
"id": "X",
|
||||
"manager": "[email protected]",
|
||||
"spouseName": "Bob",
|
||||
"children": ["Kid A", "Kid B"]
|
||||
});
|
||||
let conv = convert_contact(&c).unwrap();
|
||||
let related = conv.data["relatedTo"].as_object().unwrap();
|
||||
assert!(related.contains_key("[email protected]"));
|
||||
assert!(related.contains_key("Bob"));
|
||||
assert!(related.contains_key("Kid A"));
|
||||
assert!(related.contains_key("Kid B"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use crate::error::Error;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum GraphError {
|
||||
#[error("oauth error: {0}")]
|
||||
OAuth(String),
|
||||
|
||||
#[error("graph http {status}: {body}")]
|
||||
HttpStatus { status: u16, body: String },
|
||||
|
||||
#[error("graph auth failure: {0}")]
|
||||
Auth(String),
|
||||
|
||||
#[error("graph transport error: {0}")]
|
||||
Transport(String),
|
||||
|
||||
#[error("graph connect error: {0}")]
|
||||
Connect(String),
|
||||
|
||||
#[error("retries exhausted: {0}")]
|
||||
RetriesExhausted(String),
|
||||
|
||||
#[error("malformed graph response: {0}")]
|
||||
Malformed(String),
|
||||
|
||||
#[error("graph item vanished")]
|
||||
Vanished,
|
||||
}
|
||||
|
||||
impl From<GraphError> for Error {
|
||||
fn from(e: GraphError) -> Self {
|
||||
match e {
|
||||
GraphError::OAuth(m) => Error::Connection(format!("oauth: {m}")),
|
||||
GraphError::Auth(m) => Error::Connection(format!("auth: {m}")),
|
||||
GraphError::Connect(m) => Error::Connection(m),
|
||||
GraphError::Transport(m) => Error::Connection(m),
|
||||
GraphError::RetriesExhausted(m) => Error::Connection(m),
|
||||
GraphError::HttpStatus { status, body } => {
|
||||
Error::Connection(format!("http {status}: {body}"))
|
||||
}
|
||||
GraphError::Malformed(m) => Error::Partial(format!("malformed: {m}")),
|
||||
GraphError::Vanished => Error::Partial("graph item vanished".to_owned()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
pub mod api;
|
||||
pub mod calendar_map;
|
||||
pub mod client;
|
||||
pub mod contact_map;
|
||||
pub mod error;
|
||||
pub mod oauth;
|
||||
pub mod recurrence;
|
||||
pub mod retry;
|
||||
pub mod types;
|
||||
|
||||
pub use client::{GraphClient, GraphResponse};
|
||||
pub use error::GraphError;
|
||||
pub use types::{EventBodyFormat, MailboxKind, ResolvedPrincipal};
|
||||
@@ -0,0 +1,534 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use std::io::{self, Write};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use serde_json::Value;
|
||||
use ureq::config::Config;
|
||||
use ureq::tls::{RootCerts, TlsConfig};
|
||||
|
||||
use crate::exchange_graph::error::GraphError;
|
||||
|
||||
pub const SCOPES: &str =
|
||||
"offline_access User.Read Mail.Read MailboxSettings.Read Calendars.Read Contacts.Read";
|
||||
|
||||
pub fn default_authority(tenant: &str) -> String {
|
||||
format!("https://login.microsoftonline.com/{tenant}")
|
||||
}
|
||||
|
||||
pub fn device_code_endpoint(authority: &str) -> String {
|
||||
format!("{authority}/oauth2/v2.0/devicecode")
|
||||
}
|
||||
|
||||
pub fn token_endpoint(authority: &str) -> String {
|
||||
format!("{authority}/oauth2/v2.0/token")
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum OAuthFlow {
|
||||
PreAcquired {
|
||||
token: String,
|
||||
},
|
||||
DeviceCode {
|
||||
authority: String,
|
||||
client_id: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct JwtClaims {
|
||||
pub tenant_id: Option<String>,
|
||||
pub upn: Option<String>,
|
||||
pub name: Option<String>,
|
||||
pub exp: Option<u64>,
|
||||
}
|
||||
|
||||
pub fn decode_jwt_claims(token: &str) -> Option<JwtClaims> {
|
||||
let mut parts = token.split('.');
|
||||
let _header = parts.next()?;
|
||||
let payload = parts.next()?;
|
||||
let bytes = URL_SAFE_NO_PAD
|
||||
.decode(payload)
|
||||
.or_else(|_| base64::engine::general_purpose::STANDARD_NO_PAD.decode(payload))
|
||||
.or_else(|_| base64::engine::general_purpose::STANDARD.decode(payload))
|
||||
.ok()?;
|
||||
let value: Value = serde_json::from_slice(&bytes).ok()?;
|
||||
Some(JwtClaims {
|
||||
tenant_id: value.get("tid").and_then(Value::as_str).map(str::to_owned),
|
||||
upn: value
|
||||
.get("upn")
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| value.get("preferred_username").and_then(Value::as_str))
|
||||
.map(str::to_owned),
|
||||
name: value.get("name").and_then(Value::as_str).map(str::to_owned),
|
||||
exp: value.get("exp").and_then(Value::as_u64),
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AcquiredToken {
|
||||
pub access_token: String,
|
||||
pub refresh_token: Option<String>,
|
||||
pub expires_in: Option<u64>,
|
||||
pub tenant_id: Option<String>,
|
||||
pub upn: Option<String>,
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
fn build_agent(allow_invalid_certs: bool) -> ureq::Agent {
|
||||
let config: Config = Config::builder()
|
||||
.http_status_as_error(false)
|
||||
.tls_config(
|
||||
TlsConfig::builder()
|
||||
.root_certs(RootCerts::PlatformVerifier)
|
||||
.disable_verification(allow_invalid_certs)
|
||||
.build(),
|
||||
)
|
||||
.build();
|
||||
config.new_agent()
|
||||
}
|
||||
|
||||
pub fn acquire(flow: &OAuthFlow, allow_invalid_certs: bool) -> Result<AcquiredToken, GraphError> {
|
||||
match flow {
|
||||
OAuthFlow::PreAcquired { token } => Ok(token_from_string(token.clone())),
|
||||
OAuthFlow::DeviceCode {
|
||||
authority,
|
||||
client_id,
|
||||
} => device_code_flow(authority, client_id, allow_invalid_certs),
|
||||
}
|
||||
}
|
||||
|
||||
fn token_from_string(token: String) -> AcquiredToken {
|
||||
let claims = decode_jwt_claims(&token).unwrap_or_default();
|
||||
AcquiredToken {
|
||||
access_token: token,
|
||||
refresh_token: None,
|
||||
expires_in: None,
|
||||
tenant_id: claims.tenant_id,
|
||||
upn: claims.upn,
|
||||
name: claims.name,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DeviceCodeResponse {
|
||||
pub device_code: String,
|
||||
pub user_code: String,
|
||||
pub verification_uri: String,
|
||||
pub interval: u64,
|
||||
pub expires_in: u64,
|
||||
pub message: Option<String>,
|
||||
}
|
||||
|
||||
pub fn parse_device_code_response(json: &Value) -> Result<DeviceCodeResponse, GraphError> {
|
||||
let device_code = json
|
||||
.get("device_code")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| GraphError::OAuth("device_code missing".to_owned()))?
|
||||
.to_owned();
|
||||
let user_code = json
|
||||
.get("user_code")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("?")
|
||||
.to_owned();
|
||||
let verification_uri = json
|
||||
.get("verification_uri")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("https://microsoft.com/devicelogin")
|
||||
.to_owned();
|
||||
let interval = json.get("interval").and_then(Value::as_u64).unwrap_or(5);
|
||||
let expires_in = json
|
||||
.get("expires_in")
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(900);
|
||||
let message = json
|
||||
.get("message")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_owned);
|
||||
Ok(DeviceCodeResponse {
|
||||
device_code,
|
||||
user_code,
|
||||
verification_uri,
|
||||
interval,
|
||||
expires_in,
|
||||
message,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum TokenResponse {
|
||||
Ok(AcquiredToken),
|
||||
Pending,
|
||||
SlowDown,
|
||||
Expired,
|
||||
Declined,
|
||||
BadCode,
|
||||
Other { error: String, description: String },
|
||||
}
|
||||
|
||||
pub fn parse_token_response(status: u16, json: &Value) -> TokenResponse {
|
||||
if (200..300).contains(&status) {
|
||||
let Some(access) = json.get("access_token").and_then(Value::as_str) else {
|
||||
return TokenResponse::Other {
|
||||
error: "missing_access_token".to_owned(),
|
||||
description: String::new(),
|
||||
};
|
||||
};
|
||||
let claims = decode_jwt_claims(access).unwrap_or_default();
|
||||
return TokenResponse::Ok(AcquiredToken {
|
||||
access_token: access.to_owned(),
|
||||
refresh_token: json
|
||||
.get("refresh_token")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_owned),
|
||||
expires_in: json.get("expires_in").and_then(Value::as_u64),
|
||||
tenant_id: claims.tenant_id,
|
||||
upn: claims.upn,
|
||||
name: claims.name,
|
||||
});
|
||||
}
|
||||
let error = json
|
||||
.get("error")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.to_owned();
|
||||
let description = json
|
||||
.get("error_description")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.to_owned();
|
||||
match error.as_str() {
|
||||
"authorization_pending" => TokenResponse::Pending,
|
||||
"slow_down" => TokenResponse::SlowDown,
|
||||
"expired_token" => TokenResponse::Expired,
|
||||
"authorization_declined" => TokenResponse::Declined,
|
||||
"bad_verification_code" => TokenResponse::BadCode,
|
||||
_ => TokenResponse::Other { error, description },
|
||||
}
|
||||
}
|
||||
|
||||
fn device_code_flow(
|
||||
authority: &str,
|
||||
client_id: &str,
|
||||
allow_invalid_certs: bool,
|
||||
) -> Result<AcquiredToken, GraphError> {
|
||||
let agent = build_agent(allow_invalid_certs);
|
||||
let body = form_encode(&[("client_id", client_id), ("scope", SCOPES)]);
|
||||
let endpoint = device_code_endpoint(authority);
|
||||
let mut resp = agent
|
||||
.post(&endpoint)
|
||||
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||
.send(body.as_bytes())
|
||||
.map_err(|e| GraphError::OAuth(format!("device code endpoint: {e}")))?;
|
||||
let json: Value = resp
|
||||
.body_mut()
|
||||
.read_json()
|
||||
.map_err(|e| GraphError::OAuth(format!("device code body: {e}")))?;
|
||||
let response = parse_device_code_response(&json)?;
|
||||
if let Some(m) = response.message.as_deref() {
|
||||
eprintln!("{m}");
|
||||
} else {
|
||||
eprintln!(
|
||||
"To sign in, open {} and enter the code {}",
|
||||
response.verification_uri, response.user_code
|
||||
);
|
||||
}
|
||||
let _ = io::stdout().flush();
|
||||
let token_endpoint = token_endpoint(authority);
|
||||
let deadline = Instant::now() + Duration::from_secs(response.expires_in);
|
||||
let mut delay = Duration::from_secs(response.interval);
|
||||
loop {
|
||||
std::thread::sleep(delay);
|
||||
if Instant::now() >= deadline {
|
||||
return Err(GraphError::OAuth(
|
||||
"device code expired before user completed sign-in".to_owned(),
|
||||
));
|
||||
}
|
||||
let body = form_encode(&[
|
||||
("client_id", client_id),
|
||||
("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
|
||||
("device_code", response.device_code.as_str()),
|
||||
]);
|
||||
let mut resp = agent
|
||||
.post(&token_endpoint)
|
||||
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||
.send(body.as_bytes())
|
||||
.map_err(|e| GraphError::OAuth(format!("device code polling: {e}")))?;
|
||||
let status = resp.status().as_u16();
|
||||
let payload: Value = resp.body_mut().read_json().unwrap_or(Value::Null);
|
||||
match parse_token_response(status, &payload) {
|
||||
TokenResponse::Ok(t) => return Ok(t),
|
||||
TokenResponse::Pending => continue,
|
||||
TokenResponse::SlowDown => {
|
||||
delay = delay.saturating_add(Duration::from_secs(5));
|
||||
}
|
||||
TokenResponse::Expired => {
|
||||
return Err(GraphError::OAuth(
|
||||
"device code expired; restart the import".to_owned(),
|
||||
));
|
||||
}
|
||||
TokenResponse::Declined => {
|
||||
return Err(GraphError::OAuth(
|
||||
"the user declined the device-code sign-in request".to_owned(),
|
||||
));
|
||||
}
|
||||
TokenResponse::BadCode => {
|
||||
return Err(GraphError::OAuth("device code rejected".to_owned()));
|
||||
}
|
||||
TokenResponse::Other { error, description } => {
|
||||
return Err(GraphError::OAuth(format!(
|
||||
"device code polling: {error}: {description}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn refresh_access_token(
|
||||
authority: &str,
|
||||
client_id: &str,
|
||||
refresh_token: &str,
|
||||
allow_invalid_certs: bool,
|
||||
) -> Result<AcquiredToken, GraphError> {
|
||||
let agent = build_agent(allow_invalid_certs);
|
||||
let body = form_encode(&[
|
||||
("client_id", client_id),
|
||||
("grant_type", "refresh_token"),
|
||||
("refresh_token", refresh_token),
|
||||
("scope", SCOPES),
|
||||
]);
|
||||
let endpoint = token_endpoint(authority);
|
||||
let mut delay = Duration::from_millis(500);
|
||||
let mut attempts: u32 = 0;
|
||||
loop {
|
||||
attempts += 1;
|
||||
let send = agent
|
||||
.post(&endpoint)
|
||||
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||
.send(body.as_bytes());
|
||||
let mut resp = match send {
|
||||
Ok(r) => r,
|
||||
Err(e) if attempts < 4 => {
|
||||
std::thread::sleep(delay);
|
||||
delay = (delay * 2).min(Duration::from_secs(8));
|
||||
eprintln!("graph token refresh transport error: {e}; retrying");
|
||||
continue;
|
||||
}
|
||||
Err(e) => return Err(GraphError::OAuth(format!("refresh endpoint: {e}"))),
|
||||
};
|
||||
let status = resp.status().as_u16();
|
||||
let payload: Value = resp.body_mut().read_json().unwrap_or(Value::Null);
|
||||
if matches!(status, 500..=599) && attempts < 4 {
|
||||
std::thread::sleep(delay);
|
||||
delay = (delay * 2).min(Duration::from_secs(8));
|
||||
continue;
|
||||
}
|
||||
return match parse_token_response(status, &payload) {
|
||||
TokenResponse::Ok(t) => Ok(t),
|
||||
other => Err(GraphError::OAuth(format!(
|
||||
"refresh failed (http {status}): {other:?}"
|
||||
))),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
fn form_encode(pairs: &[(&str, &str)]) -> String {
|
||||
let mut out = String::new();
|
||||
for (i, (k, v)) in pairs.iter().enumerate() {
|
||||
if i > 0 {
|
||||
out.push('&');
|
||||
}
|
||||
out.push_str(&urlencode(k));
|
||||
out.push('=');
|
||||
out.push_str(&urlencode(v));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn urlencode(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
for b in s.bytes() {
|
||||
match b {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
|
||||
out.push(b as char);
|
||||
}
|
||||
_ => {
|
||||
use std::fmt::Write;
|
||||
let _ = write!(out, "%{b:02X}");
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn run_device_code_polling_against<F>(
|
||||
authority: &str,
|
||||
client_id: &str,
|
||||
response: &DeviceCodeResponse,
|
||||
mut step: F,
|
||||
) -> Result<AcquiredToken, GraphError>
|
||||
where
|
||||
F: FnMut(&str, &str) -> Result<(u16, Value), GraphError>,
|
||||
{
|
||||
let token_endpoint = token_endpoint(authority);
|
||||
let deadline = Instant::now() + Duration::from_secs(response.expires_in.max(1));
|
||||
let mut delay = Duration::from_millis(20);
|
||||
loop {
|
||||
if Instant::now() >= deadline {
|
||||
return Err(GraphError::OAuth(
|
||||
"device code expired before user completed sign-in".to_owned(),
|
||||
));
|
||||
}
|
||||
std::thread::sleep(delay);
|
||||
let body = form_encode(&[
|
||||
("client_id", client_id),
|
||||
("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
|
||||
("device_code", response.device_code.as_str()),
|
||||
]);
|
||||
let (status, payload) = step(&token_endpoint, &body)?;
|
||||
match parse_token_response(status, &payload) {
|
||||
TokenResponse::Ok(t) => return Ok(t),
|
||||
TokenResponse::Pending => continue,
|
||||
TokenResponse::SlowDown => {
|
||||
delay = delay.saturating_add(Duration::from_millis(20));
|
||||
}
|
||||
TokenResponse::Expired => {
|
||||
return Err(GraphError::OAuth("device code expired".to_owned()));
|
||||
}
|
||||
TokenResponse::Declined => {
|
||||
return Err(GraphError::OAuth("device code declined by user".to_owned()));
|
||||
}
|
||||
TokenResponse::BadCode => {
|
||||
return Err(GraphError::OAuth("device code rejected".to_owned()));
|
||||
}
|
||||
TokenResponse::Other { error, description } => {
|
||||
return Err(GraphError::OAuth(format!("{error}: {description}")));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_jwt(tid: &str, upn: &str, exp: u64) -> String {
|
||||
let header = URL_SAFE_NO_PAD.encode(b"{\"alg\":\"none\"}");
|
||||
let claims = format!(r#"{{"tid":"{tid}","upn":"{upn}","exp":{exp}}}"#);
|
||||
let payload = URL_SAFE_NO_PAD.encode(claims.as_bytes());
|
||||
format!("{header}.{payload}.")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jwt_claim_extraction() {
|
||||
let token = make_jwt("t-1", "[email protected]", 9999999999);
|
||||
let claims = decode_jwt_claims(&token).unwrap();
|
||||
assert_eq!(claims.tenant_id.as_deref(), Some("t-1"));
|
||||
assert_eq!(claims.upn.as_deref(), Some("[email protected]"));
|
||||
assert_eq!(claims.exp, Some(9999999999));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_token_returns_none() {
|
||||
assert!(decode_jwt_claims("garbage").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn device_code_response_parses_required_fields() {
|
||||
let json = serde_json::json!({
|
||||
"device_code": "ABCDEF",
|
||||
"user_code": "XXX-YYY",
|
||||
"verification_uri": "https://microsoft.com/devicelogin",
|
||||
"interval": 5,
|
||||
"expires_in": 900,
|
||||
"message": "go sign in"
|
||||
});
|
||||
let r = parse_device_code_response(&json).unwrap();
|
||||
assert_eq!(r.device_code, "ABCDEF");
|
||||
assert_eq!(r.user_code, "XXX-YYY");
|
||||
assert_eq!(r.interval, 5);
|
||||
assert_eq!(r.expires_in, 900);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_response_pending() {
|
||||
let json = serde_json::json!({
|
||||
"error": "authorization_pending",
|
||||
"error_description": "user has not yet completed"
|
||||
});
|
||||
assert!(matches!(
|
||||
parse_token_response(400, &json),
|
||||
TokenResponse::Pending
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_response_declined() {
|
||||
let json = serde_json::json!({"error": "authorization_declined"});
|
||||
assert!(matches!(
|
||||
parse_token_response(400, &json),
|
||||
TokenResponse::Declined
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_response_bad_code() {
|
||||
let json = serde_json::json!({"error": "bad_verification_code"});
|
||||
assert!(matches!(
|
||||
parse_token_response(400, &json),
|
||||
TokenResponse::BadCode
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_response_expired() {
|
||||
let json = serde_json::json!({"error": "expired_token"});
|
||||
assert!(matches!(
|
||||
parse_token_response(400, &json),
|
||||
TokenResponse::Expired
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_response_success_includes_refresh() {
|
||||
let token = make_jwt("t-2", "[email protected]", 9999999999);
|
||||
let json = serde_json::json!({
|
||||
"access_token": token,
|
||||
"refresh_token": "REFRESH",
|
||||
"expires_in": 3599
|
||||
});
|
||||
match parse_token_response(200, &json) {
|
||||
TokenResponse::Ok(t) => {
|
||||
assert!(t.access_token.starts_with("eyJhbGciOiJub25lIn0"));
|
||||
assert_eq!(t.refresh_token.as_deref(), Some("REFRESH"));
|
||||
assert_eq!(t.expires_in, Some(3599));
|
||||
assert_eq!(t.upn.as_deref(), Some("[email protected]"));
|
||||
}
|
||||
other => panic!("expected Ok, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn urlencode_escapes_special_chars() {
|
||||
assert_eq!(urlencode("a b/c"), "a%20b%2Fc");
|
||||
assert_eq!(urlencode("plain.123_-"), "plain.123_-");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_authority_uses_login_microsoftonline_com() {
|
||||
assert_eq!(
|
||||
default_authority("common"),
|
||||
"https://login.microsoftonline.com/common"
|
||||
);
|
||||
assert_eq!(
|
||||
default_authority("contoso.onmicrosoft.com"),
|
||||
"https://login.microsoftonline.com/contoso.onmicrosoft.com"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::exchange_graph::error::GraphError;
|
||||
|
||||
pub fn convert_patterned_recurrence(pr: &Value) -> Result<Value, GraphError> {
|
||||
Ok(Value::Object(convert_patterned_recurrence_rule(pr)?))
|
||||
}
|
||||
|
||||
pub fn convert_patterned_recurrence_rule(
|
||||
pr: &Value,
|
||||
) -> Result<serde_json::Map<String, Value>, GraphError> {
|
||||
let pattern = pr
|
||||
.get("pattern")
|
||||
.ok_or_else(|| GraphError::Malformed("recurrence.pattern missing".to_owned()))?;
|
||||
let range = pr
|
||||
.get("range")
|
||||
.ok_or_else(|| GraphError::Malformed("recurrence.range missing".to_owned()))?;
|
||||
|
||||
let mut rule = serde_json::Map::new();
|
||||
rule.insert("@type".to_owned(), Value::from("RecurrenceRule"));
|
||||
rule.insert("frequency".to_owned(), Value::from(frequency_for(pattern)));
|
||||
|
||||
if let Some(interval) = pattern.get("interval").and_then(Value::as_u64)
|
||||
&& interval != 1
|
||||
{
|
||||
rule.insert("interval".to_owned(), Value::from(interval));
|
||||
}
|
||||
|
||||
if let Some(first_day) = pattern.get("firstDayOfWeek").and_then(Value::as_str)
|
||||
&& let Some(short) = day_short(first_day)
|
||||
{
|
||||
rule.insert("firstDayOfWeek".to_owned(), Value::from(short));
|
||||
}
|
||||
|
||||
if let Some(days) = pattern.get("daysOfWeek").and_then(Value::as_array) {
|
||||
let by_day: Vec<Value> = days
|
||||
.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.filter_map(day_short)
|
||||
.map(|d| json!({"@type": "NDay", "day": d}))
|
||||
.collect();
|
||||
if !by_day.is_empty() {
|
||||
rule.insert("byDay".to_owned(), Value::Array(by_day));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(dom) = pattern.get("dayOfMonth").and_then(Value::as_i64)
|
||||
&& dom != 0
|
||||
{
|
||||
rule.insert(
|
||||
"byMonthDay".to_owned(),
|
||||
Value::Array(vec![Value::from(dom)]),
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(month) = pattern.get("month").and_then(Value::as_i64)
|
||||
&& month != 0
|
||||
{
|
||||
rule.insert(
|
||||
"byMonth".to_owned(),
|
||||
Value::Array(vec![Value::String(month.to_string())]),
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(index) = pattern.get("index").and_then(Value::as_str)
|
||||
&& let Some(setpos) = set_position_for(index)
|
||||
{
|
||||
rule.insert(
|
||||
"bySetPosition".to_owned(),
|
||||
Value::Array(vec![Value::from(setpos)]),
|
||||
);
|
||||
}
|
||||
|
||||
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")
|
||||
};
|
||||
rule.insert("until".to_owned(), Value::from(local));
|
||||
}
|
||||
}
|
||||
"numbered" => {
|
||||
if let Some(count) = range.get("numberOfOccurrences").and_then(Value::as_u64) {
|
||||
rule.insert("count".to_owned(), Value::from(count));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
Ok(rule)
|
||||
}
|
||||
|
||||
fn frequency_for(pattern: &Value) -> &'static str {
|
||||
match pattern.get("type").and_then(Value::as_str).unwrap_or("") {
|
||||
"daily" => "daily",
|
||||
"weekly" => "weekly",
|
||||
"absoluteMonthly" | "relativeMonthly" => "monthly",
|
||||
"absoluteYearly" | "relativeYearly" => "yearly",
|
||||
_ => "daily",
|
||||
}
|
||||
}
|
||||
|
||||
fn day_short(name: &str) -> Option<&'static str> {
|
||||
match name.to_ascii_lowercase().as_str() {
|
||||
"monday" => Some("mo"),
|
||||
"tuesday" => Some("tu"),
|
||||
"wednesday" => Some("we"),
|
||||
"thursday" => Some("th"),
|
||||
"friday" => Some("fr"),
|
||||
"saturday" => Some("sa"),
|
||||
"sunday" => Some("su"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn set_position_for(index: &str) -> Option<i64> {
|
||||
match index.to_ascii_lowercase().as_str() {
|
||||
"first" => Some(1),
|
||||
"second" => Some(2),
|
||||
"third" => Some(3),
|
||||
"fourth" => Some(4),
|
||||
"last" => Some(-1),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn daily_with_interval() {
|
||||
let pr = json!({
|
||||
"pattern": {"type": "daily", "interval": 3},
|
||||
"range": {"type": "noEnd"}
|
||||
});
|
||||
let out = convert_patterned_recurrence(&pr).unwrap();
|
||||
let rule = &out;
|
||||
assert_eq!(rule["frequency"], "daily");
|
||||
assert_eq!(rule["interval"], 3);
|
||||
assert!(rule.get("until").is_none());
|
||||
assert!(rule.get("count").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn weekly_with_days_of_week() {
|
||||
let pr = json!({
|
||||
"pattern": {
|
||||
"type": "weekly",
|
||||
"interval": 1,
|
||||
"daysOfWeek": ["monday", "wednesday", "friday"],
|
||||
"firstDayOfWeek": "monday"
|
||||
},
|
||||
"range": {"type": "noEnd"}
|
||||
});
|
||||
let out = convert_patterned_recurrence(&pr).unwrap();
|
||||
let rule = &out;
|
||||
assert_eq!(rule["frequency"], "weekly");
|
||||
assert_eq!(rule["firstDayOfWeek"], "mo");
|
||||
let by_day = rule["byDay"].as_array().unwrap();
|
||||
assert_eq!(by_day.len(), 3);
|
||||
assert_eq!(by_day[0]["day"], "mo");
|
||||
assert_eq!(by_day[1]["day"], "we");
|
||||
assert_eq!(by_day[2]["day"], "fr");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absolute_monthly_uses_by_month_day() {
|
||||
let pr = json!({
|
||||
"pattern": {"type": "absoluteMonthly", "interval": 1, "dayOfMonth": 15},
|
||||
"range": {"type": "noEnd"}
|
||||
});
|
||||
let out = convert_patterned_recurrence(&pr).unwrap();
|
||||
let rule = &out;
|
||||
assert_eq!(rule["frequency"], "monthly");
|
||||
assert_eq!(rule["byMonthDay"][0], 15);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_monthly_emits_by_set_position() {
|
||||
let pr = json!({
|
||||
"pattern": {
|
||||
"type": "relativeMonthly",
|
||||
"interval": 1,
|
||||
"daysOfWeek": ["thursday"],
|
||||
"index": "third"
|
||||
},
|
||||
"range": {"type": "noEnd"}
|
||||
});
|
||||
let out = convert_patterned_recurrence(&pr).unwrap();
|
||||
let rule = &out;
|
||||
assert_eq!(rule["frequency"], "monthly");
|
||||
assert_eq!(rule["bySetPosition"][0], 3);
|
||||
assert_eq!(rule["byDay"][0]["day"], "th");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absolute_yearly_with_month_and_day() {
|
||||
let pr = json!({
|
||||
"pattern": {"type": "absoluteYearly", "interval": 1, "month": 7, "dayOfMonth": 4},
|
||||
"range": {"type": "noEnd"}
|
||||
});
|
||||
let out = convert_patterned_recurrence(&pr).unwrap();
|
||||
let rule = &out;
|
||||
assert_eq!(rule["frequency"], "yearly");
|
||||
assert_eq!(rule["byMonth"][0], "7");
|
||||
assert_eq!(rule["byMonthDay"][0], 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_yearly_uses_setpos_and_byday() {
|
||||
let pr = json!({
|
||||
"pattern": {
|
||||
"type": "relativeYearly", "interval": 1, "daysOfWeek": ["monday"],
|
||||
"index": "last", "month": 5
|
||||
},
|
||||
"range": {"type": "noEnd"}
|
||||
});
|
||||
let out = convert_patterned_recurrence(&pr).unwrap();
|
||||
let rule = &out;
|
||||
assert_eq!(rule["frequency"], "yearly");
|
||||
assert_eq!(rule["bySetPosition"][0], -1);
|
||||
assert_eq!(rule["byMonth"][0], "5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn range_end_date_maps_to_until_local_datetime() {
|
||||
let pr = json!({
|
||||
"pattern": {"type": "daily", "interval": 1},
|
||||
"range": {"type": "endDate", "endDate": "2026-12-31"}
|
||||
});
|
||||
let out = convert_patterned_recurrence(&pr).unwrap();
|
||||
assert_eq!(out["until"], "2026-12-31T23:59:59");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn range_end_datetime_preserved() {
|
||||
let pr = json!({
|
||||
"pattern": {"type": "daily", "interval": 1},
|
||||
"range": {"type": "endDate", "endDate": "2026-12-31T23:59:59"}
|
||||
});
|
||||
let out = convert_patterned_recurrence(&pr).unwrap();
|
||||
assert_eq!(out["until"], "2026-12-31T23:59:59");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn range_numbered_maps_to_count() {
|
||||
let pr = json!({
|
||||
"pattern": {"type": "weekly", "interval": 1, "daysOfWeek": ["monday"]},
|
||||
"range": {"type": "numbered", "numberOfOccurrences": 10}
|
||||
});
|
||||
let out = convert_patterned_recurrence(&pr).unwrap();
|
||||
assert_eq!(out["count"], 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_end_range_omits_until_and_count() {
|
||||
let pr = json!({
|
||||
"pattern": {"type": "daily", "interval": 1},
|
||||
"range": {"type": "noEnd"}
|
||||
});
|
||||
let out = convert_patterned_recurrence(&pr).unwrap();
|
||||
assert!(out.get("until").is_none());
|
||||
assert!(out.get("count").is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum HttpClass {
|
||||
Success,
|
||||
Retryable,
|
||||
Auth,
|
||||
Vanished,
|
||||
Fatal,
|
||||
}
|
||||
|
||||
pub fn classify_http_status(status: u16) -> HttpClass {
|
||||
match status {
|
||||
200..=299 => HttpClass::Success,
|
||||
401 => HttpClass::Auth,
|
||||
403 => HttpClass::Auth,
|
||||
404 | 410 => HttpClass::Vanished,
|
||||
429 | 500 | 502 | 503 | 504 => HttpClass::Retryable,
|
||||
_ => HttpClass::Fatal,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_throttled(status: u16) -> bool {
|
||||
matches!(status, 429 | 503)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rate_limit_status_is_retryable() {
|
||||
assert_eq!(classify_http_status(429), HttpClass::Retryable);
|
||||
assert!(is_throttled(429));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn service_unavailable_is_retryable_and_throttle_marked() {
|
||||
assert_eq!(classify_http_status(503), HttpClass::Retryable);
|
||||
assert!(is_throttled(503));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_gateway_and_timeout_retry() {
|
||||
assert_eq!(classify_http_status(502), HttpClass::Retryable);
|
||||
assert_eq!(classify_http_status(504), HttpClass::Retryable);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insufficient_storage_is_per_item_fatal() {
|
||||
assert_eq!(classify_http_status(507), HttpClass::Fatal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_failure_is_marked_distinctly() {
|
||||
assert_eq!(classify_http_status(401), HttpClass::Auth);
|
||||
assert_eq!(classify_http_status(403), HttpClass::Auth);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn not_found_and_gone_are_vanished() {
|
||||
assert_eq!(classify_http_status(404), HttpClass::Vanished);
|
||||
assert_eq!(classify_http_status(410), HttpClass::Vanished);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_4xx_is_fatal() {
|
||||
assert_eq!(classify_http_status(400), HttpClass::Fatal);
|
||||
assert_eq!(classify_http_status(405), HttpClass::Fatal);
|
||||
assert_eq!(classify_http_status(408), HttpClass::Fatal);
|
||||
assert_eq!(classify_http_status(422), HttpClass::Fatal);
|
||||
assert_eq!(classify_http_status(423), HttpClass::Fatal);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use crate::error::Error;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum MailboxKind {
|
||||
Primary,
|
||||
Archive,
|
||||
}
|
||||
|
||||
impl MailboxKind {
|
||||
pub fn parse(value: &str) -> Result<MailboxKind, Error> {
|
||||
match value {
|
||||
"primary" => Ok(MailboxKind::Primary),
|
||||
"archive" => Ok(MailboxKind::Archive),
|
||||
"public-folders" => Err(Error::Usage(
|
||||
"Microsoft Graph does not expose public folders. Run `vandelay import exchange-ews --mailbox-kind public-folders` instead.".to_owned(),
|
||||
)),
|
||||
other => Err(Error::Usage(format!(
|
||||
"--mailbox-kind must be primary | archive, got {other:?}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
MailboxKind::Primary => "primary",
|
||||
MailboxKind::Archive => "archive",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum EventBodyFormat {
|
||||
Text,
|
||||
Html,
|
||||
}
|
||||
|
||||
impl EventBodyFormat {
|
||||
pub fn parse(value: &str) -> Result<EventBodyFormat, Error> {
|
||||
match value {
|
||||
"text" => Ok(EventBodyFormat::Text),
|
||||
"html" => Ok(EventBodyFormat::Html),
|
||||
other => Err(Error::Usage(format!(
|
||||
"--event-body-format must be text | html, got {other:?}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn prefer_value(self) -> &'static str {
|
||||
match self {
|
||||
EventBodyFormat::Text => "outlook.body-content-type=\"text\"",
|
||||
EventBodyFormat::Html => "outlook.body-content-type=\"html\"",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ResolvedPrincipal {
|
||||
pub id: String,
|
||||
pub user_principal_name: String,
|
||||
}
|
||||
|
||||
pub fn synthetic_account_id(directory_id: &str, kind: MailboxKind) -> String {
|
||||
match kind {
|
||||
MailboxKind::Primary => directory_id.to_owned(),
|
||||
MailboxKind::Archive => format!("{directory_id}#archive"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_primary_and_archive() {
|
||||
assert_eq!(MailboxKind::parse("primary").unwrap(), MailboxKind::Primary);
|
||||
assert_eq!(MailboxKind::parse("archive").unwrap(), MailboxKind::Archive);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_folders_is_redirected_to_ews() {
|
||||
let err = MailboxKind::parse("public-folders").unwrap_err();
|
||||
let msg = err.to_string();
|
||||
assert!(msg.contains("public folders"));
|
||||
assert!(msg.contains("exchange-ews"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synthetic_id_matches_ews_pattern() {
|
||||
assert_eq!(
|
||||
synthetic_account_id("u-uuid", MailboxKind::Primary),
|
||||
"u-uuid"
|
||||
);
|
||||
assert_eq!(
|
||||
synthetic_account_id("u-uuid", MailboxKind::Archive),
|
||||
"u-uuid#archive"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn body_format_prefer_value() {
|
||||
assert_eq!(
|
||||
EventBodyFormat::Text.prefer_value(),
|
||||
"outlook.body-content-type=\"text\""
|
||||
);
|
||||
assert_eq!(
|
||||
EventBodyFormat::Html.prefer_value(),
|
||||
"outlook.body-content-type=\"html\""
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user