Skip to main content

car_integrations/
msgraph.rs

1//! Microsoft Graph backends for the non-Apple platforms (car#520).
2//!
3//! macOS binds Calendar/Contacts/Mail to the OS accounts via EventKit /
4//! Contacts / Mail automation. On Windows (and Linux) this module talks to
5//! **Microsoft Graph** over REST instead, authenticated with the OAuth 2.0
6//! **device-code** flow (no embedded browser, works headless).
7//!
8//! ## Configuration (the one external prerequisite)
9//!
10//! Graph requires an **Azure AD app registration** — a public client with the
11//! delegated scopes `Contacts.Read`, `Calendars.ReadWrite`, `Mail.ReadWrite`,
12//! `Mail.Send`, `offline_access` (read-write since car#531 adds event
13//! create/update/delete and mail send). Supply its client id via
14//! `CAR_MSGRAPH_CLIENT_ID` (and,
15//! optionally, a tenant via `CAR_MSGRAPH_TENANT`, default `common`). A cached
16//! access token may be provided directly via `CAR_MSGRAPH_TOKEN` for headless
17//! use; otherwise [`device_code_login`] performs the interactive flow.
18//!
19//! Everything in this module is a pure REST/JSON mapping — the request builders
20//! and response parsers are unit-tested; only the network hop needs the live
21//! app registration + a signed-in Microsoft account.
22
23use 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";
29// Read-write scopes (car#531): Calendars.ReadWrite and Mail.ReadWrite subsume
30// their .Read counterparts (list_events/list_inbox), and Mail.Send authorizes
31// `POST /me/sendMail`.
32const DEFAULT_SCOPES: &str =
33    "offline_access Contacts.Read Calendars.ReadWrite Mail.ReadWrite Mail.Send User.Read";
34
35/// Env var holding the Azure AD app (client) id.
36pub const CLIENT_ID_ENV: &str = "CAR_MSGRAPH_CLIENT_ID";
37/// Env var overriding the tenant (`common` | `organizations` | `consumers` | a
38/// tenant id). Default `common`.
39pub const TENANT_ENV: &str = "CAR_MSGRAPH_TENANT";
40/// Env var supplying a ready access token (skips the device-code flow).
41pub 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
55/// True when a client id or a direct token is configured — the signal the
56/// per-OS backends use to decide "Graph" vs. "pending".
57pub 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
69// --- OAuth device-code flow (pure builders + parsers; live poll) ------------
70
71/// The device-code initiation endpoint for the configured tenant.
72fn device_code_url() -> String {
73    format!(
74        "https://login.microsoftonline.com/{}/oauth2/v2.0/devicecode",
75        tenant()
76    )
77}
78
79/// The token endpoint for the configured tenant.
80fn token_url() -> String {
81    format!(
82        "https://login.microsoftonline.com/{}/oauth2/v2.0/token",
83        tenant()
84    )
85}
86
87/// Parsed device-code response the user acts on.
88#[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
98/// Parse the `/devicecode` JSON response. Pure — unit-tested.
99fn 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
111/// Outcome of one token poll. Pure — unit-tested.
112enum 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
131/// Run the device-code login: request a code, print instructions, poll until the
132/// user authorizes (or it expires). Returns the access token. Requires
133/// [`CLIENT_ID_ENV`]. `sleep` is injected so the polling loop is deterministic
134/// in tests; production passes `std::thread::sleep`.
135pub 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    // The user-facing instruction: open the URL and enter the code.
150    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
185// --- Live Graph access ------------------------------------------------------
186
187fn 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
194/// Resolve an access token: `CAR_MSGRAPH_TOKEN` if present, else run the
195/// device-code login (which requires `CAR_MSGRAPH_CLIENT_ID`).
196fn 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
203/// GET a Graph path (e.g. `/me/contacts`) and return the parsed JSON body. The
204/// `Prefer: outlook.timezone="UTC"` header makes calendar times come back in
205/// UTC so the parsers can trust them.
206fn 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
229/// Issue a Graph request with an optional JSON body; returns the parsed
230/// response (`None` for an empty 202/204 body). Backs the POST/PATCH/DELETE
231/// mutations (car#531).
232fn 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    // `sendMail` (202) and `delete` (204) return no body.
258    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
267// --- Pure request-body builders (car-integrations input -> Graph JSON) ------
268
269/// A Graph `dateTimeTimeZone` value in UTC.
270fn 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
277/// Fold optional `notes` + `url` into a Graph text `body` (events have no
278/// dedicated URL field).
279fn 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
290/// Build the `POST /me/events` body from an [`EventCreateInput`]. Pure — tested.
291pub(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
307/// Build the `PATCH /me/events/{id}` body — only the set fields. Pure — tested.
308pub(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
333/// Build the `POST /me/sendMail` body from a [`SendRequest`]. Pure — tested.
334pub(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
357// --- Public mutation entry points (car#531) ---------------------------------
358
359fn 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
366/// Create an event (`POST /me/events`) → the created [`Event`].
367pub 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
374/// Update an event (`PATCH /me/events/{id}`) → the updated [`Event`].
375pub 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
383/// Delete an event (`DELETE /me/events/{id}`).
384pub 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
393/// Send (`POST /me/sendMail`) or draft (`POST /me/messages`, `draft_only`) mail.
394/// Returns the draft message id when drafting; `None` when sent (sendMail is a
395/// 202 with no body).
396pub fn send_mail(req: &SendRequest) -> Result<Option<String>, GraphError> {
397    if req.draft_only {
398        let body = send_mail_body(req);
399        // A draft posts the message envelope directly, not wrapped in `message`.
400        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
411// --- Pure parsers (Graph JSON -> car-integrations types) --------------------
412
413/// Parse a Graph `/me/contacts` collection into [`Contact`]s. Pure.
414pub(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
465/// Parse a Graph `dateTimeTimeZone` value (UTC, thanks to the Prefer header).
466fn parse_graph_datetime(v: &serde_json::Value) -> Option<DateTime<Utc>> {
467    let s = v.get("dateTime").and_then(|x| x.as_str())?;
468    // Graph emits e.g. "2026-07-05T09:00:00.0000000" (no offset; UTC via Prefer).
469    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
475/// Parse a Graph `/me/events` collection into [`Event`]s. Pure.
476pub(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
548/// Parse a Graph `/me/mailFolders/inbox` object into an [`InboxSummary`]. Pure.
549pub(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
564// --- Public backend entry points -------------------------------------------
565
566/// Contacts from Graph (`/me/contacts`), optionally filtered by a substring.
567pub 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
580/// Calendar events in `[start, end)` from Graph (`/me/calendarView`).
581pub 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
590/// Inbox unread/total for the signed-in account (`/me/mailFolders/inbox`).
591pub 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        // Guard against a runner that already exports the vars.
685        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    // --- write body builders (car#531) -------------------------------------
693
694    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        // notes + url fold into the text body.
717        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        // No bcc → omitted.
764        assert!(b["message"].get("bccRecipients").is_none());
765    }
766}