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// --- OneNote (notes) + Microsoft To Do (reminders) -------------------------
605// The Windows/Linux backends for the `notes.*` and `reminders.*` surfaces,
606// which are Notes.app / Reminders.app (macOS-only) natively. Graph gives the
607// signed-in M365 account's OneNote + To Do instead. Lightweight structs (not
608// the `#[non_exhaustive]` apple.rs types) so the apple.rs backend does the
609// mapping in its own module.
610
611/// A named Graph object with an id — a OneNote notebook or a To Do list.
612#[derive(Debug, Clone, PartialEq)]
613pub struct GraphNamed {
614    pub id: String,
615    pub name: String,
616}
617
618/// A OneNote page summary.
619#[derive(Debug, Clone, PartialEq)]
620pub struct GraphNote {
621    pub id: String,
622    pub title: String,
623    pub notebook: Option<String>,
624    pub modified: Option<String>,
625}
626
627/// A Microsoft To Do task.
628#[derive(Debug, Clone, PartialEq)]
629pub struct GraphTask {
630    pub id: String,
631    pub title: String,
632    pub list: Option<String>,
633    pub due: Option<String>,
634    pub completed: bool,
635}
636
637fn parse_named(v: &serde_json::Value) -> Vec<GraphNamed> {
638    v.get("value")
639        .and_then(|x| x.as_array())
640        .map(|arr| {
641            arr.iter()
642                .filter_map(|n| {
643                    let id = n.get("id")?.as_str()?.to_string();
644                    let name = n
645                        .get("displayName")
646                        .and_then(|x| x.as_str())
647                        .unwrap_or("")
648                        .to_string();
649                    Some(GraphNamed { id, name })
650                })
651                .collect()
652        })
653        .unwrap_or_default()
654}
655
656fn parse_notes(v: &serde_json::Value) -> Vec<GraphNote> {
657    v.get("value")
658        .and_then(|x| x.as_array())
659        .map(|arr| {
660            arr.iter()
661                .filter_map(|p| {
662                    let id = p.get("id")?.as_str()?.to_string();
663                    let title = p
664                        .get("title")
665                        .and_then(|x| x.as_str())
666                        .filter(|s| !s.is_empty())
667                        .unwrap_or("Untitled")
668                        .to_string();
669                    let notebook = p
670                        .get("parentNotebook")
671                        .and_then(|nb| nb.get("displayName"))
672                        .and_then(|x| x.as_str())
673                        .map(String::from);
674                    let modified = p
675                        .get("lastModifiedDateTime")
676                        .and_then(|x| x.as_str())
677                        .map(String::from);
678                    Some(GraphNote {
679                        id,
680                        title,
681                        notebook,
682                        modified,
683                    })
684                })
685                .collect()
686        })
687        .unwrap_or_default()
688}
689
690fn parse_tasks(v: &serde_json::Value, list: &str) -> Vec<GraphTask> {
691    v.get("value")
692        .and_then(|x| x.as_array())
693        .map(|arr| {
694            arr.iter()
695                .filter_map(|t| {
696                    let id = t.get("id")?.as_str()?.to_string();
697                    let title = t
698                        .get("title")
699                        .and_then(|x| x.as_str())
700                        .unwrap_or("")
701                        .to_string();
702                    let completed = t.get("status").and_then(|x| x.as_str()) == Some("completed");
703                    let due = t
704                        .get("dueDateTime")
705                        .and_then(|d| d.get("dateTime"))
706                        .and_then(|x| x.as_str())
707                        .map(String::from);
708                    Some(GraphTask {
709                        id,
710                        title,
711                        list: Some(list.to_string()),
712                        due,
713                        completed,
714                    })
715                })
716                .collect()
717        })
718        .unwrap_or_default()
719}
720
721/// OneNote notebooks (`/me/onenote/notebooks`) — the "accounts" for notes.
722pub fn onenote_notebooks() -> Result<Vec<GraphNamed>, GraphError> {
723    Ok(parse_named(&graph_get("/me/onenote/notebooks")?))
724}
725
726/// OneNote pages (`/me/onenote/pages`), newest first, optionally filtered by a
727/// case-insensitive title substring (client-side, mirroring `contacts`).
728pub fn onenote_pages(query: &str, limit: usize) -> Result<Vec<GraphNote>, GraphError> {
729    let top = limit.clamp(1, 100);
730    // `$expand=parentNotebook` so each page carries its notebook name; newest first.
731    let path = format!(
732        "/me/onenote/pages?$top={top}&$orderby=lastModifiedDateTime%20desc&$expand=parentNotebook"
733    );
734    let mut list = parse_notes(&graph_get(&path)?);
735    if !query.is_empty() {
736        let q = query.to_lowercase();
737        list.retain(|n| n.title.to_lowercase().contains(&q));
738    }
739    Ok(list)
740}
741
742/// Microsoft To Do lists (`/me/todo/lists`).
743pub fn todo_lists() -> Result<Vec<GraphNamed>, GraphError> {
744    Ok(parse_named(&graph_get("/me/todo/lists")?))
745}
746
747/// Microsoft To Do tasks across all lists (`/me/todo/lists/{id}/tasks`), up to
748/// `limit` (0 = no cap). Tasks are per-list in Graph, so this fans out; one
749/// list failing (transient error) drops only that list, not the whole result.
750pub fn todo_tasks(limit: usize) -> Result<Vec<GraphTask>, GraphError> {
751    let cap = if limit == 0 { usize::MAX } else { limit };
752    let lists = todo_lists()?;
753    let mut out = Vec::new();
754    for list in &lists {
755        if out.len() >= cap {
756            break;
757        }
758        let top = (cap - out.len()).clamp(1, 100);
759        let path = format!("/me/todo/lists/{}/tasks?$top={top}", list.id);
760        if let Ok(v) = graph_get(&path) {
761            out.extend(parse_tasks(&v, &list.name));
762        }
763    }
764    out.truncate(cap);
765    Ok(out)
766}
767
768#[cfg(test)]
769mod tests {
770    use super::*;
771
772    #[test]
773    fn device_code_parse() {
774        let v = serde_json::json!({
775            "device_code": "DEV", "user_code": "ABC-123",
776            "verification_uri": "https://microsoft.com/devicelogin",
777            "message": "go here", "interval": 5, "expires_in": 900
778        });
779        let dc = parse_device_code(&v).unwrap();
780        assert_eq!(dc.device_code, "DEV");
781        assert_eq!(dc.user_code, "ABC-123");
782        assert_eq!(dc.interval_secs, 5);
783    }
784
785    #[test]
786    fn token_poll_states() {
787        assert!(matches!(
788            parse_token_poll(&serde_json::json!({"access_token": "T"})),
789            TokenPoll::Token(_)
790        ));
791        assert!(matches!(
792            parse_token_poll(&serde_json::json!({"error": "authorization_pending"})),
793            TokenPoll::Pending
794        ));
795        assert!(matches!(
796            parse_token_poll(&serde_json::json!({"error": "slow_down"})),
797            TokenPoll::Slow
798        ));
799        assert!(matches!(
800            parse_token_poll(&serde_json::json!({"error": "expired_token"})),
801            TokenPoll::Error(_)
802        ));
803    }
804
805    #[test]
806    fn named_parse_notebooks_and_lists() {
807        let v = serde_json::json!({"value": [
808            {"id": "nb1", "displayName": "Work"},
809            {"id": "nb2", "displayName": "Personal"},
810            {"id": "no-name"}
811        ]});
812        let named = parse_named(&v);
813        assert_eq!(named.len(), 3);
814        assert_eq!(
815            named[0],
816            GraphNamed {
817                id: "nb1".into(),
818                name: "Work".into()
819            }
820        );
821        assert_eq!(named[2].name, ""); // missing displayName -> empty, still listed
822    }
823
824    #[test]
825    fn notes_parse_title_notebook_modified() {
826        let v = serde_json::json!({"value": [
827            {"id": "p1", "title": "Roadmap",
828             "lastModifiedDateTime": "2026-08-01T10:00:00Z",
829             "parentNotebook": {"displayName": "Work"}},
830            {"id": "p2", "title": ""} // empty title -> "Untitled"
831        ]});
832        let notes = parse_notes(&v);
833        assert_eq!(notes.len(), 2);
834        assert_eq!(notes[0].title, "Roadmap");
835        assert_eq!(notes[0].notebook.as_deref(), Some("Work"));
836        assert_eq!(notes[0].modified.as_deref(), Some("2026-08-01T10:00:00Z"));
837        assert_eq!(notes[1].title, "Untitled");
838        assert!(notes[1].notebook.is_none());
839    }
840
841    #[test]
842    fn tasks_parse_status_and_due() {
843        let v = serde_json::json!({"value": [
844            {"id": "t1", "title": "Ship it", "status": "notStarted",
845             "dueDateTime": {"dateTime": "2026-08-05T17:00:00.0000000", "timeZone": "UTC"}},
846            {"id": "t2", "title": "Done thing", "status": "completed"}
847        ]});
848        let tasks = parse_tasks(&v, "Tasks");
849        assert_eq!(tasks.len(), 2);
850        assert_eq!(tasks[0].list.as_deref(), Some("Tasks"));
851        assert!(!tasks[0].completed);
852        assert_eq!(tasks[0].due.as_deref(), Some("2026-08-05T17:00:00.0000000"));
853        assert!(tasks[1].completed);
854        assert!(tasks[1].due.is_none());
855    }
856
857    #[test]
858    fn contacts_parse() {
859        let v = serde_json::json!({"value": [{
860            "id": "1", "displayName": "Ada Lovelace",
861            "emailAddresses": [{"address": "ada@example.com"}],
862            "businessPhones": ["+1 555 0100"], "mobilePhone": "+1 555 0199",
863            "companyName": "Analytical Engines"
864        }]});
865        let cs = parse_contacts(&v);
866        assert_eq!(cs.len(), 1);
867        assert_eq!(cs[0].display_name, "Ada Lovelace");
868        assert_eq!(cs[0].emails, vec!["ada@example.com"]);
869        assert_eq!(cs[0].phone_numbers.len(), 2);
870        assert_eq!(cs[0].organization.as_deref(), Some("Analytical Engines"));
871    }
872
873    #[test]
874    fn events_parse_utc() {
875        let v = serde_json::json!({"value": [{
876            "id": "e1", "subject": "Standup",
877            "start": {"dateTime": "2026-07-05T09:00:00.0000000", "timeZone": "UTC"},
878            "end": {"dateTime": "2026-07-05T09:15:00.0000000", "timeZone": "UTC"},
879            "location": {"displayName": "Room 1"}, "isAllDay": false,
880            "attendees": [{"emailAddress": {"name": "Bob", "address": "bob@x.com"}, "type": "required"}]
881        }]});
882        let es = parse_events(&v, "cal");
883        assert_eq!(es.len(), 1);
884        assert_eq!(es[0].title, "Standup");
885        assert_eq!(
886            es[0].start.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
887            "2026-07-05T09:00:00Z"
888        );
889        assert_eq!(es[0].location.as_deref(), Some("Room 1"));
890        assert_eq!(es[0].attendees.len(), 1);
891        assert_eq!(es[0].attendees[0].email.as_deref(), Some("bob@x.com"));
892    }
893
894    #[test]
895    fn inbox_parse() {
896        let v =
897            serde_json::json!({"displayName": "Inbox", "unreadItemCount": 3, "totalItemCount": 42});
898        let s = parse_inbox_summary(&v, "acct");
899        assert_eq!(s.unread, 3);
900        assert_eq!(s.total, 42);
901        assert_eq!(s.account_id, "acct");
902    }
903
904    #[test]
905    fn not_configured_by_default() {
906        // Guard against a runner that already exports the vars.
907        if super::non_empty_env(CLIENT_ID_ENV).is_none() && super::token().is_none() {
908            assert!(!is_configured());
909        }
910    }
911
912    // --- write body builders (car#531) -------------------------------------
913
914    fn utc(y: i32, m: u32, d: u32, h: u32, mi: u32) -> DateTime<Utc> {
915        Utc.with_ymd_and_hms(y, m, d, h, mi, 0).unwrap()
916    }
917
918    #[test]
919    fn event_create_body_shape() {
920        let input = EventCreateInput {
921            calendar_id: "graph".into(),
922            title: "Standup".into(),
923            start: utc(2026, 7, 5, 9, 0),
924            end: utc(2026, 7, 5, 9, 15),
925            all_day: false,
926            notes: Some("daily sync".into()),
927            location: Some("Room 1".into()),
928            url: Some("https://meet.example/x".into()),
929        };
930        let b = event_create_body(&input);
931        assert_eq!(b["subject"], "Standup");
932        assert_eq!(b["start"]["dateTime"], "2026-07-05T09:00:00");
933        assert_eq!(b["start"]["timeZone"], "UTC");
934        assert_eq!(b["isAllDay"], false);
935        assert_eq!(b["location"]["displayName"], "Room 1");
936        // notes + url fold into the text body.
937        assert_eq!(b["body"]["contentType"], "text");
938        let content = b["body"]["content"].as_str().unwrap();
939        assert!(content.contains("daily sync") && content.contains("https://meet.example/x"));
940    }
941
942    #[test]
943    fn event_update_body_only_sets_present_fields() {
944        let input = EventUpdateInput {
945            event_id: "e1".into(),
946            title: Some("Renamed".into()),
947            start: None,
948            end: Some(utc(2026, 7, 5, 10, 0)),
949            all_day: None,
950            notes: None,
951            location: None,
952            url: None,
953        };
954        let b = event_update_body(&input);
955        let obj = b.as_object().unwrap();
956        assert_eq!(obj["subject"], "Renamed");
957        assert_eq!(obj["end"]["dateTime"], "2026-07-05T10:00:00");
958        assert!(!obj.contains_key("start"), "unset fields omitted");
959        assert!(!obj.contains_key("isAllDay"));
960        assert!(!obj.contains_key("location"));
961        assert!(!obj.contains_key("body"));
962    }
963
964    #[test]
965    fn send_mail_body_shape() {
966        let req = SendRequest {
967            account_id: "msgraph".into(),
968            to: vec!["a@x.com".into(), "b@x.com".into()],
969            cc: vec!["c@x.com".into()],
970            bcc: vec![],
971            subject: "Hi".into(),
972            body: "Body text".into(),
973            draft_only: false,
974        };
975        let b = send_mail_body(&req);
976        assert_eq!(b["saveToSentItems"], true);
977        assert_eq!(b["message"]["subject"], "Hi");
978        assert_eq!(b["message"]["body"]["content"], "Body text");
979        let to = b["message"]["toRecipients"].as_array().unwrap();
980        assert_eq!(to.len(), 2);
981        assert_eq!(to[0]["emailAddress"]["address"], "a@x.com");
982        assert_eq!(b["message"]["ccRecipients"].as_array().unwrap().len(), 1);
983        // No bcc → omitted.
984        assert!(b["message"].get("bccRecipients").is_none());
985    }
986}