Skip to main content

pidge_client/graph/
mail.rs

1//! GET /me/mailFolders/inbox/messages — list inbox messages.
2
3use pidge_core::{FlagStatus, Message, MessageFrom};
4use serde::Deserialize;
5
6use crate::error::ClientError;
7
8#[derive(Debug, Deserialize)]
9struct GraphMessage {
10    id: String,
11    subject: Option<String>,
12    from: Option<GraphFromWrapper>,
13    #[serde(rename = "receivedDateTime")]
14    received_date_time: chrono::DateTime<chrono::Utc>,
15    #[serde(rename = "isRead")]
16    is_read: Option<bool>,
17    #[serde(rename = "bodyPreview")]
18    body_preview: Option<String>,
19    /// Full body, requested with `Prefer: outlook.body-content-type="text"`
20    /// so Graph converts HTML to plain text server-side. Used to build a
21    /// richer preview than the 255-char `bodyPreview` cap allows.
22    body: Option<GraphBody>,
23    #[serde(rename = "hasAttachments")]
24    has_attachments: Option<bool>,
25    #[serde(rename = "conversationId", default)]
26    conversation_id: Option<String>,
27    #[serde(default)]
28    flag: Option<GraphFlag>,
29}
30
31#[derive(Debug, Deserialize)]
32struct GraphFlag {
33    #[serde(rename = "flagStatus", default)]
34    flag_status: Option<String>,
35}
36
37fn flag_status_from(g: Option<GraphFlag>) -> FlagStatus {
38    match g.and_then(|f| f.flag_status).as_deref() {
39        Some("flagged") => FlagStatus::Flagged,
40        Some("complete") => FlagStatus::Complete,
41        _ => FlagStatus::NotFlagged,
42    }
43}
44
45#[derive(Debug, Deserialize)]
46struct GraphFromWrapper {
47    #[serde(rename = "emailAddress")]
48    email_address: GraphFromAddress,
49}
50
51#[derive(Debug, Deserialize)]
52struct GraphFromAddress {
53    name: Option<String>,
54    address: Option<String>,
55}
56
57#[derive(Debug, Deserialize)]
58struct GraphList {
59    value: Vec<GraphMessage>,
60    /// Graph returns this when there are more pages. We expose it so the CLI
61    /// can decide whether to keep paging.
62    #[serde(rename = "@odata.nextLink", default)]
63    next_link: Option<String>,
64}
65
66/// One page of inbox messages plus a flag indicating whether more pages exist.
67pub struct InboxPage {
68    pub messages: Vec<Message>,
69    pub has_more: bool,
70    /// Graph continuation URL for the next page (`@odata.nextLink`).
71    pub next_link: Option<String>,
72}
73
74#[derive(Debug, Deserialize)]
75struct GraphFullMessage {
76    id: String,
77    #[serde(rename = "conversationId", default)]
78    conversation_id: Option<String>,
79    subject: Option<String>,
80    from: Option<GraphFromWrapper>,
81    #[serde(rename = "toRecipients", default)]
82    to_recipients: Vec<GraphFromWrapper>,
83    #[serde(rename = "ccRecipients", default)]
84    cc_recipients: Vec<GraphFromWrapper>,
85    #[serde(rename = "bccRecipients", default)]
86    bcc_recipients: Vec<GraphFromWrapper>,
87    #[serde(rename = "receivedDateTime")]
88    received_date_time: chrono::DateTime<chrono::Utc>,
89    #[serde(rename = "sentDateTime")]
90    sent_date_time: chrono::DateTime<chrono::Utc>,
91    #[serde(rename = "isRead")]
92    is_read: Option<bool>,
93    body: GraphBody,
94    #[serde(rename = "hasAttachments")]
95    has_attachments: Option<bool>,
96    #[serde(default)]
97    flag: Option<GraphFlag>,
98}
99
100#[derive(Debug, Deserialize)]
101struct GraphBody {
102    #[serde(rename = "contentType")]
103    content_type: String,
104    content: String,
105}
106
107#[derive(Debug, Deserialize)]
108struct GraphAttachmentList {
109    value: Vec<GraphAttachment>,
110}
111
112#[derive(Debug, Deserialize)]
113struct GraphAttachment {
114    id: String,
115    name: Option<String>,
116    #[serde(rename = "contentType")]
117    content_type: Option<String>,
118    size: Option<u64>,
119    #[serde(rename = "isInline")]
120    is_inline: Option<bool>,
121    #[serde(rename = "contentId")]
122    content_id: Option<String>,
123    #[serde(rename = "@odata.type", default)]
124    odata_type: Option<String>,
125    /// Only populated when fetching a single attachment (not in list endpoint).
126    #[serde(rename = "contentBytes", default)]
127    content_bytes: Option<String>,
128}
129
130/// List a page of messages from the Inbox folder, sorted by `receivedDateTime desc`.
131///
132/// `skip` is the offset into the result set (page * page_size for 0-based paging).
133/// Returns an `InboxPage` whose `has_more` is true when Graph included an
134/// `@odata.nextLink` — i.e., there are more messages beyond this page.
135pub async fn list_inbox(
136    http: &reqwest::Client,
137    base_url: &str,
138    access_token: &str,
139    account: &str,
140    limit: usize,
141    skip: usize,
142    unread_only: bool,
143) -> Result<InboxPage, ClientError> {
144    list_folder(
145        http,
146        base_url,
147        access_token,
148        account,
149        "inbox",
150        limit,
151        skip,
152        unread_only,
153    )
154    .await
155}
156
157/// List a page of messages from an arbitrary folder, identified by its Graph
158/// folder ID (or a well-known name). Same shape as `list_inbox`; used by
159/// `mail list --folder` to page through custom folders.
160#[allow(clippy::too_many_arguments)]
161pub async fn list_folder_messages(
162    http: &reqwest::Client,
163    base_url: &str,
164    access_token: &str,
165    account: &str,
166    folder_id: &str,
167    limit: usize,
168    skip: usize,
169    unread_only: bool,
170) -> Result<InboxPage, ClientError> {
171    list_folder(
172        http,
173        base_url,
174        access_token,
175        account,
176        folder_id,
177        limit,
178        skip,
179        unread_only,
180    )
181    .await
182}
183
184/// List a page of drafts from the Drafts folder. Drafts don't have a real
185/// "received" time, so Graph sorts by `lastModifiedDateTime desc` here.
186pub async fn list_drafts(
187    http: &reqwest::Client,
188    base_url: &str,
189    access_token: &str,
190    account: &str,
191    limit: usize,
192    skip: usize,
193) -> Result<InboxPage, ClientError> {
194    list_folder(
195        http,
196        base_url,
197        access_token,
198        account,
199        "drafts",
200        limit,
201        skip,
202        false,
203    )
204    .await
205}
206
207#[allow(clippy::too_many_arguments)]
208async fn list_folder(
209    http: &reqwest::Client,
210    base_url: &str,
211    access_token: &str,
212    account: &str,
213    folder: &str,
214    limit: usize,
215    skip: usize,
216    unread_only: bool,
217) -> Result<InboxPage, ClientError> {
218    let url = format!("{base_url}/me/mailFolders/{folder}/messages");
219    // Body is fetched in its native content type (HTML or text) so the
220    // renderer can use html2text to extract anchor text + click targets —
221    // making LINK TEXT (not URLs) the clickable surface in previews.
222    let mut req = http.get(&url).bearer_auth(access_token).query(&[
223        (
224            "$select",
225            "id,subject,from,receivedDateTime,isRead,bodyPreview,body,hasAttachments,flag,conversationId",
226        ),
227        ("$orderby", "receivedDateTime desc"),
228        ("$top", &limit.to_string()),
229    ]);
230    if skip > 0 {
231        req = req.query(&[("$skip", &skip.to_string())]);
232    }
233    if unread_only {
234        req = req.query(&[("$filter", "isRead eq false")]);
235    }
236
237    let resp = super::send_with_retry(req).await?;
238    let status = resp.status();
239    if !status.is_success() {
240        let text = resp.text().await.unwrap_or_default();
241        return Err(ClientError::Graph {
242            status: status.as_u16(),
243            message: text,
244        });
245    }
246
247    let list: GraphList = resp.json().await?;
248    Ok(InboxPage {
249        has_more: list.next_link.is_some(),
250        next_link: list.next_link,
251        messages: list
252            .value
253            .into_iter()
254            .map(|g| to_message(g, account))
255            .collect(),
256    })
257}
258
259/// Search messages across all folders using Graph's `$search` KQL query.
260///
261/// `$search` doesn't combine with `$filter` or `$orderby` — results come back
262/// in Graph's relevance ranking, not date order. Common query forms users can
263/// pass:
264///
265/// - `alice budget`          → matches anywhere in subject/body/sender
266/// - `from:alice@example.com`
267/// - `subject:"q4 review"`
268/// - `from:alice AND subject:budget`
269///
270/// Parse one raw delta item into a [`Message`] (None if the shape is not a
271/// message — e.g. a tombstone or a partial patch without required fields).
272pub(crate) fn message_from_delta_value(
273    value: serde_json::Value,
274    account: &str,
275) -> Option<pidge_core::Message> {
276    let g: GraphMessage = serde_json::from_value(value).ok()?;
277    Some(to_message(g, account))
278}
279
280/// Fetch every message in a conversation (thread), oldest first.
281pub async fn list_conversation(
282    http: &reqwest::Client,
283    base_url: &str,
284    access_token: &str,
285    account: &str,
286    conversation_id: &str,
287) -> Result<Vec<pidge_core::Message>, ClientError> {
288    let url = format!("{base_url}/me/messages");
289    let filter = format!(
290        "conversationId eq '{}'",
291        conversation_id.replace('\'', "''")
292    );
293    let req = http
294        .get(&url)
295        .bearer_auth(access_token)
296        .header("Prefer", "outlook.body-content-type=\"text\"")
297        .query(&[
298            (
299                "$select",
300                "id,subject,from,receivedDateTime,isRead,bodyPreview,body,hasAttachments,flag,conversationId",
301            ),
302            // No $orderby: Graph rejects conversationId filters combined
303            // with a sort ("InefficientFilter") — we sort client-side.
304            ("$filter", filter.as_str()),
305            ("$top", "100"),
306        ]);
307    let resp = super::send_with_retry(req).await?;
308    let status = resp.status();
309    if !status.is_success() {
310        let text = resp.text().await.unwrap_or_default();
311        return Err(ClientError::Graph {
312            status: status.as_u16(),
313            message: text,
314        });
315    }
316    let list: GraphList = resp.json().await?;
317    let mut messages: Vec<pidge_core::Message> = list
318        .value
319        .into_iter()
320        .map(|g| to_message(g, account))
321        .collect();
322    messages.sort_by_key(|m| m.received_at);
323    Ok(messages)
324}
325
326/// Fetch a page of messages at an absolute Graph URL (an `@odata.nextLink`
327/// carried in a pidge cursor). Continues any listing or search stream.
328pub async fn list_messages_at(
329    http: &reqwest::Client,
330    access_token: &str,
331    account: &str,
332    url: &str,
333) -> Result<InboxPage, ClientError> {
334    let req = http.get(url).bearer_auth(access_token);
335    let resp = super::send_with_retry(req).await?;
336    let status = resp.status();
337    if !status.is_success() {
338        let text = resp.text().await.unwrap_or_default();
339        return Err(ClientError::Graph {
340            status: status.as_u16(),
341            message: text,
342        });
343    }
344    let list: GraphList = resp.json().await?;
345    Ok(InboxPage {
346        has_more: list.next_link.is_some(),
347        next_link: list.next_link,
348        messages: list
349            .value
350            .into_iter()
351            .map(|g| to_message(g, account))
352            .collect(),
353    })
354}
355
356pub async fn search_messages(
357    http: &reqwest::Client,
358    base_url: &str,
359    access_token: &str,
360    account: &str,
361    query: &str,
362    limit: usize,
363) -> Result<InboxPage, ClientError> {
364    // $search expects a quoted KQL string; the user passes the raw query.
365    let quoted = format!("\"{}\"", query.replace('"', "\\\""));
366    let url = format!("{base_url}/me/messages");
367    let resp = super::send_with_retry(
368        http.get(&url)
369            .bearer_auth(access_token)
370            .header("Prefer", "outlook.body-content-type=\"text\"")
371            .query(&[
372                (
373                    "$select",
374                    "id,subject,from,receivedDateTime,isRead,bodyPreview,body,hasAttachments,flag,conversationId",
375                ),
376                ("$top", &limit.to_string()),
377                ("$search", &quoted),
378            ]),
379    )
380    .await?;
381    let status = resp.status();
382    if !status.is_success() {
383        let text = resp.text().await.unwrap_or_default();
384        return Err(ClientError::Graph {
385            status: status.as_u16(),
386            message: text,
387        });
388    }
389    let list: GraphList = resp.json().await?;
390    Ok(InboxPage {
391        has_more: list.next_link.is_some(),
392        next_link: list.next_link,
393        messages: list
394            .value
395            .into_iter()
396            .map(|g| to_message(g, account))
397            .collect(),
398    })
399}
400
401fn to_message(g: GraphMessage, account: &str) -> Message {
402    let (body, body_content_type) = match g.body {
403        Some(b) => {
404            let kind = if b.content_type.eq_ignore_ascii_case("html") {
405                pidge_core::BodyContentType::Html
406            } else {
407                pidge_core::BodyContentType::Text
408            };
409            (b.content, kind)
410        }
411        None => (String::new(), pidge_core::BodyContentType::Text),
412    };
413    Message {
414        account: account.to_string(),
415        id: g.id,
416        conversation_id: g.conversation_id.unwrap_or_default(),
417        from: MessageFrom {
418            name: g
419                .from
420                .as_ref()
421                .and_then(|f| f.email_address.name.clone())
422                .unwrap_or_default(),
423            address: g
424                .from
425                .as_ref()
426                .and_then(|f| f.email_address.address.clone())
427                .unwrap_or_default(),
428        },
429        subject: g.subject.unwrap_or_default(),
430        received_at: g.received_date_time,
431        is_read: g.is_read.unwrap_or(true),
432        // `preview` keeps the 255-char plain-text snippet Graph computes
433        // (bodyPreview). It's used as a cheap fallback when body is absent
434        // and as the plain-text source for `--json` output (AI agents and
435        // scripts that don't want to deal with HTML).
436        preview: g.body_preview.unwrap_or_default(),
437        flag_status: flag_status_from(g.flag),
438        has_attachments: g.has_attachments.unwrap_or(false),
439        body,
440        body_content_type,
441    }
442}
443
444/// GET /me/messages/{id} — fetch a single message with full body.
445pub async fn get_message(
446    http: &reqwest::Client,
447    base_url: &str,
448    access_token: &str,
449    account: &str,
450    message_id: &str,
451) -> Result<pidge_core::FullMessage, ClientError> {
452    let url = format!(
453        "{base_url}/me/messages/{message_id}\
454         ?$select=id,subject,from,toRecipients,ccRecipients,bccRecipients,\
455receivedDateTime,sentDateTime,isRead,body,hasAttachments,flag,conversationId"
456    );
457    let resp = super::send_with_retry(http.get(&url).bearer_auth(access_token)).await?;
458    let status = resp.status();
459    if !status.is_success() {
460        let text = resp.text().await.unwrap_or_default();
461        return Err(ClientError::Graph {
462            status: status.as_u16(),
463            message: text,
464        });
465    }
466    let g: GraphFullMessage = resp.json().await?;
467
468    fn from(addr: GraphFromAddress) -> pidge_core::MessageFrom {
469        pidge_core::MessageFrom {
470            name: addr.name.unwrap_or_default(),
471            address: addr.address.unwrap_or_default(),
472        }
473    }
474    fn unwrap_recipients(rs: Vec<GraphFromWrapper>) -> Vec<pidge_core::MessageFrom> {
475        rs.into_iter().map(|w| from(w.email_address)).collect()
476    }
477
478    let content_type = match g.body.content_type.to_lowercase().as_str() {
479        "html" => pidge_core::BodyContentType::Html,
480        _ => pidge_core::BodyContentType::Text,
481    };
482
483    Ok(pidge_core::FullMessage {
484        account: account.to_string(),
485        id: g.id,
486        conversation_id: g.conversation_id.unwrap_or_default(),
487        from: g
488            .from
489            .map(|w| from(w.email_address))
490            .unwrap_or_else(|| pidge_core::MessageFrom {
491                name: String::new(),
492                address: String::new(),
493            }),
494        to: unwrap_recipients(g.to_recipients),
495        cc: unwrap_recipients(g.cc_recipients),
496        bcc: unwrap_recipients(g.bcc_recipients),
497        subject: g.subject.unwrap_or_default(),
498        received_at: g.received_date_time,
499        sent_at: g.sent_date_time,
500        is_read: g.is_read.unwrap_or(true),
501        body_content_type: content_type,
502        body_content: g.body.content,
503        has_attachments: g.has_attachments.unwrap_or(false),
504        flag_status: flag_status_from(g.flag),
505    })
506}
507
508/// GET /me/messages/{id}?$select=internetMessageHeaders — fetch just the
509/// raw RFC 5322 headers for a message. Used by `pidge mail unsubscribe`
510/// to locate `List-Unsubscribe` / `List-Unsubscribe-Post`.
511pub async fn fetch_message_headers(
512    http: &reqwest::Client,
513    base_url: &str,
514    access_token: &str,
515    message_id: &str,
516) -> Result<Vec<(String, String)>, ClientError> {
517    let url = format!("{base_url}/me/messages/{message_id}?$select=internetMessageHeaders");
518    let resp = super::send_with_retry(http.get(&url).bearer_auth(access_token)).await?;
519    let status = resp.status();
520    if !status.is_success() {
521        let text = resp.text().await.unwrap_or_default();
522        return Err(ClientError::Graph {
523            status: status.as_u16(),
524            message: text,
525        });
526    }
527    let body: GraphHeadersResponse = resp.json().await?;
528    Ok(body
529        .internet_message_headers
530        .unwrap_or_default()
531        .into_iter()
532        .map(|h| (h.name, h.value))
533        .collect())
534}
535
536#[derive(serde::Deserialize)]
537struct GraphHeadersResponse {
538    #[serde(rename = "internetMessageHeaders", default)]
539    internet_message_headers: Option<Vec<GraphHeader>>,
540}
541
542#[derive(serde::Deserialize)]
543struct GraphHeader {
544    name: String,
545    value: String,
546}
547
548/// GET /me/messages/{id}/attachments — list attachments without fetching bytes.
549/// Filters to file attachments only.
550pub async fn list_attachments(
551    http: &reqwest::Client,
552    base_url: &str,
553    access_token: &str,
554    message_id: &str,
555) -> Result<Vec<pidge_core::Attachment>, ClientError> {
556    // contentId is intentionally NOT in $select: it lives on the
557    // `microsoft.graph.fileAttachment` subtype, not the base attachment
558    // type, so Graph rejects the query with a 400 when it's in a flat
559    // select clause. We don't currently use content_id in the CLI; if we
560    // ever need it (e.g. to match inline `cid:` references), per-attachment
561    // GETs return it without a cast.
562    let url = format!(
563        "{base_url}/me/messages/{message_id}/attachments\
564         ?$select=id,name,contentType,size,isInline"
565    );
566    let resp = super::send_with_retry(http.get(&url).bearer_auth(access_token)).await?;
567    let status = resp.status();
568    if !status.is_success() {
569        let text = resp.text().await.unwrap_or_default();
570        return Err(ClientError::Graph {
571            status: status.as_u16(),
572            message: text,
573        });
574    }
575    let list: GraphAttachmentList = resp.json().await?;
576    Ok(list
577        .value
578        .into_iter()
579        .filter(|a| {
580            a.odata_type
581                .as_deref()
582                .map(|t| t == "#microsoft.graph.fileAttachment")
583                .unwrap_or(true)
584        })
585        .map(|a| pidge_core::Attachment {
586            id: a.id,
587            name: a.name.unwrap_or_default(),
588            content_type: a.content_type.unwrap_or_default(),
589            size_bytes: a.size.unwrap_or(0),
590            is_inline: a.is_inline.unwrap_or(false),
591            content_id: a.content_id,
592        })
593        .collect())
594}
595
596/// GET /me/messages/{id}/attachments/{attachment_id} — fetch a single attachment
597/// with its base64 contentBytes. Returns the decoded bytes.
598pub async fn get_attachment_bytes(
599    http: &reqwest::Client,
600    base_url: &str,
601    access_token: &str,
602    message_id: &str,
603    attachment_id: &str,
604) -> Result<Vec<u8>, ClientError> {
605    use base64::Engine;
606    use base64::engine::general_purpose::STANDARD;
607
608    let url = format!("{base_url}/me/messages/{message_id}/attachments/{attachment_id}");
609    let resp = super::send_with_retry(http.get(&url).bearer_auth(access_token)).await?;
610    let status = resp.status();
611    if !status.is_success() {
612        let text = resp.text().await.unwrap_or_default();
613        return Err(ClientError::Graph {
614            status: status.as_u16(),
615            message: text,
616        });
617    }
618    let g: GraphAttachment = resp.json().await?;
619    let b64 = g.content_bytes.ok_or_else(|| ClientError::Graph {
620        status: 200,
621        message: "attachment response missing contentBytes".to_string(),
622    })?;
623    STANDARD.decode(&b64).map_err(|e| ClientError::Graph {
624        status: 200,
625        message: format!("attachment base64 decode: {e}"),
626    })
627}
628
629/// PATCH /me/messages/{id} — mark the message as read.
630pub async fn mark_read(
631    http: &reqwest::Client,
632    base_url: &str,
633    access_token: &str,
634    message_id: &str,
635) -> Result<(), ClientError> {
636    patch_message(
637        http,
638        base_url,
639        access_token,
640        message_id,
641        &serde_json::json!({ "isRead": true }),
642    )
643    .await
644}
645
646/// PATCH /me/messages/{id} — mark the message as unread.
647pub async fn mark_unread(
648    http: &reqwest::Client,
649    base_url: &str,
650    access_token: &str,
651    message_id: &str,
652) -> Result<(), ClientError> {
653    patch_message(
654        http,
655        base_url,
656        access_token,
657        message_id,
658        &serde_json::json!({ "isRead": false }),
659    )
660    .await
661}
662
663/// PATCH /me/messages/{id} — set or clear the follow-up flag.
664pub async fn set_flag(
665    http: &reqwest::Client,
666    base_url: &str,
667    access_token: &str,
668    message_id: &str,
669    flagged: bool,
670) -> Result<(), ClientError> {
671    let status = if flagged { "flagged" } else { "notFlagged" };
672    patch_message(
673        http,
674        base_url,
675        access_token,
676        message_id,
677        &serde_json::json!({ "flag": { "flagStatus": status } }),
678    )
679    .await
680}
681
682#[derive(serde::Deserialize)]
683struct GraphCategories {
684    #[serde(default)]
685    categories: Vec<String>,
686}
687
688/// GET /me/messages/{id}?$select=categories — read a message's categories.
689pub async fn get_categories(
690    http: &reqwest::Client,
691    base_url: &str,
692    access_token: &str,
693    message_id: &str,
694) -> Result<Vec<String>, ClientError> {
695    let url = format!("{base_url}/me/messages/{message_id}?$select=categories");
696    let resp = super::send_with_retry(http.get(&url).bearer_auth(access_token)).await?;
697    let status = resp.status();
698    if !status.is_success() {
699        let text = resp.text().await.unwrap_or_default();
700        return Err(ClientError::Graph {
701            status: status.as_u16(),
702            message: text,
703        });
704    }
705    let body: GraphCategories = resp.json().await?;
706    Ok(body.categories)
707}
708
709/// PATCH /me/messages/{id} with `{ "categories": [...] }` — replace categories.
710pub async fn set_categories(
711    http: &reqwest::Client,
712    base_url: &str,
713    access_token: &str,
714    message_id: &str,
715    categories: &[String],
716) -> Result<(), ClientError> {
717    patch_message(
718        http,
719        base_url,
720        access_token,
721        message_id,
722        &serde_json::json!({ "categories": categories }),
723    )
724    .await
725}
726
727async fn patch_message(
728    http: &reqwest::Client,
729    base_url: &str,
730    access_token: &str,
731    message_id: &str,
732    body: &serde_json::Value,
733) -> Result<(), ClientError> {
734    let url = format!("{base_url}/me/messages/{message_id}");
735    let resp =
736        super::send_with_retry(http.patch(&url).bearer_auth(access_token).json(body)).await?;
737    let status = resp.status();
738    if !status.is_success() {
739        let text = resp.text().await.unwrap_or_default();
740        return Err(ClientError::Graph {
741            status: status.as_u16(),
742            message: text,
743        });
744    }
745    Ok(())
746}
747
748/// POST /me/sendMail — compose-and-send a new message in one call.
749///
750/// The Graph endpoint takes ownership of the body — we wrap the `Outgoing`
751/// in `{ "message": ..., "saveToSentItems": true }` so a copy lands in the
752/// sender's Sent Items folder. Returns 202 Accepted on success.
753pub async fn send_mail(
754    http: &reqwest::Client,
755    base_url: &str,
756    access_token: &str,
757    message: &Outgoing,
758) -> Result<(), ClientError> {
759    let url = format!("{base_url}/me/sendMail");
760    let body = serde_json::json!({
761        "message": message.to_graph_json(),
762        "saveToSentItems": true,
763    });
764    post_no_body(http, &url, access_token, &body).await
765}
766
767/// Convert plain-text body to HTML, escaping special chars and preserving
768/// the line- and paragraph-breaks the user typed.
769///
770/// Graph's `/reply` and `/forward` endpoints accept a `comment` string and
771/// insert it into the reply body — but when the source message body is HTML
772/// (which Outlook always serves), newlines in `comment` collapse to spaces.
773/// To keep the user's formatting, we send the body as HTML via a
774/// `createReply` + PATCH dance (see `prepend_html_to_draft`), and this helper
775/// is the text→HTML conversion that feeds it.
776fn text_to_html(text: &str) -> String {
777    fn escape(s: &str) -> String {
778        s.replace('&', "&amp;")
779            .replace('<', "&lt;")
780            .replace('>', "&gt;")
781            .replace('"', "&quot;")
782    }
783    let normalized = text.replace("\r\n", "\n").replace('\r', "\n");
784    normalized
785        .split("\n\n")
786        .filter(|p| !p.trim().is_empty())
787        .map(|paragraph| {
788            let lines: Vec<String> = paragraph
789                .trim_matches('\n')
790                .split('\n')
791                .map(escape)
792                .collect();
793            format!("<p>{}</p>", lines.join("<br>"))
794        })
795        .collect::<Vec<_>>()
796        .join("")
797}
798
799#[derive(Debug, Deserialize)]
800struct GraphBodyOnly {
801    body: GraphBody,
802}
803
804/// GET a draft's body, splice `html_to_prepend` in above Graph's auto-quoted
805/// text, and PATCH the draft. Used by reply/reply-all/forward to deliver
806/// HTML-formatted comments that survive Outlook's HTML rendering.
807async fn prepend_html_to_draft(
808    http: &reqwest::Client,
809    base_url: &str,
810    access_token: &str,
811    message_id: &str,
812    html_to_prepend: &str,
813) -> Result<(), ClientError> {
814    let get_url = format!("{base_url}/me/messages/{message_id}?$select=body");
815    let resp = super::send_with_retry(http.get(&get_url).bearer_auth(access_token)).await?;
816    let status = resp.status();
817    if !status.is_success() {
818        let text = resp.text().await.unwrap_or_default();
819        return Err(ClientError::Graph {
820            status: status.as_u16(),
821            message: text,
822        });
823    }
824    let existing: GraphBodyOnly = resp.json().await?;
825    let existing_content = existing.body.content;
826
827    // Insert right after the opening `<body...>` tag if present, otherwise
828    // prepend to the whole string. Case-insensitive match because Outlook
829    // sometimes serves uppercase `<BODY>`.
830    let new_content = match find_body_tag_end(&existing_content) {
831        Some(pos) => {
832            let mut s = String::with_capacity(existing_content.len() + html_to_prepend.len());
833            s.push_str(&existing_content[..pos]);
834            s.push_str(html_to_prepend);
835            s.push_str(&existing_content[pos..]);
836            s
837        }
838        None => format!("{html_to_prepend}{existing_content}"),
839    };
840
841    let patch_url = format!("{base_url}/me/messages/{message_id}");
842    let patch_body = serde_json::json!({
843        "body": {
844            "contentType": "HTML",
845            "content": new_content,
846        }
847    });
848    let resp = super::send_with_retry(
849        http.patch(&patch_url)
850            .bearer_auth(access_token)
851            .json(&patch_body),
852    )
853    .await?;
854    let status = resp.status();
855    if !status.is_success() {
856        let text = resp.text().await.unwrap_or_default();
857        return Err(ClientError::Graph {
858            status: status.as_u16(),
859            message: text,
860        });
861    }
862    Ok(())
863}
864
865fn find_body_tag_end(html: &str) -> Option<usize> {
866    let lc = html.to_ascii_lowercase();
867    let start = lc.find("<body")?;
868    let after = &html[start..];
869    let close_rel = after.find('>')?;
870    Some(start + close_rel + 1)
871}
872
873/// Reply to a message — sends immediately. Uses createReply + body PATCH +
874/// send so the comment is delivered as HTML and the user's paragraph and
875/// line breaks survive Outlook's HTML rendering.
876pub async fn reply_message(
877    http: &reqwest::Client,
878    base_url: &str,
879    access_token: &str,
880    message_id: &str,
881    comment: &str,
882) -> Result<(), ClientError> {
883    let draft_id = create_reply_draft(http, base_url, access_token, message_id, comment).await?;
884    send_draft(http, base_url, access_token, &draft_id).await
885}
886
887/// Reply-all variant of `reply_message`.
888pub async fn reply_all_message(
889    http: &reqwest::Client,
890    base_url: &str,
891    access_token: &str,
892    message_id: &str,
893    comment: &str,
894) -> Result<(), ClientError> {
895    let draft_id =
896        create_reply_all_draft(http, base_url, access_token, message_id, comment).await?;
897    send_draft(http, base_url, access_token, &draft_id).await
898}
899
900/// Forward — sends immediately, with HTML-formatted comment.
901pub async fn forward_message(
902    http: &reqwest::Client,
903    base_url: &str,
904    access_token: &str,
905    message_id: &str,
906    to: &[String],
907    comment: &str,
908) -> Result<(), ClientError> {
909    let draft_id =
910        create_forward_draft(http, base_url, access_token, message_id, to, comment).await?;
911    send_draft(http, base_url, access_token, &draft_id).await
912}
913
914async fn post_no_body(
915    http: &reqwest::Client,
916    url: &str,
917    access_token: &str,
918    body: &serde_json::Value,
919) -> Result<(), ClientError> {
920    let resp = super::send_with_retry(http.post(url).bearer_auth(access_token).json(body)).await?;
921    let status = resp.status();
922    if !status.is_success() {
923        let text = resp.text().await.unwrap_or_default();
924        return Err(ClientError::Graph {
925            status: status.as_u16(),
926            message: text,
927        });
928    }
929    Ok(())
930}
931
932/// POST /me/messages — create a draft message in the Drafts folder.
933/// Returns the new draft's Graph message ID.
934pub async fn create_draft(
935    http: &reqwest::Client,
936    base_url: &str,
937    access_token: &str,
938    message: &Outgoing,
939) -> Result<String, ClientError> {
940    let url = format!("{base_url}/me/messages");
941    let body = message.to_graph_json();
942    let resp =
943        super::send_with_retry(http.post(&url).bearer_auth(access_token).json(&body)).await?;
944    parse_id_from_response(resp).await
945}
946
947/// POST /me/messages/{id}/createReply — create a reply draft. Returns the
948/// new draft's Graph message ID.
949///
950/// The comment is converted from plain text to HTML and spliced into the
951/// draft body via PATCH, so paragraph- and line-breaks survive Outlook's
952/// HTML rendering. (Graph's own `comment` parameter would collapse them.)
953pub async fn create_reply_draft(
954    http: &reqwest::Client,
955    base_url: &str,
956    access_token: &str,
957    message_id: &str,
958    comment: &str,
959) -> Result<String, ClientError> {
960    let url = format!("{base_url}/me/messages/{message_id}/createReply");
961    let resp = super::send_with_retry(
962        http.post(&url)
963            .bearer_auth(access_token)
964            .json(&serde_json::json!({})),
965    )
966    .await?;
967    let draft_id = parse_id_from_response(resp).await?;
968    if !comment.is_empty() {
969        prepend_html_to_draft(
970            http,
971            base_url,
972            access_token,
973            &draft_id,
974            &text_to_html(comment),
975        )
976        .await?;
977    }
978    Ok(draft_id)
979}
980
981/// POST /me/messages/{id}/createReplyAll — create a reply-all draft.
982pub async fn create_reply_all_draft(
983    http: &reqwest::Client,
984    base_url: &str,
985    access_token: &str,
986    message_id: &str,
987    comment: &str,
988) -> Result<String, ClientError> {
989    let url = format!("{base_url}/me/messages/{message_id}/createReplyAll");
990    let resp = super::send_with_retry(
991        http.post(&url)
992            .bearer_auth(access_token)
993            .json(&serde_json::json!({})),
994    )
995    .await?;
996    let draft_id = parse_id_from_response(resp).await?;
997    if !comment.is_empty() {
998        prepend_html_to_draft(
999            http,
1000            base_url,
1001            access_token,
1002            &draft_id,
1003            &text_to_html(comment),
1004        )
1005        .await?;
1006    }
1007    Ok(draft_id)
1008}
1009
1010/// POST /me/messages/{id}/createForward — create a forward draft with the
1011/// given recipients already populated.
1012pub async fn create_forward_draft(
1013    http: &reqwest::Client,
1014    base_url: &str,
1015    access_token: &str,
1016    message_id: &str,
1017    to: &[String],
1018    comment: &str,
1019) -> Result<String, ClientError> {
1020    let url = format!("{base_url}/me/messages/{message_id}/createForward");
1021    let resp = super::send_with_retry(http.post(&url).bearer_auth(access_token).json(
1022        &serde_json::json!({
1023            "toRecipients": to.iter().map(|addr| serde_json::json!({
1024                "emailAddress": { "address": addr }
1025            })).collect::<Vec<_>>(),
1026        }),
1027    ))
1028    .await?;
1029    let draft_id = parse_id_from_response(resp).await?;
1030    if !comment.is_empty() {
1031        prepend_html_to_draft(
1032            http,
1033            base_url,
1034            access_token,
1035            &draft_id,
1036            &text_to_html(comment),
1037        )
1038        .await?;
1039    }
1040    Ok(draft_id)
1041}
1042
1043/// POST /me/messages/{id}/send — send an existing draft.
1044pub async fn send_draft(
1045    http: &reqwest::Client,
1046    base_url: &str,
1047    access_token: &str,
1048    message_id: &str,
1049) -> Result<(), ClientError> {
1050    let url = format!("{base_url}/me/messages/{message_id}/send");
1051    // Graph's /send takes no body but requires `Content-Length: 0`; reqwest
1052    // omits that header for body-less requests, so the Graph edge layer
1053    // responds with HTTP 411. Sending an explicit empty body forces the
1054    // header to land.
1055    let resp = super::send_with_retry(
1056        http.post(&url)
1057            .bearer_auth(access_token)
1058            .header(reqwest::header::CONTENT_LENGTH, 0)
1059            .body(reqwest::Body::from("")),
1060    )
1061    .await?;
1062    let status = resp.status();
1063    if !status.is_success() {
1064        let text = resp.text().await.unwrap_or_default();
1065        return Err(ClientError::Graph {
1066            status: status.as_u16(),
1067            message: text,
1068        });
1069    }
1070    Ok(())
1071}
1072
1073/// PATCH /me/messages/{id} — overwrite a draft's editable fields. Only the
1074/// fields in `Outgoing` are patched, since that's what our wizard owns.
1075pub async fn update_draft(
1076    http: &reqwest::Client,
1077    base_url: &str,
1078    access_token: &str,
1079    message_id: &str,
1080    message: &Outgoing,
1081) -> Result<(), ClientError> {
1082    let url = format!("{base_url}/me/messages/{message_id}");
1083    let body = message.to_graph_json();
1084    let resp =
1085        super::send_with_retry(http.patch(&url).bearer_auth(access_token).json(&body)).await?;
1086    let status = resp.status();
1087    if !status.is_success() {
1088        let text = resp.text().await.unwrap_or_default();
1089        return Err(ClientError::Graph {
1090            status: status.as_u16(),
1091            message: text,
1092        });
1093    }
1094    Ok(())
1095}
1096
1097/// DELETE /me/messages/{id} — moves the message to Deleted Items. Same call
1098/// works for drafts and for inbox messages; the destination folder differs
1099/// only by what the user is currently in.
1100pub async fn delete_message(
1101    http: &reqwest::Client,
1102    base_url: &str,
1103    access_token: &str,
1104    message_id: &str,
1105) -> Result<(), ClientError> {
1106    let url = format!("{base_url}/me/messages/{message_id}");
1107    let resp = super::send_with_retry(http.delete(&url).bearer_auth(access_token)).await?;
1108    let status = resp.status();
1109    if !status.is_success() {
1110        let text = resp.text().await.unwrap_or_default();
1111        return Err(ClientError::Graph {
1112            status: status.as_u16(),
1113            message: text,
1114        });
1115    }
1116    Ok(())
1117}
1118
1119/// POST /me/messages/{id}/attachments — attach a file to a draft.
1120///
1121/// Uses Graph's simple (non-resumable) upload, which is limited to ~3 MB per
1122/// attachment. Larger files require `createUploadSession`, which isn't wired
1123/// yet — the CLI rejects oversized attachments before calling this.
1124pub async fn add_attachment(
1125    http: &reqwest::Client,
1126    base_url: &str,
1127    access_token: &str,
1128    message_id: &str,
1129    name: &str,
1130    content_type: &str,
1131    bytes: &[u8],
1132) -> Result<String, ClientError> {
1133    use base64::Engine;
1134    use base64::engine::general_purpose::STANDARD;
1135
1136    let url = format!("{base_url}/me/messages/{message_id}/attachments");
1137    let body = serde_json::json!({
1138        "@odata.type": "#microsoft.graph.fileAttachment",
1139        "name": name,
1140        "contentType": content_type,
1141        "contentBytes": STANDARD.encode(bytes),
1142    });
1143    let resp =
1144        super::send_with_retry(http.post(&url).bearer_auth(access_token).json(&body)).await?;
1145    parse_id_from_response(resp).await
1146}
1147
1148/// DELETE /me/messages/{id}/attachments/{att_id}.
1149pub async fn delete_attachment(
1150    http: &reqwest::Client,
1151    base_url: &str,
1152    access_token: &str,
1153    message_id: &str,
1154    attachment_id: &str,
1155) -> Result<(), ClientError> {
1156    let url = format!("{base_url}/me/messages/{message_id}/attachments/{attachment_id}");
1157    let resp = super::send_with_retry(http.delete(&url).bearer_auth(access_token)).await?;
1158    let status = resp.status();
1159    if !status.is_success() {
1160        let text = resp.text().await.unwrap_or_default();
1161        return Err(ClientError::Graph {
1162            status: status.as_u16(),
1163            message: text,
1164        });
1165    }
1166    Ok(())
1167}
1168
1169async fn parse_id_from_response(resp: reqwest::Response) -> Result<String, ClientError> {
1170    let status = resp.status();
1171    if !status.is_success() {
1172        let text = resp.text().await.unwrap_or_default();
1173        return Err(ClientError::Graph {
1174            status: status.as_u16(),
1175            message: text,
1176        });
1177    }
1178    let v: serde_json::Value = resp.json().await?;
1179    v["id"]
1180        .as_str()
1181        .map(|s| s.to_string())
1182        .ok_or_else(|| ClientError::Graph {
1183            status: 200,
1184            message: "draft response missing 'id'".to_string(),
1185        })
1186}
1187
1188/// What the user is sending — pre-Graph-serialization shape.
1189#[derive(Debug, Clone)]
1190pub struct Outgoing {
1191    pub subject: String,
1192    pub body_text: String,
1193    pub to: Vec<String>,
1194    pub cc: Vec<String>,
1195    pub bcc: Vec<String>,
1196}
1197
1198impl Outgoing {
1199    fn to_graph_json(&self) -> serde_json::Value {
1200        fn addresses(list: &[String]) -> Vec<serde_json::Value> {
1201            list.iter()
1202                .map(|addr| serde_json::json!({ "emailAddress": { "address": addr } }))
1203                .collect()
1204        }
1205        serde_json::json!({
1206            "subject": self.subject,
1207            "body": {
1208                "contentType": "Text",
1209                "content": self.body_text,
1210            },
1211            "toRecipients": addresses(&self.to),
1212            "ccRecipients": addresses(&self.cc),
1213            "bccRecipients": addresses(&self.bcc),
1214        })
1215    }
1216}
1217
1218/// POST /me/messages/{id}/move — move the message to another folder.
1219///
1220/// `destination` is either a Graph folder ID or a well-known folder name
1221/// (`"archive"`, `"deleteditems"`, `"junkemail"`, …). Graph returns the new
1222/// message (it gets a new ID in the target folder); we discard that since
1223/// the caller's cache will be refreshed on the next list/search.
1224pub async fn move_message(
1225    http: &reqwest::Client,
1226    base_url: &str,
1227    access_token: &str,
1228    message_id: &str,
1229    destination: &str,
1230) -> Result<(), ClientError> {
1231    let url = format!("{base_url}/me/messages/{message_id}/move");
1232    let resp = super::send_with_retry(
1233        http.post(&url)
1234            .bearer_auth(access_token)
1235            .json(&serde_json::json!({ "destinationId": destination })),
1236    )
1237    .await?;
1238    let status = resp.status();
1239    if !status.is_success() {
1240        let text = resp.text().await.unwrap_or_default();
1241        return Err(ClientError::Graph {
1242            status: status.as_u16(),
1243            message: text,
1244        });
1245    }
1246    Ok(())
1247}
1248
1249/// A mail folder as returned by Graph's `/me/mailFolders` endpoint.
1250#[derive(Debug, Clone, Deserialize)]
1251pub struct MailFolder {
1252    pub id: String,
1253    #[serde(rename = "displayName")]
1254    pub display_name: String,
1255    /// Total message count, present when selected. `None` if Graph omitted it.
1256    #[serde(rename = "totalItemCount", default)]
1257    pub total_item_count: Option<u64>,
1258    /// Unread message count, present when selected.
1259    #[serde(rename = "unreadItemCount", default)]
1260    pub unread_item_count: Option<u64>,
1261    /// Number of immediate child folders, present when selected. Lets callers
1262    /// skip a `childFolders` round-trip for folders that have none.
1263    #[serde(rename = "childFolderCount", default)]
1264    pub child_folder_count: Option<u64>,
1265}
1266
1267#[derive(Debug, Deserialize)]
1268struct GraphFolderList {
1269    value: Vec<MailFolder>,
1270    #[serde(rename = "@odata.nextLink", default)]
1271    next_link: Option<String>,
1272}
1273
1274/// Fields every folder listing selects — shared so top-level and child
1275/// listings return identically-shaped `MailFolder`s.
1276const FOLDER_SELECT: &str = "id,displayName,totalItemCount,unreadItemCount,childFolderCount";
1277
1278/// Page through a `mailFolders`/`childFolders` collection starting at `url`,
1279/// following `@odata.nextLink` until exhausted.
1280async fn fetch_folder_pages(
1281    http: &reqwest::Client,
1282    access_token: &str,
1283    mut url: String,
1284) -> Result<Vec<MailFolder>, ClientError> {
1285    let mut folders: Vec<MailFolder> = Vec::new();
1286    loop {
1287        let resp = super::send_with_retry(http.get(&url).bearer_auth(access_token)).await?;
1288        let status = resp.status();
1289        if !status.is_success() {
1290            let text = resp.text().await.unwrap_or_default();
1291            return Err(ClientError::Graph {
1292                status: status.as_u16(),
1293                message: text,
1294            });
1295        }
1296        let list: GraphFolderList = resp.json().await?;
1297        folders.extend(list.value);
1298        // `@odata.nextLink` is an absolute URL that already carries the
1299        // `$select`/`$top` query — follow it verbatim.
1300        match list.next_link {
1301            Some(next) => url = next,
1302            None => break,
1303        }
1304    }
1305    Ok(folders)
1306}
1307
1308/// GET /me/mailFolders — list the top-level mail folders (id, displayName,
1309/// counts). Pages through `@odata.nextLink` so mailboxes with many folders
1310/// are fully enumerated rather than silently truncated at one page.
1311pub async fn list_mail_folders(
1312    http: &reqwest::Client,
1313    base_url: &str,
1314    access_token: &str,
1315) -> Result<Vec<MailFolder>, ClientError> {
1316    let url = format!("{base_url}/me/mailFolders?$select={FOLDER_SELECT}&$top=100");
1317    fetch_folder_pages(http, access_token, url).await
1318}
1319
1320/// GET /me/mailFolders/{parent_id}/childFolders — list a folder's immediate
1321/// children. Same shape and paging as `list_mail_folders`.
1322pub async fn list_child_folders(
1323    http: &reqwest::Client,
1324    base_url: &str,
1325    access_token: &str,
1326    parent_id: &str,
1327) -> Result<Vec<MailFolder>, ClientError> {
1328    let url = format!(
1329        "{base_url}/me/mailFolders/{parent_id}/childFolders?$select={FOLDER_SELECT}&$top=100"
1330    );
1331    fetch_folder_pages(http, access_token, url).await
1332}
1333
1334async fn post_folder(
1335    http: &reqwest::Client,
1336    url: &str,
1337    access_token: &str,
1338    display_name: &str,
1339) -> Result<MailFolder, ClientError> {
1340    let resp = super::send_with_retry(
1341        http.post(url)
1342            .bearer_auth(access_token)
1343            .json(&serde_json::json!({ "displayName": display_name })),
1344    )
1345    .await?;
1346    let status = resp.status();
1347    if !status.is_success() {
1348        let text = resp.text().await.unwrap_or_default();
1349        return Err(ClientError::Graph {
1350            status: status.as_u16(),
1351            message: text,
1352        });
1353    }
1354    let folder: MailFolder = resp.json().await?;
1355    Ok(folder)
1356}
1357
1358/// POST /me/mailFolders — create a new top-level folder, returning it.
1359///
1360/// Graph rejects a duplicate `displayName` with 409; callers that want
1361/// "create if missing" semantics should list first and only call this when
1362/// no case-insensitive match exists.
1363pub async fn create_mail_folder(
1364    http: &reqwest::Client,
1365    base_url: &str,
1366    access_token: &str,
1367    display_name: &str,
1368) -> Result<MailFolder, ClientError> {
1369    let url = format!("{base_url}/me/mailFolders");
1370    post_folder(http, &url, access_token, display_name).await
1371}
1372
1373/// POST /me/mailFolders/{parent_id}/childFolders — create a child folder
1374/// under `parent_id`, returning it.
1375pub async fn create_child_folder(
1376    http: &reqwest::Client,
1377    base_url: &str,
1378    access_token: &str,
1379    parent_id: &str,
1380    display_name: &str,
1381) -> Result<MailFolder, ClientError> {
1382    let url = format!("{base_url}/me/mailFolders/{parent_id}/childFolders");
1383    post_folder(http, &url, access_token, display_name).await
1384}
1385
1386/// DELETE /me/mailFolders/{id} — delete a folder. Outlook moves the folder
1387/// (and any contents) to Deleted Items, so this is recoverable. Works for
1388/// top-level and child folders alike.
1389pub async fn delete_mail_folder(
1390    http: &reqwest::Client,
1391    base_url: &str,
1392    access_token: &str,
1393    folder_id: &str,
1394) -> Result<(), ClientError> {
1395    let url = format!("{base_url}/me/mailFolders/{folder_id}");
1396    let resp = super::send_with_retry(http.delete(&url).bearer_auth(access_token)).await?;
1397    let status = resp.status();
1398    if !status.is_success() {
1399        let text = resp.text().await.unwrap_or_default();
1400        return Err(ClientError::Graph {
1401            status: status.as_u16(),
1402            message: text,
1403        });
1404    }
1405    Ok(())
1406}
1407
1408#[cfg(test)]
1409mod tests {
1410    use super::*;
1411    use wiremock::matchers::{header, method, path, path_regex, query_param};
1412    use wiremock::{Mock, MockServer, ResponseTemplate};
1413
1414    #[tokio::test]
1415    async fn list_inbox_parses_graph_response() {
1416        let server = MockServer::start().await;
1417        Mock::given(method("GET"))
1418            .and(path("/me/mailFolders/inbox/messages"))
1419            .and(header("authorization", "Bearer AT"))
1420            .and(query_param("$top", "5"))
1421            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1422                "value": [
1423                    {
1424                        "id": "AAAA",
1425                        "subject": "Hello",
1426                        "from": {
1427                            "emailAddress": {
1428                                "name": "Maria",
1429                                "address": "maria@mklab.se"
1430                            }
1431                        },
1432                        "receivedDateTime": "2026-05-13T22:00:00Z",
1433                        "isRead": false,
1434                        "bodyPreview": "Hi there"
1435                    }
1436                ]
1437            })))
1438            .mount(&server)
1439            .await;
1440
1441        let http = reqwest::Client::new();
1442        let page = list_inbox(&http, &server.uri(), "AT", "u@e.com", 5, 0, false)
1443            .await
1444            .unwrap();
1445        assert_eq!(page.messages.len(), 1);
1446        assert_eq!(page.messages[0].subject, "Hello");
1447        assert_eq!(page.messages[0].from.address, "maria@mklab.se");
1448        assert!(!page.messages[0].is_read);
1449        assert_eq!(page.messages[0].account, "u@e.com");
1450        assert!(!page.has_more);
1451    }
1452
1453    #[tokio::test]
1454    async fn list_inbox_adds_filter_when_unread_only() {
1455        let server = MockServer::start().await;
1456        Mock::given(method("GET"))
1457            .and(path("/me/mailFolders/inbox/messages"))
1458            .and(query_param("$filter", "isRead eq false"))
1459            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1460                "value": []
1461            })))
1462            .mount(&server)
1463            .await;
1464
1465        let http = reqwest::Client::new();
1466        let page = list_inbox(&http, &server.uri(), "AT", "u@e.com", 5, 0, true)
1467            .await
1468            .unwrap();
1469        assert!(page.messages.is_empty());
1470    }
1471
1472    #[tokio::test]
1473    async fn list_inbox_passes_skip_when_nonzero() {
1474        let server = MockServer::start().await;
1475        Mock::given(method("GET"))
1476            .and(path("/me/mailFolders/inbox/messages"))
1477            .and(query_param("$skip", "25"))
1478            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1479                "value": [],
1480                "@odata.nextLink": "https://graph.example/next"
1481            })))
1482            .mount(&server)
1483            .await;
1484
1485        let http = reqwest::Client::new();
1486        let page = list_inbox(&http, &server.uri(), "AT", "u@e.com", 25, 25, false)
1487            .await
1488            .unwrap();
1489        assert!(page.has_more);
1490    }
1491
1492    #[tokio::test]
1493    async fn search_messages_passes_search_query() {
1494        let server = MockServer::start().await;
1495        Mock::given(method("GET"))
1496            .and(path("/me/messages"))
1497            .and(query_param("$search", "\"alice budget\""))
1498            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1499                "value": [
1500                    {
1501                        "id": "S1",
1502                        "subject": "Q4 budget review",
1503                        "from": { "emailAddress": { "name": "Alice", "address": "alice@example.com" } },
1504                        "receivedDateTime": "2026-05-13T22:00:00Z",
1505                        "isRead": true,
1506                        "bodyPreview": "Numbers attached"
1507                    }
1508                ]
1509            })))
1510            .mount(&server)
1511            .await;
1512
1513        let http = reqwest::Client::new();
1514        let msgs = search_messages(&http, &server.uri(), "AT", "u@e.com", "alice budget", 25)
1515            .await
1516            .unwrap()
1517            .messages;
1518        assert_eq!(msgs.len(), 1);
1519        assert_eq!(msgs[0].subject, "Q4 budget review");
1520    }
1521
1522    #[tokio::test]
1523    async fn mark_unread_patches_isread_false() {
1524        let server = MockServer::start().await;
1525        Mock::given(method("PATCH"))
1526            .and(path_regex("/me/messages/[A-Za-z0-9]+"))
1527            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
1528            .mount(&server)
1529            .await;
1530        let http = reqwest::Client::new();
1531        mark_unread(&http, &server.uri(), "AT", "MSG")
1532            .await
1533            .unwrap();
1534    }
1535
1536    #[tokio::test]
1537    async fn set_flag_patches_flag_status() {
1538        let server = MockServer::start().await;
1539        Mock::given(method("PATCH"))
1540            .and(path_regex("/me/messages/[A-Za-z0-9]+"))
1541            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
1542            .mount(&server)
1543            .await;
1544        let http = reqwest::Client::new();
1545        set_flag(&http, &server.uri(), "AT", "MSG", true)
1546            .await
1547            .unwrap();
1548        set_flag(&http, &server.uri(), "AT", "MSG", false)
1549            .await
1550            .unwrap();
1551    }
1552
1553    #[tokio::test]
1554    async fn send_mail_wraps_outgoing_in_message_envelope() {
1555        use wiremock::matchers::body_partial_json;
1556        let server = MockServer::start().await;
1557        Mock::given(method("POST"))
1558            .and(path("/me/sendMail"))
1559            .and(body_partial_json(serde_json::json!({
1560                "saveToSentItems": true,
1561                "message": {
1562                    "subject": "Hello",
1563                    "body": { "contentType": "Text", "content": "Hi there" },
1564                    "toRecipients": [{ "emailAddress": { "address": "alice@example.com" } }]
1565                }
1566            })))
1567            .respond_with(ResponseTemplate::new(202))
1568            .mount(&server)
1569            .await;
1570        let http = reqwest::Client::new();
1571        let msg = Outgoing {
1572            subject: "Hello".into(),
1573            body_text: "Hi there".into(),
1574            to: vec!["alice@example.com".into()],
1575            cc: vec![],
1576            bcc: vec![],
1577        };
1578        send_mail(&http, &server.uri(), "AT", &msg).await.unwrap();
1579    }
1580
1581    #[tokio::test]
1582    async fn reply_message_creates_draft_patches_body_and_sends() {
1583        use wiremock::matchers::body_partial_json;
1584        let server = MockServer::start().await;
1585
1586        // 1. createReply → returns draft ID
1587        Mock::given(method("POST"))
1588            .and(path_regex("/me/messages/MSG/createReply"))
1589            .respond_with(
1590                ResponseTemplate::new(201).set_body_json(serde_json::json!({ "id": "DRAFT" })),
1591            )
1592            .mount(&server)
1593            .await;
1594        // 2. GET draft body
1595        Mock::given(method("GET"))
1596            .and(path("/me/messages/DRAFT"))
1597            .and(query_param("$select", "body"))
1598            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1599                "body": { "contentType": "HTML", "content": "<html><body><div></div></body></html>" }
1600            })))
1601            .mount(&server)
1602            .await;
1603        // 3. PATCH draft body — verify our HTML lands in `body.content`
1604        Mock::given(method("PATCH"))
1605            .and(path("/me/messages/DRAFT"))
1606            .and(body_partial_json(serde_json::json!({
1607                "body": { "contentType": "HTML" }
1608            })))
1609            .respond_with(ResponseTemplate::new(200))
1610            .mount(&server)
1611            .await;
1612        // 4. send
1613        Mock::given(method("POST"))
1614            .and(path("/me/messages/DRAFT/send"))
1615            .respond_with(ResponseTemplate::new(202))
1616            .mount(&server)
1617            .await;
1618
1619        let http = reqwest::Client::new();
1620        reply_message(&http, &server.uri(), "AT", "MSG", "Thanks!")
1621            .await
1622            .unwrap();
1623    }
1624
1625    #[tokio::test]
1626    async fn forward_message_creates_draft_with_recipients_and_sends() {
1627        use wiremock::matchers::body_partial_json;
1628        let server = MockServer::start().await;
1629
1630        Mock::given(method("POST"))
1631            .and(path_regex("/me/messages/MSG/createForward"))
1632            .and(body_partial_json(serde_json::json!({
1633                "toRecipients": [{ "emailAddress": { "address": "bob@example.com" } }]
1634            })))
1635            .respond_with(
1636                ResponseTemplate::new(201).set_body_json(serde_json::json!({ "id": "DRAFT" })),
1637            )
1638            .mount(&server)
1639            .await;
1640        Mock::given(method("GET"))
1641            .and(path("/me/messages/DRAFT"))
1642            .and(query_param("$select", "body"))
1643            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1644                "body": { "contentType": "HTML", "content": "<html><body></body></html>" }
1645            })))
1646            .mount(&server)
1647            .await;
1648        Mock::given(method("PATCH"))
1649            .and(path("/me/messages/DRAFT"))
1650            .respond_with(ResponseTemplate::new(200))
1651            .mount(&server)
1652            .await;
1653        Mock::given(method("POST"))
1654            .and(path("/me/messages/DRAFT/send"))
1655            .respond_with(ResponseTemplate::new(202))
1656            .mount(&server)
1657            .await;
1658
1659        let http = reqwest::Client::new();
1660        forward_message(
1661            &http,
1662            &server.uri(),
1663            "AT",
1664            "MSG",
1665            &["bob@example.com".into()],
1666            "FYI",
1667        )
1668        .await
1669        .unwrap();
1670    }
1671
1672    #[test]
1673    fn text_to_html_escapes_and_breaks_paragraphs() {
1674        let out = text_to_html("Hej Edward,\n\nLine 1\nLine 2\n\n<script>x</script>");
1675        assert!(out.contains("<p>Hej Edward,</p>"));
1676        assert!(out.contains("<p>Line 1<br>Line 2</p>"));
1677        assert!(out.contains("&lt;script&gt;x&lt;/script&gt;"));
1678        assert!(!out.contains("<script>"));
1679    }
1680
1681    #[test]
1682    fn text_to_html_normalizes_crlf() {
1683        let out = text_to_html("A\r\nB\r\n\r\nC");
1684        assert!(out.contains("<p>A<br>B</p>"));
1685        assert!(out.contains("<p>C</p>"));
1686    }
1687
1688    #[test]
1689    fn find_body_tag_end_handles_attributes_and_case() {
1690        let html = "<html><BODY class=\"x\">content</BODY></html>";
1691        let pos = find_body_tag_end(html).unwrap();
1692        assert_eq!(&html[pos..pos + 7], "content");
1693    }
1694
1695    #[tokio::test]
1696    async fn list_mail_folders_parses_and_pages() {
1697        let server = MockServer::start().await;
1698        // First page advertises a next link; second page closes it out.
1699        Mock::given(method("GET"))
1700            .and(path("/me/mailFolders"))
1701            .and(query_param("$top", "100"))
1702            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1703                "value": [
1704                    { "id": "F1", "displayName": "Biljetter", "totalItemCount": 3, "unreadItemCount": 0 }
1705                ],
1706                "@odata.nextLink": format!("{}/me/mailFolders?page=2", server.uri())
1707            })))
1708            .mount(&server)
1709            .await;
1710        Mock::given(method("GET"))
1711            .and(path("/me/mailFolders"))
1712            .and(query_param("page", "2"))
1713            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1714                "value": [
1715                    { "id": "F2", "displayName": "Kvitton", "totalItemCount": 7, "unreadItemCount": 2 }
1716                ]
1717            })))
1718            .mount(&server)
1719            .await;
1720
1721        let http = reqwest::Client::new();
1722        let folders = list_mail_folders(&http, &server.uri(), "AT").await.unwrap();
1723        assert_eq!(folders.len(), 2);
1724        assert_eq!(folders[0].display_name, "Biljetter");
1725        assert_eq!(folders[0].total_item_count, Some(3));
1726        assert_eq!(folders[1].display_name, "Kvitton");
1727        assert_eq!(folders[1].unread_item_count, Some(2));
1728    }
1729
1730    #[tokio::test]
1731    async fn create_mail_folder_posts_display_name() {
1732        use wiremock::matchers::body_partial_json;
1733        let server = MockServer::start().await;
1734        Mock::given(method("POST"))
1735            .and(path("/me/mailFolders"))
1736            .and(body_partial_json(
1737                serde_json::json!({ "displayName": "Biljetter" }),
1738            ))
1739            .respond_with(
1740                ResponseTemplate::new(201)
1741                    .set_body_json(serde_json::json!({ "id": "NEWF", "displayName": "Biljetter" })),
1742            )
1743            .mount(&server)
1744            .await;
1745
1746        let http = reqwest::Client::new();
1747        let folder = create_mail_folder(&http, &server.uri(), "AT", "Biljetter")
1748            .await
1749            .unwrap();
1750        assert_eq!(folder.id, "NEWF");
1751        assert_eq!(folder.display_name, "Biljetter");
1752    }
1753
1754    #[tokio::test]
1755    async fn list_child_folders_hits_child_endpoint() {
1756        let server = MockServer::start().await;
1757        Mock::given(method("GET"))
1758            .and(path("/me/mailFolders/PARENT/childFolders"))
1759            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1760                "value": [
1761                    { "id": "C1", "displayName": "MKLab", "totalItemCount": 5, "unreadItemCount": 0, "childFolderCount": 0 }
1762                ]
1763            })))
1764            .mount(&server)
1765            .await;
1766        let http = reqwest::Client::new();
1767        let children = list_child_folders(&http, &server.uri(), "AT", "PARENT")
1768            .await
1769            .unwrap();
1770        assert_eq!(children.len(), 1);
1771        assert_eq!(children[0].display_name, "MKLab");
1772    }
1773
1774    #[tokio::test]
1775    async fn create_child_folder_posts_to_parent() {
1776        use wiremock::matchers::body_partial_json;
1777        let server = MockServer::start().await;
1778        Mock::given(method("POST"))
1779            .and(path("/me/mailFolders/PARENT/childFolders"))
1780            .and(body_partial_json(
1781                serde_json::json!({ "displayName": "MKLab" }),
1782            ))
1783            .respond_with(
1784                ResponseTemplate::new(201)
1785                    .set_body_json(serde_json::json!({ "id": "C9", "displayName": "MKLab" })),
1786            )
1787            .mount(&server)
1788            .await;
1789        let http = reqwest::Client::new();
1790        let folder = create_child_folder(&http, &server.uri(), "AT", "PARENT", "MKLab")
1791            .await
1792            .unwrap();
1793        assert_eq!(folder.id, "C9");
1794    }
1795
1796    #[tokio::test]
1797    async fn delete_mail_folder_hits_delete_endpoint() {
1798        let server = MockServer::start().await;
1799        Mock::given(method("DELETE"))
1800            .and(path("/me/mailFolders/F1"))
1801            .respond_with(ResponseTemplate::new(204))
1802            .mount(&server)
1803            .await;
1804        let http = reqwest::Client::new();
1805        delete_mail_folder(&http, &server.uri(), "AT", "F1")
1806            .await
1807            .unwrap();
1808    }
1809
1810    #[tokio::test]
1811    async fn get_categories_parses_field() {
1812        let server = MockServer::start().await;
1813        Mock::given(method("GET"))
1814            .and(path_regex(r"^/me/messages/.+$"))
1815            .and(query_param("$select", "categories"))
1816            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1817                "categories": ["Receipts", "Urgent"]
1818            })))
1819            .mount(&server)
1820            .await;
1821        let http = reqwest::Client::new();
1822        let cats = get_categories(&http, &server.uri(), "AT", "MSG")
1823            .await
1824            .unwrap();
1825        assert_eq!(cats, vec!["Receipts", "Urgent"]);
1826    }
1827
1828    #[tokio::test]
1829    async fn set_categories_patches_array() {
1830        use wiremock::matchers::body_partial_json;
1831        let server = MockServer::start().await;
1832        Mock::given(method("PATCH"))
1833            .and(path_regex(r"^/me/messages/.+$"))
1834            .and(body_partial_json(
1835                serde_json::json!({ "categories": ["receipt", "ticket"] }),
1836            ))
1837            .respond_with(ResponseTemplate::new(200))
1838            .mount(&server)
1839            .await;
1840        let http = reqwest::Client::new();
1841        set_categories(
1842            &http,
1843            &server.uri(),
1844            "AT",
1845            "MSG",
1846            &["receipt".to_string(), "ticket".to_string()],
1847        )
1848        .await
1849        .unwrap();
1850    }
1851
1852    #[tokio::test]
1853    async fn move_message_posts_destination() {
1854        let server = MockServer::start().await;
1855        Mock::given(method("POST"))
1856            .and(path_regex("/me/messages/[A-Za-z0-9]+/move"))
1857            .respond_with(
1858                ResponseTemplate::new(201).set_body_json(serde_json::json!({"id": "NEW"})),
1859            )
1860            .mount(&server)
1861            .await;
1862        let http = reqwest::Client::new();
1863        move_message(&http, &server.uri(), "AT", "MSG", "archive")
1864            .await
1865            .unwrap();
1866    }
1867
1868    #[tokio::test]
1869    async fn get_message_parses_graph_response() {
1870        let server = MockServer::start().await;
1871        Mock::given(method("GET"))
1872            .and(path_regex("/me/messages/[A-Za-z0-9]+"))
1873            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1874                "id": "AAA",
1875                "subject": "Hello",
1876                "from": { "emailAddress": { "name": "Maria", "address": "maria@mklab.se" } },
1877                "toRecipients": [
1878                    { "emailAddress": { "name": "Kristofer", "address": "kristofer@mklab.se" } }
1879                ],
1880                "ccRecipients": [],
1881                "bccRecipients": [],
1882                "receivedDateTime": "2026-05-14T22:00:00Z",
1883                "sentDateTime": "2026-05-14T21:59:30Z",
1884                "isRead": false,
1885                "body": { "contentType": "html", "content": "<p>Hi</p>" },
1886                "hasAttachments": true
1887            })))
1888            .mount(&server)
1889            .await;
1890
1891        let http = reqwest::Client::new();
1892        let m = get_message(&http, &server.uri(), "AT", "u@e.com", "AAA")
1893            .await
1894            .unwrap();
1895        assert_eq!(m.id, "AAA");
1896        assert_eq!(m.subject, "Hello");
1897        assert_eq!(m.from.name, "Maria");
1898        assert_eq!(m.to.len(), 1);
1899        assert_eq!(m.to[0].address, "kristofer@mklab.se");
1900        assert!(matches!(
1901            m.body_content_type,
1902            pidge_core::BodyContentType::Html
1903        ));
1904        assert_eq!(m.body_content, "<p>Hi</p>");
1905        assert!(m.has_attachments);
1906    }
1907
1908    #[tokio::test]
1909    async fn list_attachments_filters_file_attachments() {
1910        let server = MockServer::start().await;
1911        Mock::given(method("GET"))
1912            .and(path_regex("/me/messages/[A-Za-z0-9]+/attachments"))
1913            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1914                "value": [
1915                    {
1916                        "@odata.type": "#microsoft.graph.fileAttachment",
1917                        "id": "att-1",
1918                        "name": "report.pdf",
1919                        "contentType": "application/pdf",
1920                        "size": 12345,
1921                        "isInline": false
1922                    },
1923                    {
1924                        "@odata.type": "#microsoft.graph.itemAttachment",
1925                        "id": "att-2",
1926                        "name": "an-email.eml",
1927                        "contentType": "message/rfc822",
1928                        "size": 7777,
1929                        "isInline": false
1930                    }
1931                ]
1932            })))
1933            .mount(&server)
1934            .await;
1935
1936        let http = reqwest::Client::new();
1937        let atts = list_attachments(&http, &server.uri(), "AT", "MSG")
1938            .await
1939            .unwrap();
1940        assert_eq!(atts.len(), 1);
1941        assert_eq!(atts[0].name, "report.pdf");
1942        assert_eq!(atts[0].size_bytes, 12345);
1943    }
1944
1945    #[tokio::test]
1946    async fn get_attachment_bytes_decodes_base64() {
1947        let server = MockServer::start().await;
1948        Mock::given(method("GET"))
1949            .and(path_regex(
1950                "/me/messages/[A-Za-z0-9]+/attachments/[A-Za-z0-9-]+",
1951            ))
1952            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1953                "id": "att-1",
1954                "name": "report.pdf",
1955                "contentType": "application/pdf",
1956                "size": 5,
1957                "isInline": false,
1958                "contentBytes": "aGVsbG8="
1959            })))
1960            .mount(&server)
1961            .await;
1962
1963        let http = reqwest::Client::new();
1964        let bytes = get_attachment_bytes(&http, &server.uri(), "AT", "MSG", "att-1")
1965            .await
1966            .unwrap();
1967        assert_eq!(bytes, b"hello");
1968    }
1969
1970    #[tokio::test]
1971    async fn mark_read_patches_isread_true() {
1972        let server = MockServer::start().await;
1973        Mock::given(method("PATCH"))
1974            .and(path_regex("/me/messages/[A-Za-z0-9]+"))
1975            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
1976            .mount(&server)
1977            .await;
1978
1979        let http = reqwest::Client::new();
1980        mark_read(&http, &server.uri(), "AT", "MSG").await.unwrap();
1981    }
1982
1983    #[tokio::test]
1984    async fn fetch_message_headers_parses_array() {
1985        let server = MockServer::start().await;
1986        Mock::given(method("GET"))
1987            .and(path_regex(r"^/me/messages/.+$"))
1988            .and(query_param("$select", "internetMessageHeaders"))
1989            .and(header("authorization", "Bearer AT"))
1990            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1991                "internetMessageHeaders": [
1992                    { "name": "List-Unsubscribe", "value": "<mailto:u@x>, <https://x/u>" },
1993                    { "name": "List-Unsubscribe-Post", "value": "List-Unsubscribe=One-Click" }
1994                ]
1995            })))
1996            .mount(&server)
1997            .await;
1998
1999        let http = reqwest::Client::new();
2000        let headers = fetch_message_headers(&http, &server.uri(), "AT", "MSGID")
2001            .await
2002            .unwrap();
2003        assert_eq!(headers.len(), 2);
2004        assert_eq!(headers[0].0, "List-Unsubscribe");
2005        assert_eq!(headers[0].1, "<mailto:u@x>, <https://x/u>");
2006        assert_eq!(headers[1].0, "List-Unsubscribe-Post");
2007    }
2008}
2009
2010#[cfg(test)]
2011mod cursor_paging_tests {
2012    use super::*;
2013    use wiremock::matchers::{method, path, query_param};
2014    use wiremock::{Mock, MockServer, ResponseTemplate};
2015
2016    fn msg(id: &str, received: &str) -> serde_json::Value {
2017        serde_json::json!({
2018            "id": id,
2019            "subject": format!("s-{id}"),
2020            "from": {"emailAddress": {"name": "N", "address": "n@x.se"}},
2021            "receivedDateTime": received,
2022            "isRead": true,
2023            "bodyPreview": "p",
2024            "hasAttachments": false
2025        })
2026    }
2027
2028    #[tokio::test]
2029    async fn next_link_is_surfaced_and_followable() {
2030        let server = MockServer::start().await;
2031        let page2_url = format!("{}/me/mailFolders/inbox/messages?page=2", server.uri());
2032        Mock::given(method("GET"))
2033            .and(path("/me/mailFolders/inbox/messages"))
2034            .and(query_param("page", "2"))
2035            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
2036                "value": [msg("m3", "2026-07-01T10:00:00Z")]
2037            })))
2038            .mount(&server)
2039            .await;
2040        Mock::given(method("GET"))
2041            .and(path("/me/mailFolders/inbox/messages"))
2042            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
2043                "value": [msg("m1", "2026-07-03T10:00:00Z"), msg("m2", "2026-07-02T10:00:00Z")],
2044                "@odata.nextLink": page2_url
2045            })))
2046            .mount(&server)
2047            .await;
2048
2049        let http = reqwest::Client::new();
2050        let page1 = list_inbox(&http, &server.uri(), "tok", "a@b.se", 2, 0, false)
2051            .await
2052            .unwrap();
2053        assert_eq!(page1.messages.len(), 2);
2054        let next = page1.next_link.expect("first page links onward");
2055
2056        let page2 = list_messages_at(&http, "tok", "a@b.se", &next)
2057            .await
2058            .unwrap();
2059        assert_eq!(page2.messages.len(), 1);
2060        assert_eq!(page2.messages[0].id, "m3");
2061        assert!(page2.next_link.is_none(), "final page has no continuation");
2062    }
2063}