use crate::calendar::{Attendee, Event, EventCreateInput, EventUpdateInput};
use crate::contacts::Contact;
use crate::mail::{InboxSummary, 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() || non_empty_env(TOKEN_ENV).is_some()
}
fn non_empty_env(key: &str) -> Option<String> {
std::env::var(key).ok().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) = non_empty_env(TOKEN_ENV) {
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,
}
}
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,
))
}
#[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 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");
}
#[test]
fn not_configured_by_default() {
if super::non_empty_env(CLIENT_ID_ENV).is_none()
&& super::non_empty_env(TOKEN_ENV).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());
}
}