1use crate::calendar::{Attendee, Event, EventCreateInput, EventUpdateInput};
24use crate::contacts::Contact;
25use crate::mail::{InboxSummary, SendRequest};
26use chrono::{DateTime, TimeZone, Utc};
27
28const GRAPH_BASE: &str = "https://graph.microsoft.com/v1.0";
29const DEFAULT_SCOPES: &str =
33 "offline_access Contacts.Read Calendars.ReadWrite Mail.ReadWrite Mail.Send User.Read";
34
35pub const CLIENT_ID_ENV: &str = "CAR_MSGRAPH_CLIENT_ID";
37pub const TENANT_ENV: &str = "CAR_MSGRAPH_TENANT";
40pub const TOKEN_ENV: &str = "CAR_MSGRAPH_TOKEN";
42
43#[derive(Debug, thiserror::Error)]
44pub enum GraphError {
45 #[error("Microsoft Graph is not configured: set {CLIENT_ID_ENV} (and sign in) or {TOKEN_ENV}")]
46 NotConfigured,
47 #[error("Microsoft Graph auth: {0}")]
48 Auth(String),
49 #[error("Microsoft Graph request failed: {0}")]
50 Request(String),
51 #[error("Microsoft Graph returned malformed data: {0}")]
52 Parse(String),
53}
54
55pub fn is_configured() -> bool {
58 non_empty_env(CLIENT_ID_ENV).is_some() || token().is_some()
59}
60
61fn non_empty_env(key: &str) -> Option<String> {
62 std::env::var(key).ok().filter(|v| !v.trim().is_empty())
63}
64
65fn token() -> Option<String> {
68 car_secrets::resolve_env_or_keychain(TOKEN_ENV).filter(|v| !v.trim().is_empty())
69}
70
71fn tenant() -> String {
72 non_empty_env(TENANT_ENV).unwrap_or_else(|| "common".to_string())
73}
74
75fn device_code_url() -> String {
79 format!(
80 "https://login.microsoftonline.com/{}/oauth2/v2.0/devicecode",
81 tenant()
82 )
83}
84
85fn token_url() -> String {
87 format!(
88 "https://login.microsoftonline.com/{}/oauth2/v2.0/token",
89 tenant()
90 )
91}
92
93#[derive(Debug, Clone)]
95pub struct DeviceCode {
96 pub device_code: String,
97 pub user_code: String,
98 pub verification_uri: String,
99 pub message: String,
100 pub interval_secs: u64,
101 pub expires_in_secs: u64,
102}
103
104fn parse_device_code(v: &serde_json::Value) -> Result<DeviceCode, GraphError> {
106 let s = |k: &str| v.get(k).and_then(|x| x.as_str()).map(|s| s.to_string());
107 Ok(DeviceCode {
108 device_code: s("device_code").ok_or_else(|| GraphError::Auth("no device_code".into()))?,
109 user_code: s("user_code").unwrap_or_default(),
110 verification_uri: s("verification_uri").unwrap_or_default(),
111 message: s("message").unwrap_or_default(),
112 interval_secs: v.get("interval").and_then(|x| x.as_u64()).unwrap_or(5),
113 expires_in_secs: v.get("expires_in").and_then(|x| x.as_u64()).unwrap_or(900),
114 })
115}
116
117enum TokenPoll {
119 Token(String),
120 Pending,
121 Slow,
122 Error(String),
123}
124
125fn parse_token_poll(v: &serde_json::Value) -> TokenPoll {
126 if let Some(tok) = v.get("access_token").and_then(|x| x.as_str()) {
127 return TokenPoll::Token(tok.to_string());
128 }
129 match v.get("error").and_then(|x| x.as_str()) {
130 Some("authorization_pending") => TokenPoll::Pending,
131 Some("slow_down") => TokenPoll::Slow,
132 Some(other) => TokenPoll::Error(other.to_string()),
133 None => TokenPoll::Error("no access_token and no error".into()),
134 }
135}
136
137pub fn device_code_login(sleep: &dyn Fn(std::time::Duration)) -> Result<String, GraphError> {
142 let client_id = non_empty_env(CLIENT_ID_ENV).ok_or(GraphError::NotConfigured)?;
143 let client = blocking_client()?;
144
145 let resp = client
146 .post(device_code_url())
147 .form(&[("client_id", client_id.as_str()), ("scope", DEFAULT_SCOPES)])
148 .send()
149 .map_err(|e| GraphError::Auth(format!("device code request: {e}")))?;
150 let dc = parse_device_code(
151 &resp
152 .json::<serde_json::Value>()
153 .map_err(|e| GraphError::Auth(format!("device code json: {e}")))?,
154 )?;
155 tracing::info!("{}", dc.message);
157 eprintln!("{}", dc.message);
158
159 let mut interval = dc.interval_secs.max(1);
160 let deadline = dc.expires_in_secs;
161 let mut elapsed = 0u64;
162 loop {
163 if elapsed >= deadline {
164 return Err(GraphError::Auth(
165 "device code expired before authorization".into(),
166 ));
167 }
168 sleep(std::time::Duration::from_secs(interval));
169 elapsed += interval;
170 let resp = client
171 .post(token_url())
172 .form(&[
173 ("client_id", client_id.as_str()),
174 ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
175 ("device_code", dc.device_code.as_str()),
176 ])
177 .send()
178 .map_err(|e| GraphError::Auth(format!("token poll: {e}")))?;
179 let json = resp
180 .json::<serde_json::Value>()
181 .map_err(|e| GraphError::Auth(format!("token json: {e}")))?;
182 match parse_token_poll(&json) {
183 TokenPoll::Token(t) => return Ok(t),
184 TokenPoll::Pending => {}
185 TokenPoll::Slow => interval += 5,
186 TokenPoll::Error(e) => return Err(GraphError::Auth(e)),
187 }
188 }
189}
190
191fn blocking_client() -> Result<reqwest::blocking::Client, GraphError> {
194 reqwest::blocking::Client::builder()
195 .timeout(std::time::Duration::from_secs(30))
196 .build()
197 .map_err(|e| GraphError::Request(format!("http client: {e}")))
198}
199
200fn access_token() -> Result<String, GraphError> {
203 if let Some(t) = token() {
204 return Ok(t);
205 }
206 device_code_login(&std::thread::sleep)
207}
208
209fn graph_get(path: &str) -> Result<serde_json::Value, GraphError> {
213 if !is_configured() {
214 return Err(GraphError::NotConfigured);
215 }
216 let token = access_token()?;
217 let client = blocking_client()?;
218 let resp = client
219 .get(format!("{GRAPH_BASE}{path}"))
220 .bearer_auth(token)
221 .header("Prefer", "outlook.timezone=\"UTC\"")
222 .send()
223 .map_err(|e| GraphError::Request(format!("GET {path}: {e}")))?;
224 if !resp.status().is_success() {
225 let status = resp.status();
226 let detail = resp.text().unwrap_or_default();
227 return Err(GraphError::Request(format!(
228 "GET {path} -> {status}: {detail}"
229 )));
230 }
231 resp.json::<serde_json::Value>()
232 .map_err(|e| GraphError::Parse(format!("GET {path} json: {e}")))
233}
234
235fn graph_request(
239 method: reqwest::Method,
240 path: &str,
241 body: Option<&serde_json::Value>,
242) -> Result<Option<serde_json::Value>, GraphError> {
243 if !is_configured() {
244 return Err(GraphError::NotConfigured);
245 }
246 let token = access_token()?;
247 let client = blocking_client()?;
248 let mut req = client
249 .request(method, format!("{GRAPH_BASE}{path}"))
250 .bearer_auth(token)
251 .header("Prefer", "outlook.timezone=\"UTC\"");
252 if let Some(b) = body {
253 req = req.json(b);
254 }
255 let resp = req
256 .send()
257 .map_err(|e| GraphError::Request(format!("{path}: {e}")))?;
258 if !resp.status().is_success() {
259 let status = resp.status();
260 let detail = resp.text().unwrap_or_default();
261 return Err(GraphError::Request(format!("{path} -> {status}: {detail}")));
262 }
263 let text = resp.text().unwrap_or_default();
265 if text.trim().is_empty() {
266 return Ok(None);
267 }
268 serde_json::from_str(&text)
269 .map(Some)
270 .map_err(|e| GraphError::Parse(format!("{path} json: {e}")))
271}
272
273fn graph_datetime(dt: DateTime<Utc>) -> serde_json::Value {
277 serde_json::json!({
278 "dateTime": dt.format("%Y-%m-%dT%H:%M:%S").to_string(),
279 "timeZone": "UTC",
280 })
281}
282
283fn event_body_content(notes: &Option<String>, url: &Option<String>) -> Option<serde_json::Value> {
286 let mut content = notes.clone().unwrap_or_default();
287 if let Some(u) = url {
288 if !content.is_empty() {
289 content.push('\n');
290 }
291 content.push_str(u);
292 }
293 (!content.is_empty()).then(|| serde_json::json!({ "contentType": "text", "content": content }))
294}
295
296pub(crate) fn event_create_body(input: &EventCreateInput) -> serde_json::Value {
298 let mut body = serde_json::json!({
299 "subject": input.title,
300 "start": graph_datetime(input.start),
301 "end": graph_datetime(input.end),
302 "isAllDay": input.all_day,
303 });
304 if let Some(b) = event_body_content(&input.notes, &input.url) {
305 body["body"] = b;
306 }
307 if let Some(loc) = &input.location {
308 body["location"] = serde_json::json!({ "displayName": loc });
309 }
310 body
311}
312
313pub(crate) fn event_update_body(input: &EventUpdateInput) -> serde_json::Value {
315 let mut body = serde_json::Map::new();
316 if let Some(t) = &input.title {
317 body.insert("subject".into(), serde_json::json!(t));
318 }
319 if let Some(s) = input.start {
320 body.insert("start".into(), graph_datetime(s));
321 }
322 if let Some(e) = input.end {
323 body.insert("end".into(), graph_datetime(e));
324 }
325 if let Some(a) = input.all_day {
326 body.insert("isAllDay".into(), serde_json::json!(a));
327 }
328 if input.notes.is_some() || input.url.is_some() {
329 if let Some(b) = event_body_content(&input.notes, &input.url) {
330 body.insert("body".into(), b);
331 }
332 }
333 if let Some(loc) = &input.location {
334 body.insert("location".into(), serde_json::json!({ "displayName": loc }));
335 }
336 serde_json::Value::Object(body)
337}
338
339pub(crate) fn send_mail_body(req: &SendRequest) -> serde_json::Value {
341 let recips = |addrs: &[String]| -> serde_json::Value {
342 serde_json::Value::Array(
343 addrs
344 .iter()
345 .map(|a| serde_json::json!({ "emailAddress": { "address": a } }))
346 .collect(),
347 )
348 };
349 let mut message = serde_json::json!({
350 "subject": req.subject,
351 "body": { "contentType": "text", "content": req.body },
352 "toRecipients": recips(&req.to),
353 });
354 if !req.cc.is_empty() {
355 message["ccRecipients"] = recips(&req.cc);
356 }
357 if !req.bcc.is_empty() {
358 message["bccRecipients"] = recips(&req.bcc);
359 }
360 serde_json::json!({ "message": message, "saveToSentItems": true })
361}
362
363fn parse_single_event(resp: serde_json::Value) -> Result<Event, GraphError> {
366 parse_events(&serde_json::json!({ "value": [resp] }), "graph")
367 .into_iter()
368 .next()
369 .ok_or_else(|| GraphError::Parse("event response not parseable".into()))
370}
371
372pub fn create_event(input: &EventCreateInput) -> Result<Event, GraphError> {
374 let body = event_create_body(input);
375 let resp = graph_request(reqwest::Method::POST, "/me/events", Some(&body))?
376 .ok_or_else(|| GraphError::Parse("create event returned no body".into()))?;
377 parse_single_event(resp)
378}
379
380pub fn update_event(input: &EventUpdateInput) -> Result<Event, GraphError> {
382 let body = event_update_body(input);
383 let path = format!("/me/events/{}", input.event_id);
384 let resp = graph_request(reqwest::Method::PATCH, &path, Some(&body))?
385 .ok_or_else(|| GraphError::Parse("update event returned no body".into()))?;
386 parse_single_event(resp)
387}
388
389pub fn delete_event(event_id: &str) -> Result<(), GraphError> {
391 graph_request(
392 reqwest::Method::DELETE,
393 &format!("/me/events/{event_id}"),
394 None,
395 )?;
396 Ok(())
397}
398
399pub fn send_mail(req: &SendRequest) -> Result<Option<String>, GraphError> {
403 if req.draft_only {
404 let body = send_mail_body(req);
405 let message = body.get("message").cloned().unwrap_or(body);
407 let resp = graph_request(reqwest::Method::POST, "/me/messages", Some(&message))?
408 .ok_or_else(|| GraphError::Parse("draft returned no body".into()))?;
409 Ok(resp.get("id").and_then(|v| v.as_str()).map(String::from))
410 } else {
411 let body = send_mail_body(req);
412 graph_request(reqwest::Method::POST, "/me/sendMail", Some(&body))?;
413 Ok(None)
414 }
415}
416
417pub(crate) fn parse_contacts(v: &serde_json::Value) -> Vec<Contact> {
421 let items = v
422 .get("value")
423 .and_then(|x| x.as_array())
424 .cloned()
425 .unwrap_or_default();
426 items
427 .iter()
428 .map(|c| {
429 let emails = c
430 .get("emailAddresses")
431 .and_then(|x| x.as_array())
432 .map(|arr| {
433 arr.iter()
434 .filter_map(|e| e.get("address").and_then(|a| a.as_str()).map(String::from))
435 .collect()
436 })
437 .unwrap_or_default();
438 let mut phones: Vec<String> = Vec::new();
439 for key in ["businessPhones", "homePhones"] {
440 if let Some(arr) = c.get(key).and_then(|x| x.as_array()) {
441 phones.extend(arr.iter().filter_map(|p| p.as_str().map(String::from)));
442 }
443 }
444 if let Some(m) = c.get("mobilePhone").and_then(|x| x.as_str()) {
445 phones.push(m.to_string());
446 }
447 Contact {
448 id: c
449 .get("id")
450 .and_then(|x| x.as_str())
451 .unwrap_or_default()
452 .to_string(),
453 container_id: None,
454 display_name: c
455 .get("displayName")
456 .and_then(|x| x.as_str())
457 .unwrap_or_default()
458 .to_string(),
459 emails,
460 phone_numbers: phones,
461 organization: c
462 .get("companyName")
463 .and_then(|x| x.as_str())
464 .filter(|s| !s.is_empty())
465 .map(String::from),
466 }
467 })
468 .collect()
469}
470
471fn parse_graph_datetime(v: &serde_json::Value) -> Option<DateTime<Utc>> {
473 let s = v.get("dateTime").and_then(|x| x.as_str())?;
474 let trimmed = s.split('.').next().unwrap_or(s);
476 chrono::NaiveDateTime::parse_from_str(trimmed, "%Y-%m-%dT%H:%M:%S")
477 .ok()
478 .map(|ndt| Utc.from_utc_datetime(&ndt))
479}
480
481pub(crate) fn parse_events(v: &serde_json::Value, calendar_id: &str) -> Vec<Event> {
483 let items = v
484 .get("value")
485 .and_then(|x| x.as_array())
486 .cloned()
487 .unwrap_or_default();
488 items
489 .iter()
490 .filter_map(|e| {
491 let start = parse_graph_datetime(e.get("start")?)?;
492 let end = parse_graph_datetime(e.get("end")?).unwrap_or(start);
493 let attendees = e
494 .get("attendees")
495 .and_then(|x| x.as_array())
496 .map(|arr| {
497 arr.iter()
498 .map(|a| {
499 let ea = a.get("emailAddress");
500 Attendee {
501 name: ea
502 .and_then(|x| x.get("name"))
503 .and_then(|x| x.as_str())
504 .map(String::from),
505 email: ea
506 .and_then(|x| x.get("address"))
507 .and_then(|x| x.as_str())
508 .map(String::from),
509 status: a
510 .get("status")
511 .and_then(|x| x.get("response"))
512 .and_then(|x| x.as_str())
513 .map(String::from),
514 role: a.get("type").and_then(|x| x.as_str()).map(String::from),
515 is_current_user: false,
516 }
517 })
518 .collect()
519 })
520 .unwrap_or_default();
521 Some(Event {
522 id: e
523 .get("id")
524 .and_then(|x| x.as_str())
525 .unwrap_or_default()
526 .to_string(),
527 calendar_id: calendar_id.to_string(),
528 title: e
529 .get("subject")
530 .and_then(|x| x.as_str())
531 .unwrap_or_default()
532 .to_string(),
533 start,
534 end,
535 all_day: e.get("isAllDay").and_then(|x| x.as_bool()).unwrap_or(false),
536 location: e
537 .get("location")
538 .and_then(|x| x.get("displayName"))
539 .and_then(|x| x.as_str())
540 .filter(|s| !s.is_empty())
541 .map(String::from),
542 notes: e
543 .get("bodyPreview")
544 .and_then(|x| x.as_str())
545 .filter(|s| !s.is_empty())
546 .map(String::from),
547 attendees,
548 status: e.get("showAs").and_then(|x| x.as_str()).map(String::from),
549 })
550 })
551 .collect()
552}
553
554pub(crate) fn parse_inbox_summary(v: &serde_json::Value, account_id: &str) -> InboxSummary {
556 InboxSummary {
557 account_id: account_id.to_string(),
558 unread: v
559 .get("unreadItemCount")
560 .and_then(|x| x.as_u64())
561 .unwrap_or(0) as u32,
562 total: v
563 .get("totalItemCount")
564 .and_then(|x| x.as_u64())
565 .unwrap_or(0) as u32,
566 most_recent_subject: None,
567 }
568}
569
570pub fn contacts(query: &str, limit: usize) -> Result<Vec<Contact>, GraphError> {
574 let top = limit.clamp(1, 999);
575 let mut list = parse_contacts(&graph_get(&format!("/me/contacts?$top={top}"))?);
576 if !query.is_empty() {
577 let q = query.to_lowercase();
578 list.retain(|c| {
579 c.display_name.to_lowercase().contains(&q)
580 || c.emails.iter().any(|e| e.to_lowercase().contains(&q))
581 });
582 }
583 Ok(list)
584}
585
586pub fn events(start: DateTime<Utc>, end: DateTime<Utc>) -> Result<Vec<Event>, GraphError> {
588 let path = format!(
589 "/me/calendarView?startDateTime={}&endDateTime={}&$top=200",
590 start.format("%Y-%m-%dT%H:%M:%SZ"),
591 end.format("%Y-%m-%dT%H:%M:%SZ")
592 );
593 Ok(parse_events(&graph_get(&path)?, "graph"))
594}
595
596pub fn inbox_summary(account_id: &str) -> Result<InboxSummary, GraphError> {
598 Ok(parse_inbox_summary(
599 &graph_get("/me/mailFolders/inbox")?,
600 account_id,
601 ))
602}
603
604#[derive(Debug, Clone, PartialEq)]
613pub struct GraphNamed {
614 pub id: String,
615 pub name: String,
616}
617
618#[derive(Debug, Clone, PartialEq)]
620pub struct GraphNote {
621 pub id: String,
622 pub title: String,
623 pub notebook: Option<String>,
624 pub modified: Option<String>,
625}
626
627#[derive(Debug, Clone, PartialEq)]
629pub struct GraphTask {
630 pub id: String,
631 pub title: String,
632 pub list: Option<String>,
633 pub due: Option<String>,
634 pub completed: bool,
635}
636
637fn parse_named(v: &serde_json::Value) -> Vec<GraphNamed> {
638 v.get("value")
639 .and_then(|x| x.as_array())
640 .map(|arr| {
641 arr.iter()
642 .filter_map(|n| {
643 let id = n.get("id")?.as_str()?.to_string();
644 let name = n
645 .get("displayName")
646 .and_then(|x| x.as_str())
647 .unwrap_or("")
648 .to_string();
649 Some(GraphNamed { id, name })
650 })
651 .collect()
652 })
653 .unwrap_or_default()
654}
655
656fn parse_notes(v: &serde_json::Value) -> Vec<GraphNote> {
657 v.get("value")
658 .and_then(|x| x.as_array())
659 .map(|arr| {
660 arr.iter()
661 .filter_map(|p| {
662 let id = p.get("id")?.as_str()?.to_string();
663 let title = p
664 .get("title")
665 .and_then(|x| x.as_str())
666 .filter(|s| !s.is_empty())
667 .unwrap_or("Untitled")
668 .to_string();
669 let notebook = p
670 .get("parentNotebook")
671 .and_then(|nb| nb.get("displayName"))
672 .and_then(|x| x.as_str())
673 .map(String::from);
674 let modified = p
675 .get("lastModifiedDateTime")
676 .and_then(|x| x.as_str())
677 .map(String::from);
678 Some(GraphNote {
679 id,
680 title,
681 notebook,
682 modified,
683 })
684 })
685 .collect()
686 })
687 .unwrap_or_default()
688}
689
690fn parse_tasks(v: &serde_json::Value, list: &str) -> Vec<GraphTask> {
691 v.get("value")
692 .and_then(|x| x.as_array())
693 .map(|arr| {
694 arr.iter()
695 .filter_map(|t| {
696 let id = t.get("id")?.as_str()?.to_string();
697 let title = t
698 .get("title")
699 .and_then(|x| x.as_str())
700 .unwrap_or("")
701 .to_string();
702 let completed = t.get("status").and_then(|x| x.as_str()) == Some("completed");
703 let due = t
704 .get("dueDateTime")
705 .and_then(|d| d.get("dateTime"))
706 .and_then(|x| x.as_str())
707 .map(String::from);
708 Some(GraphTask {
709 id,
710 title,
711 list: Some(list.to_string()),
712 due,
713 completed,
714 })
715 })
716 .collect()
717 })
718 .unwrap_or_default()
719}
720
721pub fn onenote_notebooks() -> Result<Vec<GraphNamed>, GraphError> {
723 Ok(parse_named(&graph_get("/me/onenote/notebooks")?))
724}
725
726pub fn onenote_pages(query: &str, limit: usize) -> Result<Vec<GraphNote>, GraphError> {
729 let top = limit.clamp(1, 100);
730 let path = format!(
732 "/me/onenote/pages?$top={top}&$orderby=lastModifiedDateTime%20desc&$expand=parentNotebook"
733 );
734 let mut list = parse_notes(&graph_get(&path)?);
735 if !query.is_empty() {
736 let q = query.to_lowercase();
737 list.retain(|n| n.title.to_lowercase().contains(&q));
738 }
739 Ok(list)
740}
741
742pub fn todo_lists() -> Result<Vec<GraphNamed>, GraphError> {
744 Ok(parse_named(&graph_get("/me/todo/lists")?))
745}
746
747pub fn todo_tasks(limit: usize) -> Result<Vec<GraphTask>, GraphError> {
751 let cap = if limit == 0 { usize::MAX } else { limit };
752 let lists = todo_lists()?;
753 let mut out = Vec::new();
754 for list in &lists {
755 if out.len() >= cap {
756 break;
757 }
758 let top = (cap - out.len()).clamp(1, 100);
759 let path = format!("/me/todo/lists/{}/tasks?$top={top}", list.id);
760 if let Ok(v) = graph_get(&path) {
761 out.extend(parse_tasks(&v, &list.name));
762 }
763 }
764 out.truncate(cap);
765 Ok(out)
766}
767
768#[cfg(test)]
769mod tests {
770 use super::*;
771
772 #[test]
773 fn device_code_parse() {
774 let v = serde_json::json!({
775 "device_code": "DEV", "user_code": "ABC-123",
776 "verification_uri": "https://microsoft.com/devicelogin",
777 "message": "go here", "interval": 5, "expires_in": 900
778 });
779 let dc = parse_device_code(&v).unwrap();
780 assert_eq!(dc.device_code, "DEV");
781 assert_eq!(dc.user_code, "ABC-123");
782 assert_eq!(dc.interval_secs, 5);
783 }
784
785 #[test]
786 fn token_poll_states() {
787 assert!(matches!(
788 parse_token_poll(&serde_json::json!({"access_token": "T"})),
789 TokenPoll::Token(_)
790 ));
791 assert!(matches!(
792 parse_token_poll(&serde_json::json!({"error": "authorization_pending"})),
793 TokenPoll::Pending
794 ));
795 assert!(matches!(
796 parse_token_poll(&serde_json::json!({"error": "slow_down"})),
797 TokenPoll::Slow
798 ));
799 assert!(matches!(
800 parse_token_poll(&serde_json::json!({"error": "expired_token"})),
801 TokenPoll::Error(_)
802 ));
803 }
804
805 #[test]
806 fn named_parse_notebooks_and_lists() {
807 let v = serde_json::json!({"value": [
808 {"id": "nb1", "displayName": "Work"},
809 {"id": "nb2", "displayName": "Personal"},
810 {"id": "no-name"}
811 ]});
812 let named = parse_named(&v);
813 assert_eq!(named.len(), 3);
814 assert_eq!(
815 named[0],
816 GraphNamed {
817 id: "nb1".into(),
818 name: "Work".into()
819 }
820 );
821 assert_eq!(named[2].name, ""); }
823
824 #[test]
825 fn notes_parse_title_notebook_modified() {
826 let v = serde_json::json!({"value": [
827 {"id": "p1", "title": "Roadmap",
828 "lastModifiedDateTime": "2026-08-01T10:00:00Z",
829 "parentNotebook": {"displayName": "Work"}},
830 {"id": "p2", "title": ""} ]});
832 let notes = parse_notes(&v);
833 assert_eq!(notes.len(), 2);
834 assert_eq!(notes[0].title, "Roadmap");
835 assert_eq!(notes[0].notebook.as_deref(), Some("Work"));
836 assert_eq!(notes[0].modified.as_deref(), Some("2026-08-01T10:00:00Z"));
837 assert_eq!(notes[1].title, "Untitled");
838 assert!(notes[1].notebook.is_none());
839 }
840
841 #[test]
842 fn tasks_parse_status_and_due() {
843 let v = serde_json::json!({"value": [
844 {"id": "t1", "title": "Ship it", "status": "notStarted",
845 "dueDateTime": {"dateTime": "2026-08-05T17:00:00.0000000", "timeZone": "UTC"}},
846 {"id": "t2", "title": "Done thing", "status": "completed"}
847 ]});
848 let tasks = parse_tasks(&v, "Tasks");
849 assert_eq!(tasks.len(), 2);
850 assert_eq!(tasks[0].list.as_deref(), Some("Tasks"));
851 assert!(!tasks[0].completed);
852 assert_eq!(tasks[0].due.as_deref(), Some("2026-08-05T17:00:00.0000000"));
853 assert!(tasks[1].completed);
854 assert!(tasks[1].due.is_none());
855 }
856
857 #[test]
858 fn contacts_parse() {
859 let v = serde_json::json!({"value": [{
860 "id": "1", "displayName": "Ada Lovelace",
861 "emailAddresses": [{"address": "ada@example.com"}],
862 "businessPhones": ["+1 555 0100"], "mobilePhone": "+1 555 0199",
863 "companyName": "Analytical Engines"
864 }]});
865 let cs = parse_contacts(&v);
866 assert_eq!(cs.len(), 1);
867 assert_eq!(cs[0].display_name, "Ada Lovelace");
868 assert_eq!(cs[0].emails, vec!["ada@example.com"]);
869 assert_eq!(cs[0].phone_numbers.len(), 2);
870 assert_eq!(cs[0].organization.as_deref(), Some("Analytical Engines"));
871 }
872
873 #[test]
874 fn events_parse_utc() {
875 let v = serde_json::json!({"value": [{
876 "id": "e1", "subject": "Standup",
877 "start": {"dateTime": "2026-07-05T09:00:00.0000000", "timeZone": "UTC"},
878 "end": {"dateTime": "2026-07-05T09:15:00.0000000", "timeZone": "UTC"},
879 "location": {"displayName": "Room 1"}, "isAllDay": false,
880 "attendees": [{"emailAddress": {"name": "Bob", "address": "bob@x.com"}, "type": "required"}]
881 }]});
882 let es = parse_events(&v, "cal");
883 assert_eq!(es.len(), 1);
884 assert_eq!(es[0].title, "Standup");
885 assert_eq!(
886 es[0].start.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
887 "2026-07-05T09:00:00Z"
888 );
889 assert_eq!(es[0].location.as_deref(), Some("Room 1"));
890 assert_eq!(es[0].attendees.len(), 1);
891 assert_eq!(es[0].attendees[0].email.as_deref(), Some("bob@x.com"));
892 }
893
894 #[test]
895 fn inbox_parse() {
896 let v =
897 serde_json::json!({"displayName": "Inbox", "unreadItemCount": 3, "totalItemCount": 42});
898 let s = parse_inbox_summary(&v, "acct");
899 assert_eq!(s.unread, 3);
900 assert_eq!(s.total, 42);
901 assert_eq!(s.account_id, "acct");
902 }
903
904 #[test]
905 fn not_configured_by_default() {
906 if super::non_empty_env(CLIENT_ID_ENV).is_none() && super::token().is_none() {
908 assert!(!is_configured());
909 }
910 }
911
912 fn utc(y: i32, m: u32, d: u32, h: u32, mi: u32) -> DateTime<Utc> {
915 Utc.with_ymd_and_hms(y, m, d, h, mi, 0).unwrap()
916 }
917
918 #[test]
919 fn event_create_body_shape() {
920 let input = EventCreateInput {
921 calendar_id: "graph".into(),
922 title: "Standup".into(),
923 start: utc(2026, 7, 5, 9, 0),
924 end: utc(2026, 7, 5, 9, 15),
925 all_day: false,
926 notes: Some("daily sync".into()),
927 location: Some("Room 1".into()),
928 url: Some("https://meet.example/x".into()),
929 };
930 let b = event_create_body(&input);
931 assert_eq!(b["subject"], "Standup");
932 assert_eq!(b["start"]["dateTime"], "2026-07-05T09:00:00");
933 assert_eq!(b["start"]["timeZone"], "UTC");
934 assert_eq!(b["isAllDay"], false);
935 assert_eq!(b["location"]["displayName"], "Room 1");
936 assert_eq!(b["body"]["contentType"], "text");
938 let content = b["body"]["content"].as_str().unwrap();
939 assert!(content.contains("daily sync") && content.contains("https://meet.example/x"));
940 }
941
942 #[test]
943 fn event_update_body_only_sets_present_fields() {
944 let input = EventUpdateInput {
945 event_id: "e1".into(),
946 title: Some("Renamed".into()),
947 start: None,
948 end: Some(utc(2026, 7, 5, 10, 0)),
949 all_day: None,
950 notes: None,
951 location: None,
952 url: None,
953 };
954 let b = event_update_body(&input);
955 let obj = b.as_object().unwrap();
956 assert_eq!(obj["subject"], "Renamed");
957 assert_eq!(obj["end"]["dateTime"], "2026-07-05T10:00:00");
958 assert!(!obj.contains_key("start"), "unset fields omitted");
959 assert!(!obj.contains_key("isAllDay"));
960 assert!(!obj.contains_key("location"));
961 assert!(!obj.contains_key("body"));
962 }
963
964 #[test]
965 fn send_mail_body_shape() {
966 let req = SendRequest {
967 account_id: "msgraph".into(),
968 to: vec!["a@x.com".into(), "b@x.com".into()],
969 cc: vec!["c@x.com".into()],
970 bcc: vec![],
971 subject: "Hi".into(),
972 body: "Body text".into(),
973 draft_only: false,
974 };
975 let b = send_mail_body(&req);
976 assert_eq!(b["saveToSentItems"], true);
977 assert_eq!(b["message"]["subject"], "Hi");
978 assert_eq!(b["message"]["body"]["content"], "Body text");
979 let to = b["message"]["toRecipients"].as_array().unwrap();
980 assert_eq!(to.len(), 2);
981 assert_eq!(to[0]["emailAddress"]["address"], "a@x.com");
982 assert_eq!(b["message"]["ccRecipients"].as_array().unwrap().len(), 1);
983 assert!(b["message"].get("bccRecipients").is_none());
985 }
986}