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::{
26    InboxSummary, Mailbox, MessageBodyResult, MessageQuery, MessageSummary, SendRequest,
27};
28use chrono::{DateTime, TimeZone, Utc};
29
30const GRAPH_BASE: &str = "https://graph.microsoft.com/v1.0";
31// Read-write scopes (car#531): Calendars.ReadWrite and Mail.ReadWrite subsume
32// their .Read counterparts (list_events/list_inbox), and Mail.Send authorizes
33// `POST /me/sendMail`.
34const DEFAULT_SCOPES: &str =
35    "offline_access Contacts.Read Calendars.ReadWrite Mail.ReadWrite Mail.Send User.Read";
36
37/// Env var holding the Azure AD app (client) id.
38pub const CLIENT_ID_ENV: &str = "CAR_MSGRAPH_CLIENT_ID";
39/// Env var overriding the tenant (`common` | `organizations` | `consumers` | a
40/// tenant id). Default `common`.
41pub const TENANT_ENV: &str = "CAR_MSGRAPH_TENANT";
42/// Env var supplying a ready access token (skips the device-code flow).
43pub 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
57/// True when a client id or a direct token is configured — the signal the
58/// per-OS backends use to decide "Graph" vs. "pending".
59pub 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
67/// The Graph access token — a secret, so it resolves env-first then OS keychain
68/// (`car keys`), unlike the public client-id/tenant which stay env-only.
69fn 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
77// --- OAuth device-code flow (pure builders + parsers; live poll) ------------
78
79/// The device-code initiation endpoint for the configured tenant.
80fn device_code_url() -> String {
81    format!(
82        "https://login.microsoftonline.com/{}/oauth2/v2.0/devicecode",
83        tenant()
84    )
85}
86
87/// The token endpoint for the configured tenant.
88fn token_url() -> String {
89    format!(
90        "https://login.microsoftonline.com/{}/oauth2/v2.0/token",
91        tenant()
92    )
93}
94
95/// Parsed device-code response the user acts on.
96#[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
106/// Parse the `/devicecode` JSON response. Pure — unit-tested.
107fn 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
119/// Outcome of one token poll. Pure — unit-tested.
120enum 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
139/// Run the device-code login: request a code, print instructions, poll until the
140/// user authorizes (or it expires). Returns the access token. Requires
141/// [`CLIENT_ID_ENV`]. `sleep` is injected so the polling loop is deterministic
142/// in tests; production passes `std::thread::sleep`.
143pub 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    // The user-facing instruction: open the URL and enter the code.
158    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
193// --- Live Graph access ------------------------------------------------------
194
195fn 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
202/// Resolve an access token: `CAR_MSGRAPH_TOKEN` if present, else run the
203/// device-code login (which requires `CAR_MSGRAPH_CLIENT_ID`).
204fn 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
211/// GET a Graph path (e.g. `/me/contacts`) and return the parsed JSON body. The
212/// `Prefer: outlook.timezone="UTC"` header makes calendar times come back in
213/// UTC so the parsers can trust them.
214fn 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
237/// Issue a Graph request with an optional JSON body; returns the parsed
238/// response (`None` for an empty 202/204 body). Backs the POST/PATCH/DELETE
239/// mutations (car#531).
240fn 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    // `sendMail` (202) and `delete` (204) return no body.
266    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
275// --- Pure request-body builders (car-integrations input -> Graph JSON) ------
276
277/// A Graph `dateTimeTimeZone` value in UTC.
278fn 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
285/// Fold optional `notes` + `url` into a Graph text `body` (events have no
286/// dedicated URL field).
287fn 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
298/// Build the `POST /me/events` body from an [`EventCreateInput`]. Pure — tested.
299pub(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
315/// Build the `PATCH /me/events/{id}` body — only the set fields. Pure — tested.
316pub(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
341/// Build the `POST /me/sendMail` body from a [`SendRequest`]. Pure — tested.
342pub(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
365// --- Public mutation entry points (car#531) ---------------------------------
366
367fn 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
374/// Create an event (`POST /me/events`) → the created [`Event`].
375pub 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
382/// Update an event (`PATCH /me/events/{id}`) → the updated [`Event`].
383pub 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
391/// Delete an event (`DELETE /me/events/{id}`).
392pub 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
401/// Send (`POST /me/sendMail`) or draft (`POST /me/messages`, `draft_only`) mail.
402/// Returns the draft message id when drafting; `None` when sent (sendMail is a
403/// 202 with no body).
404pub fn send_mail(req: &SendRequest) -> Result<Option<String>, GraphError> {
405    if req.draft_only {
406        let body = send_mail_body(req);
407        // A draft posts the message envelope directly, not wrapped in `message`.
408        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
419// --- Pure parsers (Graph JSON -> car-integrations types) --------------------
420
421/// Parse a Graph `/me/contacts` collection into [`Contact`]s. Pure.
422pub(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
473/// Parse a Graph `dateTimeTimeZone` value (UTC, thanks to the Prefer header).
474fn parse_graph_datetime(v: &serde_json::Value) -> Option<DateTime<Utc>> {
475    let s = v.get("dateTime").and_then(|x| x.as_str())?;
476    // Graph emits e.g. "2026-07-05T09:00:00.0000000" (no offset; UTC via Prefer).
477    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
483/// Parse a Graph `/me/events` collection into [`Event`]s. Pure.
484pub(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
556/// Parse a Graph `/me/mailFolders/inbox` object into an [`InboxSummary`]. Pure.
557pub(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/// One node of the Graph mail-folder tree.
573///
574/// This type exists because `GET /me/mailFolders` returns **only** the folders
575/// directly under the mailbox root. Graph does not recurse and offers no
576/// `$expand` that walks arbitrary depth, so a "Travel" folder the user filed
577/// under a parent is invisible to a single call — and `mail.messages
578/// --mailbox Travel` then answered "no mail folder named Travel" for exactly
579/// the user Parslee-ai/car-releases#84 is about. Same blind spot as the
580/// INBOX-only read this surface replaced, one level down.
581///
582/// [`path`](Self::path) is the slash-joined display path from the mailbox
583/// root, the same shape the macOS backend's `full_name` carries, so one
584/// selector (`"Travel/2026"`, or the bare leaf `"2026"`) resolves on both
585/// backends.
586#[derive(Debug, Clone, PartialEq)]
587pub(crate) struct GraphFolder {
588    pub id: String,
589    /// Leaf display name.
590    pub name: String,
591    /// Slash-joined display path from the mailbox root.
592    pub path: String,
593    pub unread: u32,
594    pub total: u32,
595    /// Graph's `childFolderCount` — whether this subtree still needs a fetch.
596    pub children: u32,
597}
598
599/// Parse one page of a `/me/mailFolders` (or `.../childFolders`) response,
600/// prefixing each display path with `prefix`. Pure.
601pub(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
640/// Project a flattened folder tree onto the wire [`Mailbox`] rows.
641///
642/// `full_name` carries the folder **id**, not the display name: it is the
643/// selector a caller hands back as `MessageQuery::mailbox`, and on Graph the
644/// id is what addresses the folder unambiguously. `name` stays the human leaf
645/// label. Nesting is already flattened by [`folder_tree`] before it gets here
646/// — see [`GraphFolder`] for why a single `/me/mailFolders` call is not
647/// enough. Pure.
648pub(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
661/// Resolve a mailbox selector against a flattened folder tree: exact id
662/// first, then a case-insensitive full-path match, then a case-insensitive
663/// leaf-name match. Pure.
664///
665/// The order mirrors the macOS `resolveMailbox` exactly, so `"Travel/2026"`
666/// and the bare `"2026"` both reach the same folder on both backends — a
667/// caller should not have to know that one backend addresses folders by path
668/// and the other by id.
669pub(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
682/// Parse a Graph *instant* — a bare RFC3339 string such as
683/// `receivedDateTime`. Distinct from [`parse_graph_datetime`], which unwraps
684/// the `dateTimeTimeZone` object the calendar surface uses; feeding one to the
685/// other silently yields `None`.
686fn 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
700/// Parse `/me/mailFolders/{id}/messages` into [`MessageSummary`] rows. Pure.
701pub(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
747/// Cut a body at `cap` **characters** (not bytes — a mid-codepoint slice would
748/// panic), reporting whether anything was dropped.
749fn 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
756/// Parse `/me/messages/{id}?$select=body` into a [`MessageBodyResult`]. Pure.
757pub(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
778// --- Public backend entry points -------------------------------------------
779
780/// Contacts from Graph (`/me/contacts`), optionally filtered by a substring.
781pub 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
794/// Calendar events in `[start, end)` from Graph (`/me/calendarView`).
795pub 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
804/// Inbox unread/total for the signed-in account (`/me/mailFolders/inbox`).
805pub 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
812/// How deep the child-folder walk goes. Matches the macOS `walkMailboxes`
813/// depth so the two backends see the same tree.
814const FOLDER_WALK_MAX_DEPTH: usize = 8;
815
816/// Ceiling on the total number of Graph requests one folder enumeration may
817/// issue. A mailbox with hundreds of folders would otherwise turn a single
818/// `mail.mailboxes` call into hundreds of round trips.
819const FOLDER_WALK_MAX_REQUESTS: usize = 64;
820
821/// How many folders one page asks for, and how many pages of one listing are
822/// followed via `@odata.nextLink`.
823const FOLDER_PAGE_TOP: usize = 200;
824const FOLDER_MAX_PAGES: usize = 5;
825
826/// GET a Graph collection, following `@odata.nextLink` up to `max_pages`.
827/// Returns each page's parsed body. `nextLink` is an absolute URL, so the
828/// shared base is stripped back off before it is handed to [`graph_get`].
829fn 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
852/// Every mail folder of the signed-in account, **including nested ones**.
853///
854/// Graph's `/me/mailFolders` is root-only, so this is a bounded breadth-first
855/// walk of `/me/mailFolders/{id}/childFolders` — see [`GraphFolder`] for why
856/// a single call is not enough. Bounded by [`FOLDER_WALK_MAX_DEPTH`] and
857/// [`FOLDER_WALK_MAX_REQUESTS`]: a pathological folder tree costs a fixed
858/// number of round trips, not an unbounded one.
859fn 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                // One unreadable subtree (a shared folder the token cannot
881                // reach) must not sink the whole enumeration — the rest of the
882                // tree is still a better answer than an error.
883                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
900/// Every mail folder of the signed-in account (`/me/mailFolders` plus a
901/// bounded `childFolders` walk), nested ones included.
902pub fn mail_folders(account_id: &str) -> Result<Vec<Mailbox>, GraphError> {
903    Ok(folders_to_mailboxes(folder_tree()?, account_id))
904}
905
906/// Message rows from one folder (`/me/mailFolders/{id}/messages`).
907///
908/// `$select` is deliberately narrow: without it Graph returns the full body on
909/// every row, which is the difference between a header listing and a bulk
910/// download. `body` joins the selection only when the caller asked.
911pub 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    // `inbox` is a Graph well-known folder name, so the default costs no
917    // extra round trip; anything else is resolved against the folder list so
918    // that a display name ("Travel") works as a selector too.
919    let folder = if wanted.eq_ignore_ascii_case(crate::mail::DEFAULT_MAILBOX) {
920        "inbox".to_string()
921    } else {
922        // Say "that folder is not here" rather than querying a bogus id and
923        // returning an empty list — an unresolvable mailbox reading as "no
924        // messages" is the silent failure this surface exists to end.
925        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
953/// One message's body (`/me/messages/{id}?$select=body`).
954pub 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// --- OneNote (notes) + Microsoft To Do (reminders) -------------------------
964// The Windows/Linux backends for the `notes.*` and `reminders.*` surfaces,
965// which are Notes.app / Reminders.app (macOS-only) natively. Graph gives the
966// signed-in M365 account's OneNote + To Do instead. Lightweight structs (not
967// the `#[non_exhaustive]` apple.rs types) so the apple.rs backend does the
968// mapping in its own module.
969
970/// A named Graph object with an id — a OneNote notebook or a To Do list.
971#[derive(Debug, Clone, PartialEq)]
972pub struct GraphNamed {
973    pub id: String,
974    pub name: String,
975}
976
977/// A OneNote page summary.
978#[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/// A Microsoft To Do task.
987#[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
1080/// OneNote notebooks (`/me/onenote/notebooks`) — the "accounts" for notes.
1081pub fn onenote_notebooks() -> Result<Vec<GraphNamed>, GraphError> {
1082    Ok(parse_named(&graph_get("/me/onenote/notebooks")?))
1083}
1084
1085/// OneNote pages (`/me/onenote/pages`), newest first, optionally filtered by a
1086/// case-insensitive title substring (client-side, mirroring `contacts`).
1087pub fn onenote_pages(query: &str, limit: usize) -> Result<Vec<GraphNote>, GraphError> {
1088    let top = limit.clamp(1, 100);
1089    // `$expand=parentNotebook` so each page carries its notebook name; newest first.
1090    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
1101/// Microsoft To Do lists (`/me/todo/lists`).
1102pub fn todo_lists() -> Result<Vec<GraphNamed>, GraphError> {
1103    Ok(parse_named(&graph_get("/me/todo/lists")?))
1104}
1105
1106/// Microsoft To Do tasks across all lists (`/me/todo/lists/{id}/tasks`), up to
1107/// `limit` (0 = no cap). Tasks are per-list in Graph, so this fans out; one
1108/// list failing (transient error) drops only that list, not the whole result.
1109pub 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, ""); // missing displayName -> empty, still listed
1181    }
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": ""} // empty title -> "Untitled"
1190        ]});
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    // --- mail read surface (car-releases#84) --------------------------------
1264
1265    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    /// The root page plus the one `childFolders` page `folder_tree` would
1276    /// fetch for it, flattened the way `folder_tree` flattens them. Stands in
1277    /// for the network so the resolution logic is testable.
1278    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        // `full_name` is the selector, which on Graph is the folder id.
1297        assert_eq!(boxes[1].full_name, "BBB-travel");
1298        assert_eq!(boxes[1].unread, 1);
1299        assert_eq!(boxes[1].total, 9);
1300        // A folder with no displayName falls back to its id rather than "".
1301        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        // No `childFolderCount` in the payload means "no children to walk".
1310        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        // The macOS-shaped selector "Travel" must reach the same folder.
1321        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    // Graph's `/me/mailFolders` is root-only. Before the child walk, a user
1329    // whose Travel folder lives under a parent got the hard error "no mail
1330    // folder named 2026" — the issue's own scenario, unfixed off macOS
1331    // (Parslee-ai/car-releases#84).
1332    #[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        // Full path, bare leaf, and raw id all reach it — the same three arms,
1342        // in the same order, as the macOS `resolveMailbox`.
1343        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        // A parent whose child was walked still advertises the walk is needed.
1352        let parent = tree.iter().find(|f| f.id == "BBB-travel").unwrap();
1353        assert_eq!(parent.children, 1);
1354        assert_eq!(parent.path, "Travel");
1355        // A root folder's path is just its name — no leading separator.
1356        assert_eq!(tree[0].path, "Inbox");
1357    }
1358
1359    // An exact id must win over a display name that happens to equal another
1360    // folder's id, and a leaf match must not beat an exact full-path match.
1361    #[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        // Ambiguous leaf: first match wins, same as the macOS walk order.
1387        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        // An empty subject is None, not Some(""); a row with no body stays None.
1421        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        // Slicing by bytes here would panic mid-codepoint.
1437        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        // Guard against a runner that already exports the vars.
1452        if super::non_empty_env(CLIENT_ID_ENV).is_none() && super::token().is_none() {
1453            assert!(!is_configured());
1454        }
1455    }
1456
1457    // --- write body builders (car#531) -------------------------------------
1458
1459    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        // notes + url fold into the text body.
1482        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        // No bcc → omitted.
1529        assert!(b["message"].get("bccRecipients").is_none());
1530    }
1531}