use crate::calendar::{Attendee, Event, EventCreateInput, EventUpdateInput};
use crate::contacts::Contact;
use crate::mail::{
InboxSummary, Mailbox, MessageBodyResult, MessageQuery, MessageSummary, SendRequest,
};
use chrono::{DateTime, TimeZone, Utc};
const GRAPH_BASE: &str = "https://graph.microsoft.com/v1.0";
const DEFAULT_SCOPES: &str =
"offline_access Contacts.Read Calendars.ReadWrite Mail.ReadWrite Mail.Send User.Read";
pub const CLIENT_ID_ENV: &str = "CAR_MSGRAPH_CLIENT_ID";
pub const TENANT_ENV: &str = "CAR_MSGRAPH_TENANT";
pub const TOKEN_ENV: &str = "CAR_MSGRAPH_TOKEN";
#[derive(Debug, thiserror::Error)]
pub enum GraphError {
#[error("Microsoft Graph is not configured: set {CLIENT_ID_ENV} (and sign in) or {TOKEN_ENV}")]
NotConfigured,
#[error("Microsoft Graph auth: {0}")]
Auth(String),
#[error("Microsoft Graph request failed: {0}")]
Request(String),
#[error("Microsoft Graph returned malformed data: {0}")]
Parse(String),
}
pub fn is_configured() -> bool {
non_empty_env(CLIENT_ID_ENV).is_some() || token().is_some()
}
fn non_empty_env(key: &str) -> Option<String> {
std::env::var(key).ok().filter(|v| !v.trim().is_empty())
}
fn token() -> Option<String> {
car_secrets::resolve_env_or_keychain(TOKEN_ENV).filter(|v| !v.trim().is_empty())
}
fn tenant() -> String {
non_empty_env(TENANT_ENV).unwrap_or_else(|| "common".to_string())
}
fn device_code_url() -> String {
format!(
"https://login.microsoftonline.com/{}/oauth2/v2.0/devicecode",
tenant()
)
}
fn token_url() -> String {
format!(
"https://login.microsoftonline.com/{}/oauth2/v2.0/token",
tenant()
)
}
#[derive(Debug, Clone)]
pub struct DeviceCode {
pub device_code: String,
pub user_code: String,
pub verification_uri: String,
pub message: String,
pub interval_secs: u64,
pub expires_in_secs: u64,
}
fn parse_device_code(v: &serde_json::Value) -> Result<DeviceCode, GraphError> {
let s = |k: &str| v.get(k).and_then(|x| x.as_str()).map(|s| s.to_string());
Ok(DeviceCode {
device_code: s("device_code").ok_or_else(|| GraphError::Auth("no device_code".into()))?,
user_code: s("user_code").unwrap_or_default(),
verification_uri: s("verification_uri").unwrap_or_default(),
message: s("message").unwrap_or_default(),
interval_secs: v.get("interval").and_then(|x| x.as_u64()).unwrap_or(5),
expires_in_secs: v.get("expires_in").and_then(|x| x.as_u64()).unwrap_or(900),
})
}
enum TokenPoll {
Token(String),
Pending,
Slow,
Error(String),
}
fn parse_token_poll(v: &serde_json::Value) -> TokenPoll {
if let Some(tok) = v.get("access_token").and_then(|x| x.as_str()) {
return TokenPoll::Token(tok.to_string());
}
match v.get("error").and_then(|x| x.as_str()) {
Some("authorization_pending") => TokenPoll::Pending,
Some("slow_down") => TokenPoll::Slow,
Some(other) => TokenPoll::Error(other.to_string()),
None => TokenPoll::Error("no access_token and no error".into()),
}
}
pub fn device_code_login(sleep: &dyn Fn(std::time::Duration)) -> Result<String, GraphError> {
let client_id = non_empty_env(CLIENT_ID_ENV).ok_or(GraphError::NotConfigured)?;
let client = blocking_client()?;
let resp = client
.post(device_code_url())
.form(&[("client_id", client_id.as_str()), ("scope", DEFAULT_SCOPES)])
.send()
.map_err(|e| GraphError::Auth(format!("device code request: {e}")))?;
let dc = parse_device_code(
&resp
.json::<serde_json::Value>()
.map_err(|e| GraphError::Auth(format!("device code json: {e}")))?,
)?;
tracing::info!("{}", dc.message);
eprintln!("{}", dc.message);
let mut interval = dc.interval_secs.max(1);
let deadline = dc.expires_in_secs;
let mut elapsed = 0u64;
loop {
if elapsed >= deadline {
return Err(GraphError::Auth(
"device code expired before authorization".into(),
));
}
sleep(std::time::Duration::from_secs(interval));
elapsed += interval;
let resp = client
.post(token_url())
.form(&[
("client_id", client_id.as_str()),
("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
("device_code", dc.device_code.as_str()),
])
.send()
.map_err(|e| GraphError::Auth(format!("token poll: {e}")))?;
let json = resp
.json::<serde_json::Value>()
.map_err(|e| GraphError::Auth(format!("token json: {e}")))?;
match parse_token_poll(&json) {
TokenPoll::Token(t) => return Ok(t),
TokenPoll::Pending => {}
TokenPoll::Slow => interval += 5,
TokenPoll::Error(e) => return Err(GraphError::Auth(e)),
}
}
}
fn blocking_client() -> Result<reqwest::blocking::Client, GraphError> {
reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
.map_err(|e| GraphError::Request(format!("http client: {e}")))
}
fn access_token() -> Result<String, GraphError> {
if let Some(t) = token() {
return Ok(t);
}
device_code_login(&std::thread::sleep)
}
fn graph_get(path: &str) -> Result<serde_json::Value, GraphError> {
if !is_configured() {
return Err(GraphError::NotConfigured);
}
let token = access_token()?;
let client = blocking_client()?;
let resp = client
.get(format!("{GRAPH_BASE}{path}"))
.bearer_auth(token)
.header("Prefer", "outlook.timezone=\"UTC\"")
.send()
.map_err(|e| GraphError::Request(format!("GET {path}: {e}")))?;
if !resp.status().is_success() {
let status = resp.status();
let detail = resp.text().unwrap_or_default();
return Err(GraphError::Request(format!(
"GET {path} -> {status}: {detail}"
)));
}
resp.json::<serde_json::Value>()
.map_err(|e| GraphError::Parse(format!("GET {path} json: {e}")))
}
fn graph_request(
method: reqwest::Method,
path: &str,
body: Option<&serde_json::Value>,
) -> Result<Option<serde_json::Value>, GraphError> {
if !is_configured() {
return Err(GraphError::NotConfigured);
}
let token = access_token()?;
let client = blocking_client()?;
let mut req = client
.request(method, format!("{GRAPH_BASE}{path}"))
.bearer_auth(token)
.header("Prefer", "outlook.timezone=\"UTC\"");
if let Some(b) = body {
req = req.json(b);
}
let resp = req
.send()
.map_err(|e| GraphError::Request(format!("{path}: {e}")))?;
if !resp.status().is_success() {
let status = resp.status();
let detail = resp.text().unwrap_or_default();
return Err(GraphError::Request(format!("{path} -> {status}: {detail}")));
}
let text = resp.text().unwrap_or_default();
if text.trim().is_empty() {
return Ok(None);
}
serde_json::from_str(&text)
.map(Some)
.map_err(|e| GraphError::Parse(format!("{path} json: {e}")))
}
fn graph_datetime(dt: DateTime<Utc>) -> serde_json::Value {
serde_json::json!({
"dateTime": dt.format("%Y-%m-%dT%H:%M:%S").to_string(),
"timeZone": "UTC",
})
}
fn event_body_content(notes: &Option<String>, url: &Option<String>) -> Option<serde_json::Value> {
let mut content = notes.clone().unwrap_or_default();
if let Some(u) = url {
if !content.is_empty() {
content.push('\n');
}
content.push_str(u);
}
(!content.is_empty()).then(|| serde_json::json!({ "contentType": "text", "content": content }))
}
pub(crate) fn event_create_body(input: &EventCreateInput) -> serde_json::Value {
let mut body = serde_json::json!({
"subject": input.title,
"start": graph_datetime(input.start),
"end": graph_datetime(input.end),
"isAllDay": input.all_day,
});
if let Some(b) = event_body_content(&input.notes, &input.url) {
body["body"] = b;
}
if let Some(loc) = &input.location {
body["location"] = serde_json::json!({ "displayName": loc });
}
body
}
pub(crate) fn event_update_body(input: &EventUpdateInput) -> serde_json::Value {
let mut body = serde_json::Map::new();
if let Some(t) = &input.title {
body.insert("subject".into(), serde_json::json!(t));
}
if let Some(s) = input.start {
body.insert("start".into(), graph_datetime(s));
}
if let Some(e) = input.end {
body.insert("end".into(), graph_datetime(e));
}
if let Some(a) = input.all_day {
body.insert("isAllDay".into(), serde_json::json!(a));
}
if input.notes.is_some() || input.url.is_some() {
if let Some(b) = event_body_content(&input.notes, &input.url) {
body.insert("body".into(), b);
}
}
if let Some(loc) = &input.location {
body.insert("location".into(), serde_json::json!({ "displayName": loc }));
}
serde_json::Value::Object(body)
}
pub(crate) fn send_mail_body(req: &SendRequest) -> serde_json::Value {
let recips = |addrs: &[String]| -> serde_json::Value {
serde_json::Value::Array(
addrs
.iter()
.map(|a| serde_json::json!({ "emailAddress": { "address": a } }))
.collect(),
)
};
let mut message = serde_json::json!({
"subject": req.subject,
"body": { "contentType": "text", "content": req.body },
"toRecipients": recips(&req.to),
});
if !req.cc.is_empty() {
message["ccRecipients"] = recips(&req.cc);
}
if !req.bcc.is_empty() {
message["bccRecipients"] = recips(&req.bcc);
}
serde_json::json!({ "message": message, "saveToSentItems": true })
}
fn parse_single_event(resp: serde_json::Value) -> Result<Event, GraphError> {
parse_events(&serde_json::json!({ "value": [resp] }), "graph")
.into_iter()
.next()
.ok_or_else(|| GraphError::Parse("event response not parseable".into()))
}
pub fn create_event(input: &EventCreateInput) -> Result<Event, GraphError> {
let body = event_create_body(input);
let resp = graph_request(reqwest::Method::POST, "/me/events", Some(&body))?
.ok_or_else(|| GraphError::Parse("create event returned no body".into()))?;
parse_single_event(resp)
}
pub fn update_event(input: &EventUpdateInput) -> Result<Event, GraphError> {
let body = event_update_body(input);
let path = format!("/me/events/{}", input.event_id);
let resp = graph_request(reqwest::Method::PATCH, &path, Some(&body))?
.ok_or_else(|| GraphError::Parse("update event returned no body".into()))?;
parse_single_event(resp)
}
pub fn delete_event(event_id: &str) -> Result<(), GraphError> {
graph_request(
reqwest::Method::DELETE,
&format!("/me/events/{event_id}"),
None,
)?;
Ok(())
}
pub fn send_mail(req: &SendRequest) -> Result<Option<String>, GraphError> {
if req.draft_only {
let body = send_mail_body(req);
let message = body.get("message").cloned().unwrap_or(body);
let resp = graph_request(reqwest::Method::POST, "/me/messages", Some(&message))?
.ok_or_else(|| GraphError::Parse("draft returned no body".into()))?;
Ok(resp.get("id").and_then(|v| v.as_str()).map(String::from))
} else {
let body = send_mail_body(req);
graph_request(reqwest::Method::POST, "/me/sendMail", Some(&body))?;
Ok(None)
}
}
pub(crate) fn parse_contacts(v: &serde_json::Value) -> Vec<Contact> {
let items = v
.get("value")
.and_then(|x| x.as_array())
.cloned()
.unwrap_or_default();
items
.iter()
.map(|c| {
let emails = c
.get("emailAddresses")
.and_then(|x| x.as_array())
.map(|arr| {
arr.iter()
.filter_map(|e| e.get("address").and_then(|a| a.as_str()).map(String::from))
.collect()
})
.unwrap_or_default();
let mut phones: Vec<String> = Vec::new();
for key in ["businessPhones", "homePhones"] {
if let Some(arr) = c.get(key).and_then(|x| x.as_array()) {
phones.extend(arr.iter().filter_map(|p| p.as_str().map(String::from)));
}
}
if let Some(m) = c.get("mobilePhone").and_then(|x| x.as_str()) {
phones.push(m.to_string());
}
Contact {
id: c
.get("id")
.and_then(|x| x.as_str())
.unwrap_or_default()
.to_string(),
container_id: None,
display_name: c
.get("displayName")
.and_then(|x| x.as_str())
.unwrap_or_default()
.to_string(),
emails,
phone_numbers: phones,
organization: c
.get("companyName")
.and_then(|x| x.as_str())
.filter(|s| !s.is_empty())
.map(String::from),
}
})
.collect()
}
fn parse_graph_datetime(v: &serde_json::Value) -> Option<DateTime<Utc>> {
let s = v.get("dateTime").and_then(|x| x.as_str())?;
let trimmed = s.split('.').next().unwrap_or(s);
chrono::NaiveDateTime::parse_from_str(trimmed, "%Y-%m-%dT%H:%M:%S")
.ok()
.map(|ndt| Utc.from_utc_datetime(&ndt))
}
pub(crate) fn parse_events(v: &serde_json::Value, calendar_id: &str) -> Vec<Event> {
let items = v
.get("value")
.and_then(|x| x.as_array())
.cloned()
.unwrap_or_default();
items
.iter()
.filter_map(|e| {
let start = parse_graph_datetime(e.get("start")?)?;
let end = parse_graph_datetime(e.get("end")?).unwrap_or(start);
let attendees = e
.get("attendees")
.and_then(|x| x.as_array())
.map(|arr| {
arr.iter()
.map(|a| {
let ea = a.get("emailAddress");
Attendee {
name: ea
.and_then(|x| x.get("name"))
.and_then(|x| x.as_str())
.map(String::from),
email: ea
.and_then(|x| x.get("address"))
.and_then(|x| x.as_str())
.map(String::from),
status: a
.get("status")
.and_then(|x| x.get("response"))
.and_then(|x| x.as_str())
.map(String::from),
role: a.get("type").and_then(|x| x.as_str()).map(String::from),
is_current_user: false,
}
})
.collect()
})
.unwrap_or_default();
Some(Event {
id: e
.get("id")
.and_then(|x| x.as_str())
.unwrap_or_default()
.to_string(),
calendar_id: calendar_id.to_string(),
title: e
.get("subject")
.and_then(|x| x.as_str())
.unwrap_or_default()
.to_string(),
start,
end,
all_day: e.get("isAllDay").and_then(|x| x.as_bool()).unwrap_or(false),
location: e
.get("location")
.and_then(|x| x.get("displayName"))
.and_then(|x| x.as_str())
.filter(|s| !s.is_empty())
.map(String::from),
notes: e
.get("bodyPreview")
.and_then(|x| x.as_str())
.filter(|s| !s.is_empty())
.map(String::from),
attendees,
status: e.get("showAs").and_then(|x| x.as_str()).map(String::from),
})
})
.collect()
}
pub(crate) fn parse_inbox_summary(v: &serde_json::Value, account_id: &str) -> InboxSummary {
InboxSummary {
account_id: account_id.to_string(),
unread: v
.get("unreadItemCount")
.and_then(|x| x.as_u64())
.unwrap_or(0) as u32,
total: v
.get("totalItemCount")
.and_then(|x| x.as_u64())
.unwrap_or(0) as u32,
most_recent_subject: None,
}
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct GraphFolder {
pub id: String,
pub name: String,
pub path: String,
pub unread: u32,
pub total: u32,
pub children: u32,
}
pub(crate) fn parse_folder_page(v: &serde_json::Value, prefix: &str) -> Vec<GraphFolder> {
v.get("value")
.and_then(|x| x.as_array())
.map(|arr| arr.as_slice())
.unwrap_or_default()
.iter()
.filter_map(|f| {
let id = f.get("id").and_then(|x| x.as_str())?;
let name = f
.get("displayName")
.and_then(|x| x.as_str())
.unwrap_or(id)
.to_string();
let path = if prefix.is_empty() {
name.clone()
} else {
format!("{prefix}/{name}")
};
Some(GraphFolder {
id: id.to_string(),
name,
path,
unread: f
.get("unreadItemCount")
.and_then(|x| x.as_u64())
.unwrap_or(0) as u32,
total: f
.get("totalItemCount")
.and_then(|x| x.as_u64())
.unwrap_or(0) as u32,
children: f
.get("childFolderCount")
.and_then(|x| x.as_u64())
.unwrap_or(0) as u32,
})
})
.collect()
}
pub(crate) fn folders_to_mailboxes(folders: Vec<GraphFolder>, account_id: &str) -> Vec<Mailbox> {
folders
.into_iter()
.map(|f| Mailbox {
account_id: account_id.to_string(),
name: f.name,
full_name: f.id,
unread: f.unread,
total: f.total,
})
.collect()
}
pub(crate) fn resolve_folder_id(folders: &[GraphFolder], wanted: &str) -> Option<String> {
if let Some(hit) = folders.iter().find(|f| f.id == wanted) {
return Some(hit.id.clone());
}
if let Some(hit) = folders.iter().find(|f| f.path.eq_ignore_ascii_case(wanted)) {
return Some(hit.id.clone());
}
folders
.iter()
.find(|f| f.name.eq_ignore_ascii_case(wanted))
.map(|f| f.id.clone())
}
fn parse_graph_instant(v: Option<&serde_json::Value>) -> Option<DateTime<Utc>> {
let s = v.and_then(|x| x.as_str())?;
DateTime::parse_from_rfc3339(s)
.ok()
.map(|dt| dt.with_timezone(&Utc))
}
fn graph_address(v: Option<&serde_json::Value>) -> Option<String> {
v.and_then(|x| x.get("emailAddress"))
.and_then(|x| x.get("address"))
.and_then(|x| x.as_str())
.map(String::from)
}
pub(crate) fn parse_messages(
v: &serde_json::Value,
account_id: &str,
mailbox: &str,
cap: usize,
) -> Vec<MessageSummary> {
v.get("value")
.and_then(|x| x.as_array())
.map(|arr| arr.as_slice())
.unwrap_or_default()
.iter()
.filter_map(|m| {
let id = m.get("id").and_then(|x| x.as_str())?;
let body = m
.get("body")
.and_then(|b| b.get("content"))
.and_then(|x| x.as_str())
.map(|s| truncate_chars(s, cap).0);
Some(MessageSummary {
id: crate::mail::encode_graph_message_id(id),
account_id: account_id.to_string(),
mailbox: mailbox.to_string(),
subject: m
.get("subject")
.and_then(|x| x.as_str())
.filter(|s| !s.is_empty())
.map(String::from),
sender: graph_address(m.get("from")).or_else(|| graph_address(m.get("sender"))),
recipients: m
.get("toRecipients")
.and_then(|x| x.as_array())
.map(|arr| arr.iter().filter_map(|r| graph_address(Some(r))).collect())
.unwrap_or_default(),
date_received: parse_graph_instant(m.get("receivedDateTime")),
read: m.get("isRead").and_then(|x| x.as_bool()).unwrap_or(false),
preview: m
.get("bodyPreview")
.and_then(|x| x.as_str())
.filter(|s| !s.is_empty())
.map(String::from),
body,
})
})
.collect()
}
fn truncate_chars(s: &str, cap: usize) -> (String, bool) {
if cap == 0 || s.chars().count() <= cap {
return (s.to_string(), false);
}
(s.chars().take(cap).collect(), true)
}
pub(crate) fn parse_message_body(v: &serde_json::Value, id: &str, cap: usize) -> MessageBodyResult {
let body = v.get("body");
let content_type = body
.and_then(|b| b.get("contentType"))
.and_then(|x| x.as_str())
.filter(|s| s.eq_ignore_ascii_case("html"))
.map(|_| "html")
.unwrap_or("text");
let raw = body
.and_then(|b| b.get("content"))
.and_then(|x| x.as_str())
.map(|s| truncate_chars(s, cap));
MessageBodyResult {
availability: crate::Availability::available("msgraph"),
id: id.to_string(),
content_type: content_type.to_string(),
body: raw.as_ref().map(|(s, _)| s.clone()),
truncated: raw.map(|(_, t)| t).unwrap_or(false),
}
}
pub fn contacts(query: &str, limit: usize) -> Result<Vec<Contact>, GraphError> {
let top = limit.clamp(1, 999);
let mut list = parse_contacts(&graph_get(&format!("/me/contacts?$top={top}"))?);
if !query.is_empty() {
let q = query.to_lowercase();
list.retain(|c| {
c.display_name.to_lowercase().contains(&q)
|| c.emails.iter().any(|e| e.to_lowercase().contains(&q))
});
}
Ok(list)
}
pub fn events(start: DateTime<Utc>, end: DateTime<Utc>) -> Result<Vec<Event>, GraphError> {
let path = format!(
"/me/calendarView?startDateTime={}&endDateTime={}&$top=200",
start.format("%Y-%m-%dT%H:%M:%SZ"),
end.format("%Y-%m-%dT%H:%M:%SZ")
);
Ok(parse_events(&graph_get(&path)?, "graph"))
}
pub fn inbox_summary(account_id: &str) -> Result<InboxSummary, GraphError> {
Ok(parse_inbox_summary(
&graph_get("/me/mailFolders/inbox")?,
account_id,
))
}
const FOLDER_WALK_MAX_DEPTH: usize = 8;
const FOLDER_WALK_MAX_REQUESTS: usize = 64;
const FOLDER_PAGE_TOP: usize = 200;
const FOLDER_MAX_PAGES: usize = 5;
fn graph_get_pages(
path: &str,
max_pages: usize,
requests: &mut usize,
) -> Result<Vec<serde_json::Value>, GraphError> {
let mut out = Vec::new();
let mut next = Some(path.to_string());
while let Some(p) = next.take() {
if *requests >= FOLDER_WALK_MAX_REQUESTS || out.len() >= max_pages {
break;
}
*requests += 1;
let v = graph_get(&p)?;
next = v
.get("@odata.nextLink")
.and_then(|x| x.as_str())
.and_then(|link| link.strip_prefix(GRAPH_BASE))
.map(String::from);
out.push(v);
}
Ok(out)
}
fn folder_tree() -> Result<Vec<GraphFolder>, GraphError> {
let mut requests = 0usize;
let root = format!("/me/mailFolders?$top={FOLDER_PAGE_TOP}");
let mut frontier: Vec<GraphFolder> = graph_get_pages(&root, FOLDER_MAX_PAGES, &mut requests)?
.iter()
.flat_map(|page| parse_folder_page(page, ""))
.collect();
let mut out: Vec<GraphFolder> = Vec::new();
let mut depth = 0usize;
loop {
let mut next: Vec<GraphFolder> = Vec::new();
if depth < FOLDER_WALK_MAX_DEPTH {
for f in &frontier {
if f.children == 0 || requests >= FOLDER_WALK_MAX_REQUESTS {
continue;
}
let path = format!(
"/me/mailFolders/{}/childFolders?$top={FOLDER_PAGE_TOP}",
f.id
);
if let Ok(pages) = graph_get_pages(&path, FOLDER_MAX_PAGES, &mut requests) {
for page in &pages {
next.extend(parse_folder_page(page, &f.path));
}
}
}
}
out.append(&mut frontier);
if next.is_empty() {
break;
}
frontier = next;
depth += 1;
}
Ok(out)
}
pub fn mail_folders(account_id: &str) -> Result<Vec<Mailbox>, GraphError> {
Ok(folders_to_mailboxes(folder_tree()?, account_id))
}
pub fn messages(account_id: &str, query: &MessageQuery) -> Result<Vec<MessageSummary>, GraphError> {
let wanted = query
.mailbox
.clone()
.unwrap_or_else(|| crate::mail::DEFAULT_MAILBOX.to_string());
let folder = if wanted.eq_ignore_ascii_case(crate::mail::DEFAULT_MAILBOX) {
"inbox".to_string()
} else {
resolve_folder_id(&folder_tree()?, &wanted).ok_or_else(|| {
GraphError::Request(format!(
"no mail folder named {wanted} — list them with mail.mailboxes"
))
})?
};
let top = query.limit.clamp(1, 500);
let mut select = "id,subject,from,toRecipients,receivedDateTime,isRead,bodyPreview".to_string();
if query.include_body {
select.push_str(",body");
}
let mut path = format!(
"/me/mailFolders/{folder}/messages?$top={top}&$orderby=receivedDateTime%20desc&$select={select}"
);
if let Some(since) = query.since {
path.push_str(&format!(
"&$filter=receivedDateTime%20ge%20{}",
since.format("%Y-%m-%dT%H:%M:%SZ")
));
}
Ok(parse_messages(
&graph_get(&path)?,
account_id,
&wanted,
crate::mail::MESSAGE_BODY_CAP,
))
}
pub fn message_body(graph_id: &str) -> Result<MessageBodyResult, GraphError> {
let v = graph_get(&format!("/me/messages/{graph_id}?$select=body"))?;
Ok(parse_message_body(
&v,
&crate::mail::encode_graph_message_id(graph_id),
crate::mail::MESSAGE_BODY_CAP,
))
}
#[derive(Debug, Clone, PartialEq)]
pub struct GraphNamed {
pub id: String,
pub name: String,
}
#[derive(Debug, Clone, PartialEq)]
pub struct GraphNote {
pub id: String,
pub title: String,
pub notebook: Option<String>,
pub modified: Option<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct GraphTask {
pub id: String,
pub title: String,
pub list: Option<String>,
pub due: Option<String>,
pub completed: bool,
}
fn parse_named(v: &serde_json::Value) -> Vec<GraphNamed> {
v.get("value")
.and_then(|x| x.as_array())
.map(|arr| {
arr.iter()
.filter_map(|n| {
let id = n.get("id")?.as_str()?.to_string();
let name = n
.get("displayName")
.and_then(|x| x.as_str())
.unwrap_or("")
.to_string();
Some(GraphNamed { id, name })
})
.collect()
})
.unwrap_or_default()
}
fn parse_notes(v: &serde_json::Value) -> Vec<GraphNote> {
v.get("value")
.and_then(|x| x.as_array())
.map(|arr| {
arr.iter()
.filter_map(|p| {
let id = p.get("id")?.as_str()?.to_string();
let title = p
.get("title")
.and_then(|x| x.as_str())
.filter(|s| !s.is_empty())
.unwrap_or("Untitled")
.to_string();
let notebook = p
.get("parentNotebook")
.and_then(|nb| nb.get("displayName"))
.and_then(|x| x.as_str())
.map(String::from);
let modified = p
.get("lastModifiedDateTime")
.and_then(|x| x.as_str())
.map(String::from);
Some(GraphNote {
id,
title,
notebook,
modified,
})
})
.collect()
})
.unwrap_or_default()
}
fn parse_tasks(v: &serde_json::Value, list: &str) -> Vec<GraphTask> {
v.get("value")
.and_then(|x| x.as_array())
.map(|arr| {
arr.iter()
.filter_map(|t| {
let id = t.get("id")?.as_str()?.to_string();
let title = t
.get("title")
.and_then(|x| x.as_str())
.unwrap_or("")
.to_string();
let completed = t.get("status").and_then(|x| x.as_str()) == Some("completed");
let due = t
.get("dueDateTime")
.and_then(|d| d.get("dateTime"))
.and_then(|x| x.as_str())
.map(String::from);
Some(GraphTask {
id,
title,
list: Some(list.to_string()),
due,
completed,
})
})
.collect()
})
.unwrap_or_default()
}
pub fn onenote_notebooks() -> Result<Vec<GraphNamed>, GraphError> {
Ok(parse_named(&graph_get("/me/onenote/notebooks")?))
}
pub fn onenote_pages(query: &str, limit: usize) -> Result<Vec<GraphNote>, GraphError> {
let top = limit.clamp(1, 100);
let path = format!(
"/me/onenote/pages?$top={top}&$orderby=lastModifiedDateTime%20desc&$expand=parentNotebook"
);
let mut list = parse_notes(&graph_get(&path)?);
if !query.is_empty() {
let q = query.to_lowercase();
list.retain(|n| n.title.to_lowercase().contains(&q));
}
Ok(list)
}
pub fn todo_lists() -> Result<Vec<GraphNamed>, GraphError> {
Ok(parse_named(&graph_get("/me/todo/lists")?))
}
pub fn todo_tasks(limit: usize) -> Result<Vec<GraphTask>, GraphError> {
let cap = if limit == 0 { usize::MAX } else { limit };
let lists = todo_lists()?;
let mut out = Vec::new();
for list in &lists {
if out.len() >= cap {
break;
}
let top = (cap - out.len()).clamp(1, 100);
let path = format!("/me/todo/lists/{}/tasks?$top={top}", list.id);
if let Ok(v) = graph_get(&path) {
out.extend(parse_tasks(&v, &list.name));
}
}
out.truncate(cap);
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn device_code_parse() {
let v = serde_json::json!({
"device_code": "DEV", "user_code": "ABC-123",
"verification_uri": "https://microsoft.com/devicelogin",
"message": "go here", "interval": 5, "expires_in": 900
});
let dc = parse_device_code(&v).unwrap();
assert_eq!(dc.device_code, "DEV");
assert_eq!(dc.user_code, "ABC-123");
assert_eq!(dc.interval_secs, 5);
}
#[test]
fn token_poll_states() {
assert!(matches!(
parse_token_poll(&serde_json::json!({"access_token": "T"})),
TokenPoll::Token(_)
));
assert!(matches!(
parse_token_poll(&serde_json::json!({"error": "authorization_pending"})),
TokenPoll::Pending
));
assert!(matches!(
parse_token_poll(&serde_json::json!({"error": "slow_down"})),
TokenPoll::Slow
));
assert!(matches!(
parse_token_poll(&serde_json::json!({"error": "expired_token"})),
TokenPoll::Error(_)
));
}
#[test]
fn named_parse_notebooks_and_lists() {
let v = serde_json::json!({"value": [
{"id": "nb1", "displayName": "Work"},
{"id": "nb2", "displayName": "Personal"},
{"id": "no-name"}
]});
let named = parse_named(&v);
assert_eq!(named.len(), 3);
assert_eq!(
named[0],
GraphNamed {
id: "nb1".into(),
name: "Work".into()
}
);
assert_eq!(named[2].name, ""); }
#[test]
fn notes_parse_title_notebook_modified() {
let v = serde_json::json!({"value": [
{"id": "p1", "title": "Roadmap",
"lastModifiedDateTime": "2026-08-01T10:00:00Z",
"parentNotebook": {"displayName": "Work"}},
{"id": "p2", "title": ""} ]});
let notes = parse_notes(&v);
assert_eq!(notes.len(), 2);
assert_eq!(notes[0].title, "Roadmap");
assert_eq!(notes[0].notebook.as_deref(), Some("Work"));
assert_eq!(notes[0].modified.as_deref(), Some("2026-08-01T10:00:00Z"));
assert_eq!(notes[1].title, "Untitled");
assert!(notes[1].notebook.is_none());
}
#[test]
fn tasks_parse_status_and_due() {
let v = serde_json::json!({"value": [
{"id": "t1", "title": "Ship it", "status": "notStarted",
"dueDateTime": {"dateTime": "2026-08-05T17:00:00.0000000", "timeZone": "UTC"}},
{"id": "t2", "title": "Done thing", "status": "completed"}
]});
let tasks = parse_tasks(&v, "Tasks");
assert_eq!(tasks.len(), 2);
assert_eq!(tasks[0].list.as_deref(), Some("Tasks"));
assert!(!tasks[0].completed);
assert_eq!(tasks[0].due.as_deref(), Some("2026-08-05T17:00:00.0000000"));
assert!(tasks[1].completed);
assert!(tasks[1].due.is_none());
}
#[test]
fn contacts_parse() {
let v = serde_json::json!({"value": [{
"id": "1", "displayName": "Ada Lovelace",
"emailAddresses": [{"address": "ada@example.com"}],
"businessPhones": ["+1 555 0100"], "mobilePhone": "+1 555 0199",
"companyName": "Analytical Engines"
}]});
let cs = parse_contacts(&v);
assert_eq!(cs.len(), 1);
assert_eq!(cs[0].display_name, "Ada Lovelace");
assert_eq!(cs[0].emails, vec!["ada@example.com"]);
assert_eq!(cs[0].phone_numbers.len(), 2);
assert_eq!(cs[0].organization.as_deref(), Some("Analytical Engines"));
}
#[test]
fn events_parse_utc() {
let v = serde_json::json!({"value": [{
"id": "e1", "subject": "Standup",
"start": {"dateTime": "2026-07-05T09:00:00.0000000", "timeZone": "UTC"},
"end": {"dateTime": "2026-07-05T09:15:00.0000000", "timeZone": "UTC"},
"location": {"displayName": "Room 1"}, "isAllDay": false,
"attendees": [{"emailAddress": {"name": "Bob", "address": "bob@x.com"}, "type": "required"}]
}]});
let es = parse_events(&v, "cal");
assert_eq!(es.len(), 1);
assert_eq!(es[0].title, "Standup");
assert_eq!(
es[0].start.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
"2026-07-05T09:00:00Z"
);
assert_eq!(es[0].location.as_deref(), Some("Room 1"));
assert_eq!(es[0].attendees.len(), 1);
assert_eq!(es[0].attendees[0].email.as_deref(), Some("bob@x.com"));
}
#[test]
fn inbox_parse() {
let v =
serde_json::json!({"displayName": "Inbox", "unreadItemCount": 3, "totalItemCount": 42});
let s = parse_inbox_summary(&v, "acct");
assert_eq!(s.unread, 3);
assert_eq!(s.total, 42);
assert_eq!(s.account_id, "acct");
}
fn folders_fixture() -> serde_json::Value {
serde_json::json!({"value": [
{"id": "AAA-inbox", "displayName": "Inbox",
"unreadItemCount": 3, "totalItemCount": 42},
{"id": "BBB-travel", "displayName": "Travel",
"unreadItemCount": 1, "totalItemCount": 9, "childFolderCount": 1},
{"id": "CCC-nameless"}
]})
}
fn folder_tree_fixture() -> Vec<GraphFolder> {
let mut tree = parse_folder_page(&folders_fixture(), "");
tree.extend(parse_folder_page(
&serde_json::json!({"value": [
{"id": "DDD-2026", "displayName": "2026",
"unreadItemCount": 2, "totalItemCount": 5}
]}),
"Travel",
));
tree
}
#[test]
fn mailboxes_parse() {
let boxes = folders_to_mailboxes(parse_folder_page(&folders_fixture(), ""), "acct");
assert_eq!(boxes.len(), 3);
assert_eq!(boxes[1].account_id, "acct");
assert_eq!(boxes[1].name, "Travel");
assert_eq!(boxes[1].full_name, "BBB-travel");
assert_eq!(boxes[1].unread, 1);
assert_eq!(boxes[1].total, 9);
assert_eq!(boxes[2].name, "CCC-nameless");
}
#[test]
fn folder_page_parses_child_counts_and_prefixes() {
let rows = parse_folder_page(&folders_fixture(), "Parent");
assert_eq!(rows[1].path, "Parent/Travel");
assert_eq!(rows[1].children, 1);
assert_eq!(rows[0].children, 0);
}
#[test]
fn folder_resolves_by_id_and_by_display_name() {
let f = folder_tree_fixture();
assert_eq!(
resolve_folder_id(&f, "BBB-travel").as_deref(),
Some("BBB-travel")
);
assert_eq!(
resolve_folder_id(&f, "travel").as_deref(),
Some("BBB-travel")
);
assert_eq!(resolve_folder_id(&f, "Archive"), None);
}
#[test]
fn child_folders_carry_a_slash_joined_path_and_resolve_like_macos() {
let tree = folder_tree_fixture();
let nested = tree.iter().find(|f| f.id == "DDD-2026").unwrap();
assert_eq!(nested.name, "2026");
assert_eq!(nested.path, "Travel/2026");
assert_eq!(nested.unread, 2);
assert_eq!(nested.total, 5);
for selector in ["Travel/2026", "travel/2026", "2026", "DDD-2026"] {
assert_eq!(
resolve_folder_id(&tree, selector).as_deref(),
Some("DDD-2026"),
"selector {selector:?} should resolve to the nested folder"
);
}
let parent = tree.iter().find(|f| f.id == "BBB-travel").unwrap();
assert_eq!(parent.children, 1);
assert_eq!(parent.path, "Travel");
assert_eq!(tree[0].path, "Inbox");
}
#[test]
fn folder_resolution_prefers_id_then_path_then_leaf() {
let tree = vec![
GraphFolder {
id: "X".into(),
name: "Travel".into(),
path: "Travel".into(),
unread: 0,
total: 0,
children: 1,
},
GraphFolder {
id: "Y".into(),
name: "Travel".into(),
path: "Archive/Travel".into(),
unread: 0,
total: 0,
children: 0,
},
];
assert_eq!(resolve_folder_id(&tree, "Y").as_deref(), Some("Y"));
assert_eq!(
resolve_folder_id(&tree, "Archive/Travel").as_deref(),
Some("Y")
);
assert_eq!(resolve_folder_id(&tree, "travel").as_deref(), Some("X"));
}
#[test]
fn messages_parse() {
let v = serde_json::json!({"value": [
{"id": "MSG1", "subject": "Flight confirmation",
"from": {"emailAddress": {"address": "no-reply@air.example"}},
"toRecipients": [{"emailAddress": {"address": "me@x.com"}},
{"emailAddress": {"address": "you@x.com"}}],
"receivedDateTime": "2026-08-01T12:30:00Z",
"isRead": false, "bodyPreview": "Your itinerary",
"body": {"contentType": "html", "content": "<p>hi</p>"}},
{"id": "MSG2", "subject": "", "receivedDateTime": "2026-07-30T08:00:00Z",
"isRead": true}
]});
let rows = parse_messages(&v, "acct", "Travel", 100);
assert_eq!(rows.len(), 2);
assert_eq!(rows[0].id, "msgraph:MSG1");
assert_eq!(rows[0].mailbox, "Travel");
assert_eq!(rows[0].subject.as_deref(), Some("Flight confirmation"));
assert_eq!(rows[0].sender.as_deref(), Some("no-reply@air.example"));
assert_eq!(rows[0].recipients, vec!["me@x.com", "you@x.com"]);
assert_eq!(
rows[0]
.date_received
.unwrap()
.format("%Y-%m-%dT%H:%M:%SZ")
.to_string(),
"2026-08-01T12:30:00Z"
);
assert!(!rows[0].read);
assert_eq!(rows[0].body.as_deref(), Some("<p>hi</p>"));
assert!(rows[1].subject.is_none());
assert!(rows[1].body.is_none());
assert!(rows[1].read);
assert!(rows[1].recipients.is_empty());
}
#[test]
fn message_body_parses_and_truncates_on_char_boundaries() {
let v = serde_json::json!({"body": {"contentType": "text", "content": "ünïcodé body"}});
let full = parse_message_body(&v, "msgraph:MSG1", 100);
assert_eq!(full.content_type, "text");
assert_eq!(full.body.as_deref(), Some("ünïcodé body"));
assert!(!full.truncated);
assert!(full.availability.available);
let cut = parse_message_body(&v, "msgraph:MSG1", 3);
assert_eq!(cut.body.as_deref(), Some("ünï"));
assert!(cut.truncated);
let html = parse_message_body(
&serde_json::json!({"body": {"contentType": "HTML", "content": "<b>x</b>"}}),
"msgraph:MSG1",
100,
);
assert_eq!(html.content_type, "html");
}
#[test]
fn not_configured_by_default() {
if super::non_empty_env(CLIENT_ID_ENV).is_none() && super::token().is_none() {
assert!(!is_configured());
}
}
fn utc(y: i32, m: u32, d: u32, h: u32, mi: u32) -> DateTime<Utc> {
Utc.with_ymd_and_hms(y, m, d, h, mi, 0).unwrap()
}
#[test]
fn event_create_body_shape() {
let input = EventCreateInput {
calendar_id: "graph".into(),
title: "Standup".into(),
start: utc(2026, 7, 5, 9, 0),
end: utc(2026, 7, 5, 9, 15),
all_day: false,
notes: Some("daily sync".into()),
location: Some("Room 1".into()),
url: Some("https://meet.example/x".into()),
};
let b = event_create_body(&input);
assert_eq!(b["subject"], "Standup");
assert_eq!(b["start"]["dateTime"], "2026-07-05T09:00:00");
assert_eq!(b["start"]["timeZone"], "UTC");
assert_eq!(b["isAllDay"], false);
assert_eq!(b["location"]["displayName"], "Room 1");
assert_eq!(b["body"]["contentType"], "text");
let content = b["body"]["content"].as_str().unwrap();
assert!(content.contains("daily sync") && content.contains("https://meet.example/x"));
}
#[test]
fn event_update_body_only_sets_present_fields() {
let input = EventUpdateInput {
event_id: "e1".into(),
title: Some("Renamed".into()),
start: None,
end: Some(utc(2026, 7, 5, 10, 0)),
all_day: None,
notes: None,
location: None,
url: None,
};
let b = event_update_body(&input);
let obj = b.as_object().unwrap();
assert_eq!(obj["subject"], "Renamed");
assert_eq!(obj["end"]["dateTime"], "2026-07-05T10:00:00");
assert!(!obj.contains_key("start"), "unset fields omitted");
assert!(!obj.contains_key("isAllDay"));
assert!(!obj.contains_key("location"));
assert!(!obj.contains_key("body"));
}
#[test]
fn send_mail_body_shape() {
let req = SendRequest {
account_id: "msgraph".into(),
to: vec!["a@x.com".into(), "b@x.com".into()],
cc: vec!["c@x.com".into()],
bcc: vec![],
subject: "Hi".into(),
body: "Body text".into(),
draft_only: false,
};
let b = send_mail_body(&req);
assert_eq!(b["saveToSentItems"], true);
assert_eq!(b["message"]["subject"], "Hi");
assert_eq!(b["message"]["body"]["content"], "Body text");
let to = b["message"]["toRecipients"].as_array().unwrap();
assert_eq!(to.len(), 2);
assert_eq!(to[0]["emailAddress"]["address"], "a@x.com");
assert_eq!(b["message"]["ccRecipients"].as_array().unwrap().len(), 1);
assert!(b["message"].get("bccRecipients").is_none());
}
}