Initial commit

This commit is contained in:
Maurus Decimus
2026-05-29 18:02:15 +02:00
commit 576073f8c9
263 changed files with 78638 additions and 0 deletions
+286
View File
@@ -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"));
}
}
+735
View File
@@ -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());
}
}
+454
View File
@@ -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");
}
}
+586
View File
@@ -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"));
}
}
+51
View File
@@ -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()),
}
}
}
+19
View File
@@ -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};
+534
View File
@@ -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"
);
}
}
+277
View File
@@ -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());
}
}
+78
View File
@@ -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);
}
}
+116
View File
@@ -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\""
);
}
}