1use crate::calendar::{Attendee, Event, EventCreateInput, EventUpdateInput};
24use crate::contacts::Contact;
25use crate::mail::{
26 InboxSummary, Mailbox, MessageBodyResult, MessageQuery, MessageSummary, SendRequest,
27};
28use chrono::{DateTime, TimeZone, Utc};
29
30const GRAPH_BASE: &str = "https://graph.microsoft.com/v1.0";
31const DEFAULT_SCOPES: &str =
35 "offline_access Contacts.Read Calendars.ReadWrite Mail.ReadWrite Mail.Send User.Read";
36
37pub const CLIENT_ID_ENV: &str = "CAR_MSGRAPH_CLIENT_ID";
39pub const TENANT_ENV: &str = "CAR_MSGRAPH_TENANT";
42pub const TOKEN_ENV: &str = "CAR_MSGRAPH_TOKEN";
44
45#[derive(Debug, thiserror::Error)]
46pub enum GraphError {
47 #[error("Microsoft Graph is not configured: set {CLIENT_ID_ENV} (and sign in) or {TOKEN_ENV}")]
48 NotConfigured,
49 #[error("Microsoft Graph auth: {0}")]
50 Auth(String),
51 #[error("Microsoft Graph request failed: {0}")]
52 Request(String),
53 #[error("Microsoft Graph returned malformed data: {0}")]
54 Parse(String),
55}
56
57pub fn is_configured() -> bool {
60 non_empty_env(CLIENT_ID_ENV).is_some() || token().is_some()
61}
62
63fn non_empty_env(key: &str) -> Option<String> {
64 std::env::var(key).ok().filter(|v| !v.trim().is_empty())
65}
66
67fn token() -> Option<String> {
70 car_secrets::resolve_env_or_keychain(TOKEN_ENV).filter(|v| !v.trim().is_empty())
71}
72
73fn tenant() -> String {
74 non_empty_env(TENANT_ENV).unwrap_or_else(|| "common".to_string())
75}
76
77fn device_code_url() -> String {
81 format!(
82 "https://login.microsoftonline.com/{}/oauth2/v2.0/devicecode",
83 tenant()
84 )
85}
86
87fn token_url() -> String {
89 format!(
90 "https://login.microsoftonline.com/{}/oauth2/v2.0/token",
91 tenant()
92 )
93}
94
95#[derive(Debug, Clone)]
97pub struct DeviceCode {
98 pub device_code: String,
99 pub user_code: String,
100 pub verification_uri: String,
101 pub message: String,
102 pub interval_secs: u64,
103 pub expires_in_secs: u64,
104}
105
106fn parse_device_code(v: &serde_json::Value) -> Result<DeviceCode, GraphError> {
108 let s = |k: &str| v.get(k).and_then(|x| x.as_str()).map(|s| s.to_string());
109 Ok(DeviceCode {
110 device_code: s("device_code").ok_or_else(|| GraphError::Auth("no device_code".into()))?,
111 user_code: s("user_code").unwrap_or_default(),
112 verification_uri: s("verification_uri").unwrap_or_default(),
113 message: s("message").unwrap_or_default(),
114 interval_secs: v.get("interval").and_then(|x| x.as_u64()).unwrap_or(5),
115 expires_in_secs: v.get("expires_in").and_then(|x| x.as_u64()).unwrap_or(900),
116 })
117}
118
119enum TokenPoll {
121 Token(String),
122 Pending,
123 Slow,
124 Error(String),
125}
126
127fn parse_token_poll(v: &serde_json::Value) -> TokenPoll {
128 if let Some(tok) = v.get("access_token").and_then(|x| x.as_str()) {
129 return TokenPoll::Token(tok.to_string());
130 }
131 match v.get("error").and_then(|x| x.as_str()) {
132 Some("authorization_pending") => TokenPoll::Pending,
133 Some("slow_down") => TokenPoll::Slow,
134 Some(other) => TokenPoll::Error(other.to_string()),
135 None => TokenPoll::Error("no access_token and no error".into()),
136 }
137}
138
139pub fn device_code_login(sleep: &dyn Fn(std::time::Duration)) -> Result<String, GraphError> {
144 let client_id = non_empty_env(CLIENT_ID_ENV).ok_or(GraphError::NotConfigured)?;
145 let client = blocking_client()?;
146
147 let resp = client
148 .post(device_code_url())
149 .form(&[("client_id", client_id.as_str()), ("scope", DEFAULT_SCOPES)])
150 .send()
151 .map_err(|e| GraphError::Auth(format!("device code request: {e}")))?;
152 let dc = parse_device_code(
153 &resp
154 .json::<serde_json::Value>()
155 .map_err(|e| GraphError::Auth(format!("device code json: {e}")))?,
156 )?;
157 tracing::info!("{}", dc.message);
159 eprintln!("{}", dc.message);
160
161 let mut interval = dc.interval_secs.max(1);
162 let deadline = dc.expires_in_secs;
163 let mut elapsed = 0u64;
164 loop {
165 if elapsed >= deadline {
166 return Err(GraphError::Auth(
167 "device code expired before authorization".into(),
168 ));
169 }
170 sleep(std::time::Duration::from_secs(interval));
171 elapsed += interval;
172 let resp = client
173 .post(token_url())
174 .form(&[
175 ("client_id", client_id.as_str()),
176 ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
177 ("device_code", dc.device_code.as_str()),
178 ])
179 .send()
180 .map_err(|e| GraphError::Auth(format!("token poll: {e}")))?;
181 let json = resp
182 .json::<serde_json::Value>()
183 .map_err(|e| GraphError::Auth(format!("token json: {e}")))?;
184 match parse_token_poll(&json) {
185 TokenPoll::Token(t) => return Ok(t),
186 TokenPoll::Pending => {}
187 TokenPoll::Slow => interval += 5,
188 TokenPoll::Error(e) => return Err(GraphError::Auth(e)),
189 }
190 }
191}
192
193fn blocking_client() -> Result<reqwest::blocking::Client, GraphError> {
196 reqwest::blocking::Client::builder()
197 .timeout(std::time::Duration::from_secs(30))
198 .build()
199 .map_err(|e| GraphError::Request(format!("http client: {e}")))
200}
201
202fn access_token() -> Result<String, GraphError> {
205 if let Some(t) = token() {
206 return Ok(t);
207 }
208 device_code_login(&std::thread::sleep)
209}
210
211fn graph_get(path: &str) -> Result<serde_json::Value, GraphError> {
215 if !is_configured() {
216 return Err(GraphError::NotConfigured);
217 }
218 let token = access_token()?;
219 let client = blocking_client()?;
220 let resp = client
221 .get(format!("{GRAPH_BASE}{path}"))
222 .bearer_auth(token)
223 .header("Prefer", "outlook.timezone=\"UTC\"")
224 .send()
225 .map_err(|e| GraphError::Request(format!("GET {path}: {e}")))?;
226 if !resp.status().is_success() {
227 let status = resp.status();
228 let detail = resp.text().unwrap_or_default();
229 return Err(GraphError::Request(format!(
230 "GET {path} -> {status}: {detail}"
231 )));
232 }
233 resp.json::<serde_json::Value>()
234 .map_err(|e| GraphError::Parse(format!("GET {path} json: {e}")))
235}
236
237fn graph_request(
241 method: reqwest::Method,
242 path: &str,
243 body: Option<&serde_json::Value>,
244) -> Result<Option<serde_json::Value>, GraphError> {
245 if !is_configured() {
246 return Err(GraphError::NotConfigured);
247 }
248 let token = access_token()?;
249 let client = blocking_client()?;
250 let mut req = client
251 .request(method, format!("{GRAPH_BASE}{path}"))
252 .bearer_auth(token)
253 .header("Prefer", "outlook.timezone=\"UTC\"");
254 if let Some(b) = body {
255 req = req.json(b);
256 }
257 let resp = req
258 .send()
259 .map_err(|e| GraphError::Request(format!("{path}: {e}")))?;
260 if !resp.status().is_success() {
261 let status = resp.status();
262 let detail = resp.text().unwrap_or_default();
263 return Err(GraphError::Request(format!("{path} -> {status}: {detail}")));
264 }
265 let text = resp.text().unwrap_or_default();
267 if text.trim().is_empty() {
268 return Ok(None);
269 }
270 serde_json::from_str(&text)
271 .map(Some)
272 .map_err(|e| GraphError::Parse(format!("{path} json: {e}")))
273}
274
275fn graph_datetime(dt: DateTime<Utc>) -> serde_json::Value {
279 serde_json::json!({
280 "dateTime": dt.format("%Y-%m-%dT%H:%M:%S").to_string(),
281 "timeZone": "UTC",
282 })
283}
284
285fn event_body_content(notes: &Option<String>, url: &Option<String>) -> Option<serde_json::Value> {
288 let mut content = notes.clone().unwrap_or_default();
289 if let Some(u) = url {
290 if !content.is_empty() {
291 content.push('\n');
292 }
293 content.push_str(u);
294 }
295 (!content.is_empty()).then(|| serde_json::json!({ "contentType": "text", "content": content }))
296}
297
298pub(crate) fn event_create_body(input: &EventCreateInput) -> serde_json::Value {
300 let mut body = serde_json::json!({
301 "subject": input.title,
302 "start": graph_datetime(input.start),
303 "end": graph_datetime(input.end),
304 "isAllDay": input.all_day,
305 });
306 if let Some(b) = event_body_content(&input.notes, &input.url) {
307 body["body"] = b;
308 }
309 if let Some(loc) = &input.location {
310 body["location"] = serde_json::json!({ "displayName": loc });
311 }
312 body
313}
314
315pub(crate) fn event_update_body(input: &EventUpdateInput) -> serde_json::Value {
317 let mut body = serde_json::Map::new();
318 if let Some(t) = &input.title {
319 body.insert("subject".into(), serde_json::json!(t));
320 }
321 if let Some(s) = input.start {
322 body.insert("start".into(), graph_datetime(s));
323 }
324 if let Some(e) = input.end {
325 body.insert("end".into(), graph_datetime(e));
326 }
327 if let Some(a) = input.all_day {
328 body.insert("isAllDay".into(), serde_json::json!(a));
329 }
330 if input.notes.is_some() || input.url.is_some() {
331 if let Some(b) = event_body_content(&input.notes, &input.url) {
332 body.insert("body".into(), b);
333 }
334 }
335 if let Some(loc) = &input.location {
336 body.insert("location".into(), serde_json::json!({ "displayName": loc }));
337 }
338 serde_json::Value::Object(body)
339}
340
341pub(crate) fn send_mail_body(req: &SendRequest) -> serde_json::Value {
343 let recips = |addrs: &[String]| -> serde_json::Value {
344 serde_json::Value::Array(
345 addrs
346 .iter()
347 .map(|a| serde_json::json!({ "emailAddress": { "address": a } }))
348 .collect(),
349 )
350 };
351 let mut message = serde_json::json!({
352 "subject": req.subject,
353 "body": { "contentType": "text", "content": req.body },
354 "toRecipients": recips(&req.to),
355 });
356 if !req.cc.is_empty() {
357 message["ccRecipients"] = recips(&req.cc);
358 }
359 if !req.bcc.is_empty() {
360 message["bccRecipients"] = recips(&req.bcc);
361 }
362 serde_json::json!({ "message": message, "saveToSentItems": true })
363}
364
365fn parse_single_event(resp: serde_json::Value) -> Result<Event, GraphError> {
368 parse_events(&serde_json::json!({ "value": [resp] }), "graph")
369 .into_iter()
370 .next()
371 .ok_or_else(|| GraphError::Parse("event response not parseable".into()))
372}
373
374pub fn create_event(input: &EventCreateInput) -> Result<Event, GraphError> {
376 let body = event_create_body(input);
377 let resp = graph_request(reqwest::Method::POST, "/me/events", Some(&body))?
378 .ok_or_else(|| GraphError::Parse("create event returned no body".into()))?;
379 parse_single_event(resp)
380}
381
382pub fn update_event(input: &EventUpdateInput) -> Result<Event, GraphError> {
384 let body = event_update_body(input);
385 let path = format!("/me/events/{}", input.event_id);
386 let resp = graph_request(reqwest::Method::PATCH, &path, Some(&body))?
387 .ok_or_else(|| GraphError::Parse("update event returned no body".into()))?;
388 parse_single_event(resp)
389}
390
391pub fn delete_event(event_id: &str) -> Result<(), GraphError> {
393 graph_request(
394 reqwest::Method::DELETE,
395 &format!("/me/events/{event_id}"),
396 None,
397 )?;
398 Ok(())
399}
400
401pub fn send_mail(req: &SendRequest) -> Result<Option<String>, GraphError> {
405 if req.draft_only {
406 let body = send_mail_body(req);
407 let message = body.get("message").cloned().unwrap_or(body);
409 let resp = graph_request(reqwest::Method::POST, "/me/messages", Some(&message))?
410 .ok_or_else(|| GraphError::Parse("draft returned no body".into()))?;
411 Ok(resp.get("id").and_then(|v| v.as_str()).map(String::from))
412 } else {
413 let body = send_mail_body(req);
414 graph_request(reqwest::Method::POST, "/me/sendMail", Some(&body))?;
415 Ok(None)
416 }
417}
418
419pub(crate) fn parse_contacts(v: &serde_json::Value) -> Vec<Contact> {
423 let items = v
424 .get("value")
425 .and_then(|x| x.as_array())
426 .cloned()
427 .unwrap_or_default();
428 items
429 .iter()
430 .map(|c| {
431 let emails = c
432 .get("emailAddresses")
433 .and_then(|x| x.as_array())
434 .map(|arr| {
435 arr.iter()
436 .filter_map(|e| e.get("address").and_then(|a| a.as_str()).map(String::from))
437 .collect()
438 })
439 .unwrap_or_default();
440 let mut phones: Vec<String> = Vec::new();
441 for key in ["businessPhones", "homePhones"] {
442 if let Some(arr) = c.get(key).and_then(|x| x.as_array()) {
443 phones.extend(arr.iter().filter_map(|p| p.as_str().map(String::from)));
444 }
445 }
446 if let Some(m) = c.get("mobilePhone").and_then(|x| x.as_str()) {
447 phones.push(m.to_string());
448 }
449 Contact {
450 id: c
451 .get("id")
452 .and_then(|x| x.as_str())
453 .unwrap_or_default()
454 .to_string(),
455 container_id: None,
456 display_name: c
457 .get("displayName")
458 .and_then(|x| x.as_str())
459 .unwrap_or_default()
460 .to_string(),
461 emails,
462 phone_numbers: phones,
463 organization: c
464 .get("companyName")
465 .and_then(|x| x.as_str())
466 .filter(|s| !s.is_empty())
467 .map(String::from),
468 }
469 })
470 .collect()
471}
472
473fn parse_graph_datetime(v: &serde_json::Value) -> Option<DateTime<Utc>> {
475 let s = v.get("dateTime").and_then(|x| x.as_str())?;
476 let trimmed = s.split('.').next().unwrap_or(s);
478 chrono::NaiveDateTime::parse_from_str(trimmed, "%Y-%m-%dT%H:%M:%S")
479 .ok()
480 .map(|ndt| Utc.from_utc_datetime(&ndt))
481}
482
483pub(crate) fn parse_events(v: &serde_json::Value, calendar_id: &str) -> Vec<Event> {
485 let items = v
486 .get("value")
487 .and_then(|x| x.as_array())
488 .cloned()
489 .unwrap_or_default();
490 items
491 .iter()
492 .filter_map(|e| {
493 let start = parse_graph_datetime(e.get("start")?)?;
494 let end = parse_graph_datetime(e.get("end")?).unwrap_or(start);
495 let attendees = e
496 .get("attendees")
497 .and_then(|x| x.as_array())
498 .map(|arr| {
499 arr.iter()
500 .map(|a| {
501 let ea = a.get("emailAddress");
502 Attendee {
503 name: ea
504 .and_then(|x| x.get("name"))
505 .and_then(|x| x.as_str())
506 .map(String::from),
507 email: ea
508 .and_then(|x| x.get("address"))
509 .and_then(|x| x.as_str())
510 .map(String::from),
511 status: a
512 .get("status")
513 .and_then(|x| x.get("response"))
514 .and_then(|x| x.as_str())
515 .map(String::from),
516 role: a.get("type").and_then(|x| x.as_str()).map(String::from),
517 is_current_user: false,
518 }
519 })
520 .collect()
521 })
522 .unwrap_or_default();
523 Some(Event {
524 id: e
525 .get("id")
526 .and_then(|x| x.as_str())
527 .unwrap_or_default()
528 .to_string(),
529 calendar_id: calendar_id.to_string(),
530 title: e
531 .get("subject")
532 .and_then(|x| x.as_str())
533 .unwrap_or_default()
534 .to_string(),
535 start,
536 end,
537 all_day: e.get("isAllDay").and_then(|x| x.as_bool()).unwrap_or(false),
538 location: e
539 .get("location")
540 .and_then(|x| x.get("displayName"))
541 .and_then(|x| x.as_str())
542 .filter(|s| !s.is_empty())
543 .map(String::from),
544 notes: e
545 .get("bodyPreview")
546 .and_then(|x| x.as_str())
547 .filter(|s| !s.is_empty())
548 .map(String::from),
549 attendees,
550 status: e.get("showAs").and_then(|x| x.as_str()).map(String::from),
551 })
552 })
553 .collect()
554}
555
556pub(crate) fn parse_inbox_summary(v: &serde_json::Value, account_id: &str) -> InboxSummary {
558 InboxSummary {
559 account_id: account_id.to_string(),
560 unread: v
561 .get("unreadItemCount")
562 .and_then(|x| x.as_u64())
563 .unwrap_or(0) as u32,
564 total: v
565 .get("totalItemCount")
566 .and_then(|x| x.as_u64())
567 .unwrap_or(0) as u32,
568 most_recent_subject: None,
569 }
570}
571
572#[derive(Debug, Clone, PartialEq)]
587pub(crate) struct GraphFolder {
588 pub id: String,
589 pub name: String,
591 pub path: String,
593 pub unread: u32,
594 pub total: u32,
595 pub children: u32,
597}
598
599pub(crate) fn parse_folder_page(v: &serde_json::Value, prefix: &str) -> Vec<GraphFolder> {
602 v.get("value")
603 .and_then(|x| x.as_array())
604 .map(|arr| arr.as_slice())
605 .unwrap_or_default()
606 .iter()
607 .filter_map(|f| {
608 let id = f.get("id").and_then(|x| x.as_str())?;
609 let name = f
610 .get("displayName")
611 .and_then(|x| x.as_str())
612 .unwrap_or(id)
613 .to_string();
614 let path = if prefix.is_empty() {
615 name.clone()
616 } else {
617 format!("{prefix}/{name}")
618 };
619 Some(GraphFolder {
620 id: id.to_string(),
621 name,
622 path,
623 unread: f
624 .get("unreadItemCount")
625 .and_then(|x| x.as_u64())
626 .unwrap_or(0) as u32,
627 total: f
628 .get("totalItemCount")
629 .and_then(|x| x.as_u64())
630 .unwrap_or(0) as u32,
631 children: f
632 .get("childFolderCount")
633 .and_then(|x| x.as_u64())
634 .unwrap_or(0) as u32,
635 })
636 })
637 .collect()
638}
639
640pub(crate) fn folders_to_mailboxes(folders: Vec<GraphFolder>, account_id: &str) -> Vec<Mailbox> {
649 folders
650 .into_iter()
651 .map(|f| Mailbox {
652 account_id: account_id.to_string(),
653 name: f.name,
654 full_name: f.id,
655 unread: f.unread,
656 total: f.total,
657 })
658 .collect()
659}
660
661pub(crate) fn resolve_folder_id(folders: &[GraphFolder], wanted: &str) -> Option<String> {
670 if let Some(hit) = folders.iter().find(|f| f.id == wanted) {
671 return Some(hit.id.clone());
672 }
673 if let Some(hit) = folders.iter().find(|f| f.path.eq_ignore_ascii_case(wanted)) {
674 return Some(hit.id.clone());
675 }
676 folders
677 .iter()
678 .find(|f| f.name.eq_ignore_ascii_case(wanted))
679 .map(|f| f.id.clone())
680}
681
682fn parse_graph_instant(v: Option<&serde_json::Value>) -> Option<DateTime<Utc>> {
687 let s = v.and_then(|x| x.as_str())?;
688 DateTime::parse_from_rfc3339(s)
689 .ok()
690 .map(|dt| dt.with_timezone(&Utc))
691}
692
693fn graph_address(v: Option<&serde_json::Value>) -> Option<String> {
694 v.and_then(|x| x.get("emailAddress"))
695 .and_then(|x| x.get("address"))
696 .and_then(|x| x.as_str())
697 .map(String::from)
698}
699
700pub(crate) fn parse_messages(
702 v: &serde_json::Value,
703 account_id: &str,
704 mailbox: &str,
705 cap: usize,
706) -> Vec<MessageSummary> {
707 v.get("value")
708 .and_then(|x| x.as_array())
709 .map(|arr| arr.as_slice())
710 .unwrap_or_default()
711 .iter()
712 .filter_map(|m| {
713 let id = m.get("id").and_then(|x| x.as_str())?;
714 let body = m
715 .get("body")
716 .and_then(|b| b.get("content"))
717 .and_then(|x| x.as_str())
718 .map(|s| truncate_chars(s, cap).0);
719 Some(MessageSummary {
720 id: crate::mail::encode_graph_message_id(id),
721 account_id: account_id.to_string(),
722 mailbox: mailbox.to_string(),
723 subject: m
724 .get("subject")
725 .and_then(|x| x.as_str())
726 .filter(|s| !s.is_empty())
727 .map(String::from),
728 sender: graph_address(m.get("from")).or_else(|| graph_address(m.get("sender"))),
729 recipients: m
730 .get("toRecipients")
731 .and_then(|x| x.as_array())
732 .map(|arr| arr.iter().filter_map(|r| graph_address(Some(r))).collect())
733 .unwrap_or_default(),
734 date_received: parse_graph_instant(m.get("receivedDateTime")),
735 read: m.get("isRead").and_then(|x| x.as_bool()).unwrap_or(false),
736 preview: m
737 .get("bodyPreview")
738 .and_then(|x| x.as_str())
739 .filter(|s| !s.is_empty())
740 .map(String::from),
741 body,
742 })
743 })
744 .collect()
745}
746
747fn truncate_chars(s: &str, cap: usize) -> (String, bool) {
750 if cap == 0 || s.chars().count() <= cap {
751 return (s.to_string(), false);
752 }
753 (s.chars().take(cap).collect(), true)
754}
755
756pub(crate) fn parse_message_body(v: &serde_json::Value, id: &str, cap: usize) -> MessageBodyResult {
758 let body = v.get("body");
759 let content_type = body
760 .and_then(|b| b.get("contentType"))
761 .and_then(|x| x.as_str())
762 .filter(|s| s.eq_ignore_ascii_case("html"))
763 .map(|_| "html")
764 .unwrap_or("text");
765 let raw = body
766 .and_then(|b| b.get("content"))
767 .and_then(|x| x.as_str())
768 .map(|s| truncate_chars(s, cap));
769 MessageBodyResult {
770 availability: crate::Availability::available("msgraph"),
771 id: id.to_string(),
772 content_type: content_type.to_string(),
773 body: raw.as_ref().map(|(s, _)| s.clone()),
774 truncated: raw.map(|(_, t)| t).unwrap_or(false),
775 }
776}
777
778pub fn contacts(query: &str, limit: usize) -> Result<Vec<Contact>, GraphError> {
782 let top = limit.clamp(1, 999);
783 let mut list = parse_contacts(&graph_get(&format!("/me/contacts?$top={top}"))?);
784 if !query.is_empty() {
785 let q = query.to_lowercase();
786 list.retain(|c| {
787 c.display_name.to_lowercase().contains(&q)
788 || c.emails.iter().any(|e| e.to_lowercase().contains(&q))
789 });
790 }
791 Ok(list)
792}
793
794pub fn events(start: DateTime<Utc>, end: DateTime<Utc>) -> Result<Vec<Event>, GraphError> {
796 let path = format!(
797 "/me/calendarView?startDateTime={}&endDateTime={}&$top=200",
798 start.format("%Y-%m-%dT%H:%M:%SZ"),
799 end.format("%Y-%m-%dT%H:%M:%SZ")
800 );
801 Ok(parse_events(&graph_get(&path)?, "graph"))
802}
803
804pub fn inbox_summary(account_id: &str) -> Result<InboxSummary, GraphError> {
806 Ok(parse_inbox_summary(
807 &graph_get("/me/mailFolders/inbox")?,
808 account_id,
809 ))
810}
811
812const FOLDER_WALK_MAX_DEPTH: usize = 8;
815
816const FOLDER_WALK_MAX_REQUESTS: usize = 64;
820
821const FOLDER_PAGE_TOP: usize = 200;
824const FOLDER_MAX_PAGES: usize = 5;
825
826fn graph_get_pages(
830 path: &str,
831 max_pages: usize,
832 requests: &mut usize,
833) -> Result<Vec<serde_json::Value>, GraphError> {
834 let mut out = Vec::new();
835 let mut next = Some(path.to_string());
836 while let Some(p) = next.take() {
837 if *requests >= FOLDER_WALK_MAX_REQUESTS || out.len() >= max_pages {
838 break;
839 }
840 *requests += 1;
841 let v = graph_get(&p)?;
842 next = v
843 .get("@odata.nextLink")
844 .and_then(|x| x.as_str())
845 .and_then(|link| link.strip_prefix(GRAPH_BASE))
846 .map(String::from);
847 out.push(v);
848 }
849 Ok(out)
850}
851
852fn folder_tree() -> Result<Vec<GraphFolder>, GraphError> {
860 let mut requests = 0usize;
861 let root = format!("/me/mailFolders?$top={FOLDER_PAGE_TOP}");
862 let mut frontier: Vec<GraphFolder> = graph_get_pages(&root, FOLDER_MAX_PAGES, &mut requests)?
863 .iter()
864 .flat_map(|page| parse_folder_page(page, ""))
865 .collect();
866
867 let mut out: Vec<GraphFolder> = Vec::new();
868 let mut depth = 0usize;
869 loop {
870 let mut next: Vec<GraphFolder> = Vec::new();
871 if depth < FOLDER_WALK_MAX_DEPTH {
872 for f in &frontier {
873 if f.children == 0 || requests >= FOLDER_WALK_MAX_REQUESTS {
874 continue;
875 }
876 let path = format!(
877 "/me/mailFolders/{}/childFolders?$top={FOLDER_PAGE_TOP}",
878 f.id
879 );
880 if let Ok(pages) = graph_get_pages(&path, FOLDER_MAX_PAGES, &mut requests) {
884 for page in &pages {
885 next.extend(parse_folder_page(page, &f.path));
886 }
887 }
888 }
889 }
890 out.append(&mut frontier);
891 if next.is_empty() {
892 break;
893 }
894 frontier = next;
895 depth += 1;
896 }
897 Ok(out)
898}
899
900pub fn mail_folders(account_id: &str) -> Result<Vec<Mailbox>, GraphError> {
903 Ok(folders_to_mailboxes(folder_tree()?, account_id))
904}
905
906pub fn messages(account_id: &str, query: &MessageQuery) -> Result<Vec<MessageSummary>, GraphError> {
912 let wanted = query
913 .mailbox
914 .clone()
915 .unwrap_or_else(|| crate::mail::DEFAULT_MAILBOX.to_string());
916 let folder = if wanted.eq_ignore_ascii_case(crate::mail::DEFAULT_MAILBOX) {
920 "inbox".to_string()
921 } else {
922 resolve_folder_id(&folder_tree()?, &wanted).ok_or_else(|| {
926 GraphError::Request(format!(
927 "no mail folder named {wanted} — list them with mail.mailboxes"
928 ))
929 })?
930 };
931 let top = query.limit.clamp(1, 500);
932 let mut select = "id,subject,from,toRecipients,receivedDateTime,isRead,bodyPreview".to_string();
933 if query.include_body {
934 select.push_str(",body");
935 }
936 let mut path = format!(
937 "/me/mailFolders/{folder}/messages?$top={top}&$orderby=receivedDateTime%20desc&$select={select}"
938 );
939 if let Some(since) = query.since {
940 path.push_str(&format!(
941 "&$filter=receivedDateTime%20ge%20{}",
942 since.format("%Y-%m-%dT%H:%M:%SZ")
943 ));
944 }
945 Ok(parse_messages(
946 &graph_get(&path)?,
947 account_id,
948 &wanted,
949 crate::mail::MESSAGE_BODY_CAP,
950 ))
951}
952
953pub fn message_body(graph_id: &str) -> Result<MessageBodyResult, GraphError> {
955 let v = graph_get(&format!("/me/messages/{graph_id}?$select=body"))?;
956 Ok(parse_message_body(
957 &v,
958 &crate::mail::encode_graph_message_id(graph_id),
959 crate::mail::MESSAGE_BODY_CAP,
960 ))
961}
962
963#[derive(Debug, Clone, PartialEq)]
972pub struct GraphNamed {
973 pub id: String,
974 pub name: String,
975}
976
977#[derive(Debug, Clone, PartialEq)]
979pub struct GraphNote {
980 pub id: String,
981 pub title: String,
982 pub notebook: Option<String>,
983 pub modified: Option<String>,
984}
985
986#[derive(Debug, Clone, PartialEq)]
988pub struct GraphTask {
989 pub id: String,
990 pub title: String,
991 pub list: Option<String>,
992 pub due: Option<String>,
993 pub completed: bool,
994}
995
996fn parse_named(v: &serde_json::Value) -> Vec<GraphNamed> {
997 v.get("value")
998 .and_then(|x| x.as_array())
999 .map(|arr| {
1000 arr.iter()
1001 .filter_map(|n| {
1002 let id = n.get("id")?.as_str()?.to_string();
1003 let name = n
1004 .get("displayName")
1005 .and_then(|x| x.as_str())
1006 .unwrap_or("")
1007 .to_string();
1008 Some(GraphNamed { id, name })
1009 })
1010 .collect()
1011 })
1012 .unwrap_or_default()
1013}
1014
1015fn parse_notes(v: &serde_json::Value) -> Vec<GraphNote> {
1016 v.get("value")
1017 .and_then(|x| x.as_array())
1018 .map(|arr| {
1019 arr.iter()
1020 .filter_map(|p| {
1021 let id = p.get("id")?.as_str()?.to_string();
1022 let title = p
1023 .get("title")
1024 .and_then(|x| x.as_str())
1025 .filter(|s| !s.is_empty())
1026 .unwrap_or("Untitled")
1027 .to_string();
1028 let notebook = p
1029 .get("parentNotebook")
1030 .and_then(|nb| nb.get("displayName"))
1031 .and_then(|x| x.as_str())
1032 .map(String::from);
1033 let modified = p
1034 .get("lastModifiedDateTime")
1035 .and_then(|x| x.as_str())
1036 .map(String::from);
1037 Some(GraphNote {
1038 id,
1039 title,
1040 notebook,
1041 modified,
1042 })
1043 })
1044 .collect()
1045 })
1046 .unwrap_or_default()
1047}
1048
1049fn parse_tasks(v: &serde_json::Value, list: &str) -> Vec<GraphTask> {
1050 v.get("value")
1051 .and_then(|x| x.as_array())
1052 .map(|arr| {
1053 arr.iter()
1054 .filter_map(|t| {
1055 let id = t.get("id")?.as_str()?.to_string();
1056 let title = t
1057 .get("title")
1058 .and_then(|x| x.as_str())
1059 .unwrap_or("")
1060 .to_string();
1061 let completed = t.get("status").and_then(|x| x.as_str()) == Some("completed");
1062 let due = t
1063 .get("dueDateTime")
1064 .and_then(|d| d.get("dateTime"))
1065 .and_then(|x| x.as_str())
1066 .map(String::from);
1067 Some(GraphTask {
1068 id,
1069 title,
1070 list: Some(list.to_string()),
1071 due,
1072 completed,
1073 })
1074 })
1075 .collect()
1076 })
1077 .unwrap_or_default()
1078}
1079
1080pub fn onenote_notebooks() -> Result<Vec<GraphNamed>, GraphError> {
1082 Ok(parse_named(&graph_get("/me/onenote/notebooks")?))
1083}
1084
1085pub fn onenote_pages(query: &str, limit: usize) -> Result<Vec<GraphNote>, GraphError> {
1088 let top = limit.clamp(1, 100);
1089 let path = format!(
1091 "/me/onenote/pages?$top={top}&$orderby=lastModifiedDateTime%20desc&$expand=parentNotebook"
1092 );
1093 let mut list = parse_notes(&graph_get(&path)?);
1094 if !query.is_empty() {
1095 let q = query.to_lowercase();
1096 list.retain(|n| n.title.to_lowercase().contains(&q));
1097 }
1098 Ok(list)
1099}
1100
1101pub fn todo_lists() -> Result<Vec<GraphNamed>, GraphError> {
1103 Ok(parse_named(&graph_get("/me/todo/lists")?))
1104}
1105
1106pub fn todo_tasks(limit: usize) -> Result<Vec<GraphTask>, GraphError> {
1110 let cap = if limit == 0 { usize::MAX } else { limit };
1111 let lists = todo_lists()?;
1112 let mut out = Vec::new();
1113 for list in &lists {
1114 if out.len() >= cap {
1115 break;
1116 }
1117 let top = (cap - out.len()).clamp(1, 100);
1118 let path = format!("/me/todo/lists/{}/tasks?$top={top}", list.id);
1119 if let Ok(v) = graph_get(&path) {
1120 out.extend(parse_tasks(&v, &list.name));
1121 }
1122 }
1123 out.truncate(cap);
1124 Ok(out)
1125}
1126
1127#[cfg(test)]
1128mod tests {
1129 use super::*;
1130
1131 #[test]
1132 fn device_code_parse() {
1133 let v = serde_json::json!({
1134 "device_code": "DEV", "user_code": "ABC-123",
1135 "verification_uri": "https://microsoft.com/devicelogin",
1136 "message": "go here", "interval": 5, "expires_in": 900
1137 });
1138 let dc = parse_device_code(&v).unwrap();
1139 assert_eq!(dc.device_code, "DEV");
1140 assert_eq!(dc.user_code, "ABC-123");
1141 assert_eq!(dc.interval_secs, 5);
1142 }
1143
1144 #[test]
1145 fn token_poll_states() {
1146 assert!(matches!(
1147 parse_token_poll(&serde_json::json!({"access_token": "T"})),
1148 TokenPoll::Token(_)
1149 ));
1150 assert!(matches!(
1151 parse_token_poll(&serde_json::json!({"error": "authorization_pending"})),
1152 TokenPoll::Pending
1153 ));
1154 assert!(matches!(
1155 parse_token_poll(&serde_json::json!({"error": "slow_down"})),
1156 TokenPoll::Slow
1157 ));
1158 assert!(matches!(
1159 parse_token_poll(&serde_json::json!({"error": "expired_token"})),
1160 TokenPoll::Error(_)
1161 ));
1162 }
1163
1164 #[test]
1165 fn named_parse_notebooks_and_lists() {
1166 let v = serde_json::json!({"value": [
1167 {"id": "nb1", "displayName": "Work"},
1168 {"id": "nb2", "displayName": "Personal"},
1169 {"id": "no-name"}
1170 ]});
1171 let named = parse_named(&v);
1172 assert_eq!(named.len(), 3);
1173 assert_eq!(
1174 named[0],
1175 GraphNamed {
1176 id: "nb1".into(),
1177 name: "Work".into()
1178 }
1179 );
1180 assert_eq!(named[2].name, ""); }
1182
1183 #[test]
1184 fn notes_parse_title_notebook_modified() {
1185 let v = serde_json::json!({"value": [
1186 {"id": "p1", "title": "Roadmap",
1187 "lastModifiedDateTime": "2026-08-01T10:00:00Z",
1188 "parentNotebook": {"displayName": "Work"}},
1189 {"id": "p2", "title": ""} ]});
1191 let notes = parse_notes(&v);
1192 assert_eq!(notes.len(), 2);
1193 assert_eq!(notes[0].title, "Roadmap");
1194 assert_eq!(notes[0].notebook.as_deref(), Some("Work"));
1195 assert_eq!(notes[0].modified.as_deref(), Some("2026-08-01T10:00:00Z"));
1196 assert_eq!(notes[1].title, "Untitled");
1197 assert!(notes[1].notebook.is_none());
1198 }
1199
1200 #[test]
1201 fn tasks_parse_status_and_due() {
1202 let v = serde_json::json!({"value": [
1203 {"id": "t1", "title": "Ship it", "status": "notStarted",
1204 "dueDateTime": {"dateTime": "2026-08-05T17:00:00.0000000", "timeZone": "UTC"}},
1205 {"id": "t2", "title": "Done thing", "status": "completed"}
1206 ]});
1207 let tasks = parse_tasks(&v, "Tasks");
1208 assert_eq!(tasks.len(), 2);
1209 assert_eq!(tasks[0].list.as_deref(), Some("Tasks"));
1210 assert!(!tasks[0].completed);
1211 assert_eq!(tasks[0].due.as_deref(), Some("2026-08-05T17:00:00.0000000"));
1212 assert!(tasks[1].completed);
1213 assert!(tasks[1].due.is_none());
1214 }
1215
1216 #[test]
1217 fn contacts_parse() {
1218 let v = serde_json::json!({"value": [{
1219 "id": "1", "displayName": "Ada Lovelace",
1220 "emailAddresses": [{"address": "ada@example.com"}],
1221 "businessPhones": ["+1 555 0100"], "mobilePhone": "+1 555 0199",
1222 "companyName": "Analytical Engines"
1223 }]});
1224 let cs = parse_contacts(&v);
1225 assert_eq!(cs.len(), 1);
1226 assert_eq!(cs[0].display_name, "Ada Lovelace");
1227 assert_eq!(cs[0].emails, vec!["ada@example.com"]);
1228 assert_eq!(cs[0].phone_numbers.len(), 2);
1229 assert_eq!(cs[0].organization.as_deref(), Some("Analytical Engines"));
1230 }
1231
1232 #[test]
1233 fn events_parse_utc() {
1234 let v = serde_json::json!({"value": [{
1235 "id": "e1", "subject": "Standup",
1236 "start": {"dateTime": "2026-07-05T09:00:00.0000000", "timeZone": "UTC"},
1237 "end": {"dateTime": "2026-07-05T09:15:00.0000000", "timeZone": "UTC"},
1238 "location": {"displayName": "Room 1"}, "isAllDay": false,
1239 "attendees": [{"emailAddress": {"name": "Bob", "address": "bob@x.com"}, "type": "required"}]
1240 }]});
1241 let es = parse_events(&v, "cal");
1242 assert_eq!(es.len(), 1);
1243 assert_eq!(es[0].title, "Standup");
1244 assert_eq!(
1245 es[0].start.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
1246 "2026-07-05T09:00:00Z"
1247 );
1248 assert_eq!(es[0].location.as_deref(), Some("Room 1"));
1249 assert_eq!(es[0].attendees.len(), 1);
1250 assert_eq!(es[0].attendees[0].email.as_deref(), Some("bob@x.com"));
1251 }
1252
1253 #[test]
1254 fn inbox_parse() {
1255 let v =
1256 serde_json::json!({"displayName": "Inbox", "unreadItemCount": 3, "totalItemCount": 42});
1257 let s = parse_inbox_summary(&v, "acct");
1258 assert_eq!(s.unread, 3);
1259 assert_eq!(s.total, 42);
1260 assert_eq!(s.account_id, "acct");
1261 }
1262
1263 fn folders_fixture() -> serde_json::Value {
1266 serde_json::json!({"value": [
1267 {"id": "AAA-inbox", "displayName": "Inbox",
1268 "unreadItemCount": 3, "totalItemCount": 42},
1269 {"id": "BBB-travel", "displayName": "Travel",
1270 "unreadItemCount": 1, "totalItemCount": 9, "childFolderCount": 1},
1271 {"id": "CCC-nameless"}
1272 ]})
1273 }
1274
1275 fn folder_tree_fixture() -> Vec<GraphFolder> {
1279 let mut tree = parse_folder_page(&folders_fixture(), "");
1280 tree.extend(parse_folder_page(
1281 &serde_json::json!({"value": [
1282 {"id": "DDD-2026", "displayName": "2026",
1283 "unreadItemCount": 2, "totalItemCount": 5}
1284 ]}),
1285 "Travel",
1286 ));
1287 tree
1288 }
1289
1290 #[test]
1291 fn mailboxes_parse() {
1292 let boxes = folders_to_mailboxes(parse_folder_page(&folders_fixture(), ""), "acct");
1293 assert_eq!(boxes.len(), 3);
1294 assert_eq!(boxes[1].account_id, "acct");
1295 assert_eq!(boxes[1].name, "Travel");
1296 assert_eq!(boxes[1].full_name, "BBB-travel");
1298 assert_eq!(boxes[1].unread, 1);
1299 assert_eq!(boxes[1].total, 9);
1300 assert_eq!(boxes[2].name, "CCC-nameless");
1302 }
1303
1304 #[test]
1305 fn folder_page_parses_child_counts_and_prefixes() {
1306 let rows = parse_folder_page(&folders_fixture(), "Parent");
1307 assert_eq!(rows[1].path, "Parent/Travel");
1308 assert_eq!(rows[1].children, 1);
1309 assert_eq!(rows[0].children, 0);
1311 }
1312
1313 #[test]
1314 fn folder_resolves_by_id_and_by_display_name() {
1315 let f = folder_tree_fixture();
1316 assert_eq!(
1317 resolve_folder_id(&f, "BBB-travel").as_deref(),
1318 Some("BBB-travel")
1319 );
1320 assert_eq!(
1322 resolve_folder_id(&f, "travel").as_deref(),
1323 Some("BBB-travel")
1324 );
1325 assert_eq!(resolve_folder_id(&f, "Archive"), None);
1326 }
1327
1328 #[test]
1333 fn child_folders_carry_a_slash_joined_path_and_resolve_like_macos() {
1334 let tree = folder_tree_fixture();
1335 let nested = tree.iter().find(|f| f.id == "DDD-2026").unwrap();
1336 assert_eq!(nested.name, "2026");
1337 assert_eq!(nested.path, "Travel/2026");
1338 assert_eq!(nested.unread, 2);
1339 assert_eq!(nested.total, 5);
1340
1341 for selector in ["Travel/2026", "travel/2026", "2026", "DDD-2026"] {
1344 assert_eq!(
1345 resolve_folder_id(&tree, selector).as_deref(),
1346 Some("DDD-2026"),
1347 "selector {selector:?} should resolve to the nested folder"
1348 );
1349 }
1350
1351 let parent = tree.iter().find(|f| f.id == "BBB-travel").unwrap();
1353 assert_eq!(parent.children, 1);
1354 assert_eq!(parent.path, "Travel");
1355 assert_eq!(tree[0].path, "Inbox");
1357 }
1358
1359 #[test]
1362 fn folder_resolution_prefers_id_then_path_then_leaf() {
1363 let tree = vec![
1364 GraphFolder {
1365 id: "X".into(),
1366 name: "Travel".into(),
1367 path: "Travel".into(),
1368 unread: 0,
1369 total: 0,
1370 children: 1,
1371 },
1372 GraphFolder {
1373 id: "Y".into(),
1374 name: "Travel".into(),
1375 path: "Archive/Travel".into(),
1376 unread: 0,
1377 total: 0,
1378 children: 0,
1379 },
1380 ];
1381 assert_eq!(resolve_folder_id(&tree, "Y").as_deref(), Some("Y"));
1382 assert_eq!(
1383 resolve_folder_id(&tree, "Archive/Travel").as_deref(),
1384 Some("Y")
1385 );
1386 assert_eq!(resolve_folder_id(&tree, "travel").as_deref(), Some("X"));
1388 }
1389
1390 #[test]
1391 fn messages_parse() {
1392 let v = serde_json::json!({"value": [
1393 {"id": "MSG1", "subject": "Flight confirmation",
1394 "from": {"emailAddress": {"address": "no-reply@air.example"}},
1395 "toRecipients": [{"emailAddress": {"address": "me@x.com"}},
1396 {"emailAddress": {"address": "you@x.com"}}],
1397 "receivedDateTime": "2026-08-01T12:30:00Z",
1398 "isRead": false, "bodyPreview": "Your itinerary",
1399 "body": {"contentType": "html", "content": "<p>hi</p>"}},
1400 {"id": "MSG2", "subject": "", "receivedDateTime": "2026-07-30T08:00:00Z",
1401 "isRead": true}
1402 ]});
1403 let rows = parse_messages(&v, "acct", "Travel", 100);
1404 assert_eq!(rows.len(), 2);
1405 assert_eq!(rows[0].id, "msgraph:MSG1");
1406 assert_eq!(rows[0].mailbox, "Travel");
1407 assert_eq!(rows[0].subject.as_deref(), Some("Flight confirmation"));
1408 assert_eq!(rows[0].sender.as_deref(), Some("no-reply@air.example"));
1409 assert_eq!(rows[0].recipients, vec!["me@x.com", "you@x.com"]);
1410 assert_eq!(
1411 rows[0]
1412 .date_received
1413 .unwrap()
1414 .format("%Y-%m-%dT%H:%M:%SZ")
1415 .to_string(),
1416 "2026-08-01T12:30:00Z"
1417 );
1418 assert!(!rows[0].read);
1419 assert_eq!(rows[0].body.as_deref(), Some("<p>hi</p>"));
1420 assert!(rows[1].subject.is_none());
1422 assert!(rows[1].body.is_none());
1423 assert!(rows[1].read);
1424 assert!(rows[1].recipients.is_empty());
1425 }
1426
1427 #[test]
1428 fn message_body_parses_and_truncates_on_char_boundaries() {
1429 let v = serde_json::json!({"body": {"contentType": "text", "content": "ünïcodé body"}});
1430 let full = parse_message_body(&v, "msgraph:MSG1", 100);
1431 assert_eq!(full.content_type, "text");
1432 assert_eq!(full.body.as_deref(), Some("ünïcodé body"));
1433 assert!(!full.truncated);
1434 assert!(full.availability.available);
1435
1436 let cut = parse_message_body(&v, "msgraph:MSG1", 3);
1438 assert_eq!(cut.body.as_deref(), Some("ünï"));
1439 assert!(cut.truncated);
1440
1441 let html = parse_message_body(
1442 &serde_json::json!({"body": {"contentType": "HTML", "content": "<b>x</b>"}}),
1443 "msgraph:MSG1",
1444 100,
1445 );
1446 assert_eq!(html.content_type, "html");
1447 }
1448
1449 #[test]
1450 fn not_configured_by_default() {
1451 if super::non_empty_env(CLIENT_ID_ENV).is_none() && super::token().is_none() {
1453 assert!(!is_configured());
1454 }
1455 }
1456
1457 fn utc(y: i32, m: u32, d: u32, h: u32, mi: u32) -> DateTime<Utc> {
1460 Utc.with_ymd_and_hms(y, m, d, h, mi, 0).unwrap()
1461 }
1462
1463 #[test]
1464 fn event_create_body_shape() {
1465 let input = EventCreateInput {
1466 calendar_id: "graph".into(),
1467 title: "Standup".into(),
1468 start: utc(2026, 7, 5, 9, 0),
1469 end: utc(2026, 7, 5, 9, 15),
1470 all_day: false,
1471 notes: Some("daily sync".into()),
1472 location: Some("Room 1".into()),
1473 url: Some("https://meet.example/x".into()),
1474 };
1475 let b = event_create_body(&input);
1476 assert_eq!(b["subject"], "Standup");
1477 assert_eq!(b["start"]["dateTime"], "2026-07-05T09:00:00");
1478 assert_eq!(b["start"]["timeZone"], "UTC");
1479 assert_eq!(b["isAllDay"], false);
1480 assert_eq!(b["location"]["displayName"], "Room 1");
1481 assert_eq!(b["body"]["contentType"], "text");
1483 let content = b["body"]["content"].as_str().unwrap();
1484 assert!(content.contains("daily sync") && content.contains("https://meet.example/x"));
1485 }
1486
1487 #[test]
1488 fn event_update_body_only_sets_present_fields() {
1489 let input = EventUpdateInput {
1490 event_id: "e1".into(),
1491 title: Some("Renamed".into()),
1492 start: None,
1493 end: Some(utc(2026, 7, 5, 10, 0)),
1494 all_day: None,
1495 notes: None,
1496 location: None,
1497 url: None,
1498 };
1499 let b = event_update_body(&input);
1500 let obj = b.as_object().unwrap();
1501 assert_eq!(obj["subject"], "Renamed");
1502 assert_eq!(obj["end"]["dateTime"], "2026-07-05T10:00:00");
1503 assert!(!obj.contains_key("start"), "unset fields omitted");
1504 assert!(!obj.contains_key("isAllDay"));
1505 assert!(!obj.contains_key("location"));
1506 assert!(!obj.contains_key("body"));
1507 }
1508
1509 #[test]
1510 fn send_mail_body_shape() {
1511 let req = SendRequest {
1512 account_id: "msgraph".into(),
1513 to: vec!["a@x.com".into(), "b@x.com".into()],
1514 cc: vec!["c@x.com".into()],
1515 bcc: vec![],
1516 subject: "Hi".into(),
1517 body: "Body text".into(),
1518 draft_only: false,
1519 };
1520 let b = send_mail_body(&req);
1521 assert_eq!(b["saveToSentItems"], true);
1522 assert_eq!(b["message"]["subject"], "Hi");
1523 assert_eq!(b["message"]["body"]["content"], "Body text");
1524 let to = b["message"]["toRecipients"].as_array().unwrap();
1525 assert_eq!(to.len(), 2);
1526 assert_eq!(to[0]["emailAddress"]["address"], "a@x.com");
1527 assert_eq!(b["message"]["ccRecipients"].as_array().unwrap().len(), 1);
1528 assert!(b["message"].get("bccRecipients").is_none());
1530 }
1531}