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