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() || non_empty_env(TOKEN_ENV).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 tenant() -> String {
66 non_empty_env(TENANT_ENV).unwrap_or_else(|| "common".to_string())
67}
68
69fn device_code_url() -> String {
73 format!(
74 "https://login.microsoftonline.com/{}/oauth2/v2.0/devicecode",
75 tenant()
76 )
77}
78
79fn token_url() -> String {
81 format!(
82 "https://login.microsoftonline.com/{}/oauth2/v2.0/token",
83 tenant()
84 )
85}
86
87#[derive(Debug, Clone)]
89pub struct DeviceCode {
90 pub device_code: String,
91 pub user_code: String,
92 pub verification_uri: String,
93 pub message: String,
94 pub interval_secs: u64,
95 pub expires_in_secs: u64,
96}
97
98fn parse_device_code(v: &serde_json::Value) -> Result<DeviceCode, GraphError> {
100 let s = |k: &str| v.get(k).and_then(|x| x.as_str()).map(|s| s.to_string());
101 Ok(DeviceCode {
102 device_code: s("device_code").ok_or_else(|| GraphError::Auth("no device_code".into()))?,
103 user_code: s("user_code").unwrap_or_default(),
104 verification_uri: s("verification_uri").unwrap_or_default(),
105 message: s("message").unwrap_or_default(),
106 interval_secs: v.get("interval").and_then(|x| x.as_u64()).unwrap_or(5),
107 expires_in_secs: v.get("expires_in").and_then(|x| x.as_u64()).unwrap_or(900),
108 })
109}
110
111enum TokenPoll {
113 Token(String),
114 Pending,
115 Slow,
116 Error(String),
117}
118
119fn parse_token_poll(v: &serde_json::Value) -> TokenPoll {
120 if let Some(tok) = v.get("access_token").and_then(|x| x.as_str()) {
121 return TokenPoll::Token(tok.to_string());
122 }
123 match v.get("error").and_then(|x| x.as_str()) {
124 Some("authorization_pending") => TokenPoll::Pending,
125 Some("slow_down") => TokenPoll::Slow,
126 Some(other) => TokenPoll::Error(other.to_string()),
127 None => TokenPoll::Error("no access_token and no error".into()),
128 }
129}
130
131pub fn device_code_login(sleep: &dyn Fn(std::time::Duration)) -> Result<String, GraphError> {
136 let client_id = non_empty_env(CLIENT_ID_ENV).ok_or(GraphError::NotConfigured)?;
137 let client = blocking_client()?;
138
139 let resp = client
140 .post(device_code_url())
141 .form(&[("client_id", client_id.as_str()), ("scope", DEFAULT_SCOPES)])
142 .send()
143 .map_err(|e| GraphError::Auth(format!("device code request: {e}")))?;
144 let dc = parse_device_code(
145 &resp
146 .json::<serde_json::Value>()
147 .map_err(|e| GraphError::Auth(format!("device code json: {e}")))?,
148 )?;
149 tracing::info!("{}", dc.message);
151 eprintln!("{}", dc.message);
152
153 let mut interval = dc.interval_secs.max(1);
154 let deadline = dc.expires_in_secs;
155 let mut elapsed = 0u64;
156 loop {
157 if elapsed >= deadline {
158 return Err(GraphError::Auth(
159 "device code expired before authorization".into(),
160 ));
161 }
162 sleep(std::time::Duration::from_secs(interval));
163 elapsed += interval;
164 let resp = client
165 .post(token_url())
166 .form(&[
167 ("client_id", client_id.as_str()),
168 ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
169 ("device_code", dc.device_code.as_str()),
170 ])
171 .send()
172 .map_err(|e| GraphError::Auth(format!("token poll: {e}")))?;
173 let json = resp
174 .json::<serde_json::Value>()
175 .map_err(|e| GraphError::Auth(format!("token json: {e}")))?;
176 match parse_token_poll(&json) {
177 TokenPoll::Token(t) => return Ok(t),
178 TokenPoll::Pending => {}
179 TokenPoll::Slow => interval += 5,
180 TokenPoll::Error(e) => return Err(GraphError::Auth(e)),
181 }
182 }
183}
184
185fn blocking_client() -> Result<reqwest::blocking::Client, GraphError> {
188 reqwest::blocking::Client::builder()
189 .timeout(std::time::Duration::from_secs(30))
190 .build()
191 .map_err(|e| GraphError::Request(format!("http client: {e}")))
192}
193
194fn access_token() -> Result<String, GraphError> {
197 if let Some(t) = non_empty_env(TOKEN_ENV) {
198 return Ok(t);
199 }
200 device_code_login(&std::thread::sleep)
201}
202
203fn graph_get(path: &str) -> Result<serde_json::Value, GraphError> {
207 if !is_configured() {
208 return Err(GraphError::NotConfigured);
209 }
210 let token = access_token()?;
211 let client = blocking_client()?;
212 let resp = client
213 .get(format!("{GRAPH_BASE}{path}"))
214 .bearer_auth(token)
215 .header("Prefer", "outlook.timezone=\"UTC\"")
216 .send()
217 .map_err(|e| GraphError::Request(format!("GET {path}: {e}")))?;
218 if !resp.status().is_success() {
219 let status = resp.status();
220 let detail = resp.text().unwrap_or_default();
221 return Err(GraphError::Request(format!(
222 "GET {path} -> {status}: {detail}"
223 )));
224 }
225 resp.json::<serde_json::Value>()
226 .map_err(|e| GraphError::Parse(format!("GET {path} json: {e}")))
227}
228
229fn graph_request(
233 method: reqwest::Method,
234 path: &str,
235 body: Option<&serde_json::Value>,
236) -> Result<Option<serde_json::Value>, GraphError> {
237 if !is_configured() {
238 return Err(GraphError::NotConfigured);
239 }
240 let token = access_token()?;
241 let client = blocking_client()?;
242 let mut req = client
243 .request(method, format!("{GRAPH_BASE}{path}"))
244 .bearer_auth(token)
245 .header("Prefer", "outlook.timezone=\"UTC\"");
246 if let Some(b) = body {
247 req = req.json(b);
248 }
249 let resp = req
250 .send()
251 .map_err(|e| GraphError::Request(format!("{path}: {e}")))?;
252 if !resp.status().is_success() {
253 let status = resp.status();
254 let detail = resp.text().unwrap_or_default();
255 return Err(GraphError::Request(format!("{path} -> {status}: {detail}")));
256 }
257 let text = resp.text().unwrap_or_default();
259 if text.trim().is_empty() {
260 return Ok(None);
261 }
262 serde_json::from_str(&text)
263 .map(Some)
264 .map_err(|e| GraphError::Parse(format!("{path} json: {e}")))
265}
266
267fn graph_datetime(dt: DateTime<Utc>) -> serde_json::Value {
271 serde_json::json!({
272 "dateTime": dt.format("%Y-%m-%dT%H:%M:%S").to_string(),
273 "timeZone": "UTC",
274 })
275}
276
277fn event_body_content(notes: &Option<String>, url: &Option<String>) -> Option<serde_json::Value> {
280 let mut content = notes.clone().unwrap_or_default();
281 if let Some(u) = url {
282 if !content.is_empty() {
283 content.push('\n');
284 }
285 content.push_str(u);
286 }
287 (!content.is_empty()).then(|| serde_json::json!({ "contentType": "text", "content": content }))
288}
289
290pub(crate) fn event_create_body(input: &EventCreateInput) -> serde_json::Value {
292 let mut body = serde_json::json!({
293 "subject": input.title,
294 "start": graph_datetime(input.start),
295 "end": graph_datetime(input.end),
296 "isAllDay": input.all_day,
297 });
298 if let Some(b) = event_body_content(&input.notes, &input.url) {
299 body["body"] = b;
300 }
301 if let Some(loc) = &input.location {
302 body["location"] = serde_json::json!({ "displayName": loc });
303 }
304 body
305}
306
307pub(crate) fn event_update_body(input: &EventUpdateInput) -> serde_json::Value {
309 let mut body = serde_json::Map::new();
310 if let Some(t) = &input.title {
311 body.insert("subject".into(), serde_json::json!(t));
312 }
313 if let Some(s) = input.start {
314 body.insert("start".into(), graph_datetime(s));
315 }
316 if let Some(e) = input.end {
317 body.insert("end".into(), graph_datetime(e));
318 }
319 if let Some(a) = input.all_day {
320 body.insert("isAllDay".into(), serde_json::json!(a));
321 }
322 if input.notes.is_some() || input.url.is_some() {
323 if let Some(b) = event_body_content(&input.notes, &input.url) {
324 body.insert("body".into(), b);
325 }
326 }
327 if let Some(loc) = &input.location {
328 body.insert("location".into(), serde_json::json!({ "displayName": loc }));
329 }
330 serde_json::Value::Object(body)
331}
332
333pub(crate) fn send_mail_body(req: &SendRequest) -> serde_json::Value {
335 let recips = |addrs: &[String]| -> serde_json::Value {
336 serde_json::Value::Array(
337 addrs
338 .iter()
339 .map(|a| serde_json::json!({ "emailAddress": { "address": a } }))
340 .collect(),
341 )
342 };
343 let mut message = serde_json::json!({
344 "subject": req.subject,
345 "body": { "contentType": "text", "content": req.body },
346 "toRecipients": recips(&req.to),
347 });
348 if !req.cc.is_empty() {
349 message["ccRecipients"] = recips(&req.cc);
350 }
351 if !req.bcc.is_empty() {
352 message["bccRecipients"] = recips(&req.bcc);
353 }
354 serde_json::json!({ "message": message, "saveToSentItems": true })
355}
356
357fn parse_single_event(resp: serde_json::Value) -> Result<Event, GraphError> {
360 parse_events(&serde_json::json!({ "value": [resp] }), "graph")
361 .into_iter()
362 .next()
363 .ok_or_else(|| GraphError::Parse("event response not parseable".into()))
364}
365
366pub fn create_event(input: &EventCreateInput) -> Result<Event, GraphError> {
368 let body = event_create_body(input);
369 let resp = graph_request(reqwest::Method::POST, "/me/events", Some(&body))?
370 .ok_or_else(|| GraphError::Parse("create event returned no body".into()))?;
371 parse_single_event(resp)
372}
373
374pub fn update_event(input: &EventUpdateInput) -> Result<Event, GraphError> {
376 let body = event_update_body(input);
377 let path = format!("/me/events/{}", input.event_id);
378 let resp = graph_request(reqwest::Method::PATCH, &path, Some(&body))?
379 .ok_or_else(|| GraphError::Parse("update event returned no body".into()))?;
380 parse_single_event(resp)
381}
382
383pub fn delete_event(event_id: &str) -> Result<(), GraphError> {
385 graph_request(
386 reqwest::Method::DELETE,
387 &format!("/me/events/{event_id}"),
388 None,
389 )?;
390 Ok(())
391}
392
393pub fn send_mail(req: &SendRequest) -> Result<Option<String>, GraphError> {
397 if req.draft_only {
398 let body = send_mail_body(req);
399 let message = body.get("message").cloned().unwrap_or(body);
401 let resp = graph_request(reqwest::Method::POST, "/me/messages", Some(&message))?
402 .ok_or_else(|| GraphError::Parse("draft returned no body".into()))?;
403 Ok(resp.get("id").and_then(|v| v.as_str()).map(String::from))
404 } else {
405 let body = send_mail_body(req);
406 graph_request(reqwest::Method::POST, "/me/sendMail", Some(&body))?;
407 Ok(None)
408 }
409}
410
411pub(crate) fn parse_contacts(v: &serde_json::Value) -> Vec<Contact> {
415 let items = v
416 .get("value")
417 .and_then(|x| x.as_array())
418 .cloned()
419 .unwrap_or_default();
420 items
421 .iter()
422 .map(|c| {
423 let emails = c
424 .get("emailAddresses")
425 .and_then(|x| x.as_array())
426 .map(|arr| {
427 arr.iter()
428 .filter_map(|e| e.get("address").and_then(|a| a.as_str()).map(String::from))
429 .collect()
430 })
431 .unwrap_or_default();
432 let mut phones: Vec<String> = Vec::new();
433 for key in ["businessPhones", "homePhones"] {
434 if let Some(arr) = c.get(key).and_then(|x| x.as_array()) {
435 phones.extend(arr.iter().filter_map(|p| p.as_str().map(String::from)));
436 }
437 }
438 if let Some(m) = c.get("mobilePhone").and_then(|x| x.as_str()) {
439 phones.push(m.to_string());
440 }
441 Contact {
442 id: c
443 .get("id")
444 .and_then(|x| x.as_str())
445 .unwrap_or_default()
446 .to_string(),
447 container_id: None,
448 display_name: c
449 .get("displayName")
450 .and_then(|x| x.as_str())
451 .unwrap_or_default()
452 .to_string(),
453 emails,
454 phone_numbers: phones,
455 organization: c
456 .get("companyName")
457 .and_then(|x| x.as_str())
458 .filter(|s| !s.is_empty())
459 .map(String::from),
460 }
461 })
462 .collect()
463}
464
465fn parse_graph_datetime(v: &serde_json::Value) -> Option<DateTime<Utc>> {
467 let s = v.get("dateTime").and_then(|x| x.as_str())?;
468 let trimmed = s.split('.').next().unwrap_or(s);
470 chrono::NaiveDateTime::parse_from_str(trimmed, "%Y-%m-%dT%H:%M:%S")
471 .ok()
472 .map(|ndt| Utc.from_utc_datetime(&ndt))
473}
474
475pub(crate) fn parse_events(v: &serde_json::Value, calendar_id: &str) -> Vec<Event> {
477 let items = v
478 .get("value")
479 .and_then(|x| x.as_array())
480 .cloned()
481 .unwrap_or_default();
482 items
483 .iter()
484 .filter_map(|e| {
485 let start = parse_graph_datetime(e.get("start")?)?;
486 let end = parse_graph_datetime(e.get("end")?).unwrap_or(start);
487 let attendees = e
488 .get("attendees")
489 .and_then(|x| x.as_array())
490 .map(|arr| {
491 arr.iter()
492 .map(|a| {
493 let ea = a.get("emailAddress");
494 Attendee {
495 name: ea
496 .and_then(|x| x.get("name"))
497 .and_then(|x| x.as_str())
498 .map(String::from),
499 email: ea
500 .and_then(|x| x.get("address"))
501 .and_then(|x| x.as_str())
502 .map(String::from),
503 status: a
504 .get("status")
505 .and_then(|x| x.get("response"))
506 .and_then(|x| x.as_str())
507 .map(String::from),
508 role: a.get("type").and_then(|x| x.as_str()).map(String::from),
509 is_current_user: false,
510 }
511 })
512 .collect()
513 })
514 .unwrap_or_default();
515 Some(Event {
516 id: e
517 .get("id")
518 .and_then(|x| x.as_str())
519 .unwrap_or_default()
520 .to_string(),
521 calendar_id: calendar_id.to_string(),
522 title: e
523 .get("subject")
524 .and_then(|x| x.as_str())
525 .unwrap_or_default()
526 .to_string(),
527 start,
528 end,
529 all_day: e.get("isAllDay").and_then(|x| x.as_bool()).unwrap_or(false),
530 location: e
531 .get("location")
532 .and_then(|x| x.get("displayName"))
533 .and_then(|x| x.as_str())
534 .filter(|s| !s.is_empty())
535 .map(String::from),
536 notes: e
537 .get("bodyPreview")
538 .and_then(|x| x.as_str())
539 .filter(|s| !s.is_empty())
540 .map(String::from),
541 attendees,
542 status: e.get("showAs").and_then(|x| x.as_str()).map(String::from),
543 })
544 })
545 .collect()
546}
547
548pub(crate) fn parse_inbox_summary(v: &serde_json::Value, account_id: &str) -> InboxSummary {
550 InboxSummary {
551 account_id: account_id.to_string(),
552 unread: v
553 .get("unreadItemCount")
554 .and_then(|x| x.as_u64())
555 .unwrap_or(0) as u32,
556 total: v
557 .get("totalItemCount")
558 .and_then(|x| x.as_u64())
559 .unwrap_or(0) as u32,
560 most_recent_subject: None,
561 }
562}
563
564pub fn contacts(query: &str, limit: usize) -> Result<Vec<Contact>, GraphError> {
568 let top = limit.clamp(1, 999);
569 let mut list = parse_contacts(&graph_get(&format!("/me/contacts?$top={top}"))?);
570 if !query.is_empty() {
571 let q = query.to_lowercase();
572 list.retain(|c| {
573 c.display_name.to_lowercase().contains(&q)
574 || c.emails.iter().any(|e| e.to_lowercase().contains(&q))
575 });
576 }
577 Ok(list)
578}
579
580pub fn events(start: DateTime<Utc>, end: DateTime<Utc>) -> Result<Vec<Event>, GraphError> {
582 let path = format!(
583 "/me/calendarView?startDateTime={}&endDateTime={}&$top=200",
584 start.format("%Y-%m-%dT%H:%M:%SZ"),
585 end.format("%Y-%m-%dT%H:%M:%SZ")
586 );
587 Ok(parse_events(&graph_get(&path)?, "graph"))
588}
589
590pub fn inbox_summary(account_id: &str) -> Result<InboxSummary, GraphError> {
592 Ok(parse_inbox_summary(
593 &graph_get("/me/mailFolders/inbox")?,
594 account_id,
595 ))
596}
597
598#[cfg(test)]
599mod tests {
600 use super::*;
601
602 #[test]
603 fn device_code_parse() {
604 let v = serde_json::json!({
605 "device_code": "DEV", "user_code": "ABC-123",
606 "verification_uri": "https://microsoft.com/devicelogin",
607 "message": "go here", "interval": 5, "expires_in": 900
608 });
609 let dc = parse_device_code(&v).unwrap();
610 assert_eq!(dc.device_code, "DEV");
611 assert_eq!(dc.user_code, "ABC-123");
612 assert_eq!(dc.interval_secs, 5);
613 }
614
615 #[test]
616 fn token_poll_states() {
617 assert!(matches!(
618 parse_token_poll(&serde_json::json!({"access_token": "T"})),
619 TokenPoll::Token(_)
620 ));
621 assert!(matches!(
622 parse_token_poll(&serde_json::json!({"error": "authorization_pending"})),
623 TokenPoll::Pending
624 ));
625 assert!(matches!(
626 parse_token_poll(&serde_json::json!({"error": "slow_down"})),
627 TokenPoll::Slow
628 ));
629 assert!(matches!(
630 parse_token_poll(&serde_json::json!({"error": "expired_token"})),
631 TokenPoll::Error(_)
632 ));
633 }
634
635 #[test]
636 fn contacts_parse() {
637 let v = serde_json::json!({"value": [{
638 "id": "1", "displayName": "Ada Lovelace",
639 "emailAddresses": [{"address": "ada@example.com"}],
640 "businessPhones": ["+1 555 0100"], "mobilePhone": "+1 555 0199",
641 "companyName": "Analytical Engines"
642 }]});
643 let cs = parse_contacts(&v);
644 assert_eq!(cs.len(), 1);
645 assert_eq!(cs[0].display_name, "Ada Lovelace");
646 assert_eq!(cs[0].emails, vec!["ada@example.com"]);
647 assert_eq!(cs[0].phone_numbers.len(), 2);
648 assert_eq!(cs[0].organization.as_deref(), Some("Analytical Engines"));
649 }
650
651 #[test]
652 fn events_parse_utc() {
653 let v = serde_json::json!({"value": [{
654 "id": "e1", "subject": "Standup",
655 "start": {"dateTime": "2026-07-05T09:00:00.0000000", "timeZone": "UTC"},
656 "end": {"dateTime": "2026-07-05T09:15:00.0000000", "timeZone": "UTC"},
657 "location": {"displayName": "Room 1"}, "isAllDay": false,
658 "attendees": [{"emailAddress": {"name": "Bob", "address": "bob@x.com"}, "type": "required"}]
659 }]});
660 let es = parse_events(&v, "cal");
661 assert_eq!(es.len(), 1);
662 assert_eq!(es[0].title, "Standup");
663 assert_eq!(
664 es[0].start.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
665 "2026-07-05T09:00:00Z"
666 );
667 assert_eq!(es[0].location.as_deref(), Some("Room 1"));
668 assert_eq!(es[0].attendees.len(), 1);
669 assert_eq!(es[0].attendees[0].email.as_deref(), Some("bob@x.com"));
670 }
671
672 #[test]
673 fn inbox_parse() {
674 let v =
675 serde_json::json!({"displayName": "Inbox", "unreadItemCount": 3, "totalItemCount": 42});
676 let s = parse_inbox_summary(&v, "acct");
677 assert_eq!(s.unread, 3);
678 assert_eq!(s.total, 42);
679 assert_eq!(s.account_id, "acct");
680 }
681
682 #[test]
683 fn not_configured_by_default() {
684 if super::non_empty_env(CLIENT_ID_ENV).is_none()
686 && super::non_empty_env(TOKEN_ENV).is_none()
687 {
688 assert!(!is_configured());
689 }
690 }
691
692 fn utc(y: i32, m: u32, d: u32, h: u32, mi: u32) -> DateTime<Utc> {
695 Utc.with_ymd_and_hms(y, m, d, h, mi, 0).unwrap()
696 }
697
698 #[test]
699 fn event_create_body_shape() {
700 let input = EventCreateInput {
701 calendar_id: "graph".into(),
702 title: "Standup".into(),
703 start: utc(2026, 7, 5, 9, 0),
704 end: utc(2026, 7, 5, 9, 15),
705 all_day: false,
706 notes: Some("daily sync".into()),
707 location: Some("Room 1".into()),
708 url: Some("https://meet.example/x".into()),
709 };
710 let b = event_create_body(&input);
711 assert_eq!(b["subject"], "Standup");
712 assert_eq!(b["start"]["dateTime"], "2026-07-05T09:00:00");
713 assert_eq!(b["start"]["timeZone"], "UTC");
714 assert_eq!(b["isAllDay"], false);
715 assert_eq!(b["location"]["displayName"], "Room 1");
716 assert_eq!(b["body"]["contentType"], "text");
718 let content = b["body"]["content"].as_str().unwrap();
719 assert!(content.contains("daily sync") && content.contains("https://meet.example/x"));
720 }
721
722 #[test]
723 fn event_update_body_only_sets_present_fields() {
724 let input = EventUpdateInput {
725 event_id: "e1".into(),
726 title: Some("Renamed".into()),
727 start: None,
728 end: Some(utc(2026, 7, 5, 10, 0)),
729 all_day: None,
730 notes: None,
731 location: None,
732 url: None,
733 };
734 let b = event_update_body(&input);
735 let obj = b.as_object().unwrap();
736 assert_eq!(obj["subject"], "Renamed");
737 assert_eq!(obj["end"]["dateTime"], "2026-07-05T10:00:00");
738 assert!(!obj.contains_key("start"), "unset fields omitted");
739 assert!(!obj.contains_key("isAllDay"));
740 assert!(!obj.contains_key("location"));
741 assert!(!obj.contains_key("body"));
742 }
743
744 #[test]
745 fn send_mail_body_shape() {
746 let req = SendRequest {
747 account_id: "msgraph".into(),
748 to: vec!["a@x.com".into(), "b@x.com".into()],
749 cc: vec!["c@x.com".into()],
750 bcc: vec![],
751 subject: "Hi".into(),
752 body: "Body text".into(),
753 draft_only: false,
754 };
755 let b = send_mail_body(&req);
756 assert_eq!(b["saveToSentItems"], true);
757 assert_eq!(b["message"]["subject"], "Hi");
758 assert_eq!(b["message"]["body"]["content"], "Body text");
759 let to = b["message"]["toRecipients"].as_array().unwrap();
760 assert_eq!(to.len(), 2);
761 assert_eq!(to[0]["emailAddress"]["address"], "a@x.com");
762 assert_eq!(b["message"]["ccRecipients"].as_array().unwrap().len(), 1);
763 assert!(b["message"].get("bccRecipients").is_none());
765 }
766}