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