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
582#[derive(serde::Deserialize)]
583struct GraphCategories {
584    #[serde(default)]
585    categories: Vec<String>,
586}
587
588/// GET /me/messages/{id}?$select=categories — read a message's categories.
589pub async fn get_categories(
590    http: &reqwest::Client,
591    base_url: &str,
592    access_token: &str,
593    message_id: &str,
594) -> Result<Vec<String>, ClientError> {
595    let url = format!("{base_url}/me/messages/{message_id}?$select=categories");
596    let resp = http.get(&url).bearer_auth(access_token).send().await?;
597    let status = resp.status();
598    if !status.is_success() {
599        let text = resp.text().await.unwrap_or_default();
600        return Err(ClientError::Graph {
601            status: status.as_u16(),
602            message: text,
603        });
604    }
605    let body: GraphCategories = resp.json().await?;
606    Ok(body.categories)
607}
608
609/// PATCH /me/messages/{id} with `{ "categories": [...] }` — replace categories.
610pub async fn set_categories(
611    http: &reqwest::Client,
612    base_url: &str,
613    access_token: &str,
614    message_id: &str,
615    categories: &[String],
616) -> Result<(), ClientError> {
617    patch_message(
618        http,
619        base_url,
620        access_token,
621        message_id,
622        &serde_json::json!({ "categories": categories }),
623    )
624    .await
625}
626
627async fn patch_message(
628    http: &reqwest::Client,
629    base_url: &str,
630    access_token: &str,
631    message_id: &str,
632    body: &serde_json::Value,
633) -> Result<(), ClientError> {
634    let url = format!("{base_url}/me/messages/{message_id}");
635    let resp = http
636        .patch(&url)
637        .bearer_auth(access_token)
638        .json(body)
639        .send()
640        .await?;
641    let status = resp.status();
642    if !status.is_success() {
643        let text = resp.text().await.unwrap_or_default();
644        return Err(ClientError::Graph {
645            status: status.as_u16(),
646            message: text,
647        });
648    }
649    Ok(())
650}
651
652/// POST /me/sendMail — compose-and-send a new message in one call.
653///
654/// The Graph endpoint takes ownership of the body — we wrap the `Outgoing`
655/// in `{ "message": ..., "saveToSentItems": true }` so a copy lands in the
656/// sender's Sent Items folder. Returns 202 Accepted on success.
657pub async fn send_mail(
658    http: &reqwest::Client,
659    base_url: &str,
660    access_token: &str,
661    message: &Outgoing,
662) -> Result<(), ClientError> {
663    let url = format!("{base_url}/me/sendMail");
664    let body = serde_json::json!({
665        "message": message.to_graph_json(),
666        "saveToSentItems": true,
667    });
668    post_no_body(http, &url, access_token, &body).await
669}
670
671/// Convert plain-text body to HTML, escaping special chars and preserving
672/// the line- and paragraph-breaks the user typed.
673///
674/// Graph's `/reply` and `/forward` endpoints accept a `comment` string and
675/// insert it into the reply body — but when the source message body is HTML
676/// (which Outlook always serves), newlines in `comment` collapse to spaces.
677/// To keep the user's formatting, we send the body as HTML via a
678/// `createReply` + PATCH dance (see `prepend_html_to_draft`), and this helper
679/// is the text→HTML conversion that feeds it.
680fn text_to_html(text: &str) -> String {
681    fn escape(s: &str) -> String {
682        s.replace('&', "&amp;")
683            .replace('<', "&lt;")
684            .replace('>', "&gt;")
685            .replace('"', "&quot;")
686    }
687    let normalized = text.replace("\r\n", "\n").replace('\r', "\n");
688    normalized
689        .split("\n\n")
690        .filter(|p| !p.trim().is_empty())
691        .map(|paragraph| {
692            let lines: Vec<String> = paragraph
693                .trim_matches('\n')
694                .split('\n')
695                .map(escape)
696                .collect();
697            format!("<p>{}</p>", lines.join("<br>"))
698        })
699        .collect::<Vec<_>>()
700        .join("")
701}
702
703#[derive(Debug, Deserialize)]
704struct GraphBodyOnly {
705    body: GraphBody,
706}
707
708/// GET a draft's body, splice `html_to_prepend` in above Graph's auto-quoted
709/// text, and PATCH the draft. Used by reply/reply-all/forward to deliver
710/// HTML-formatted comments that survive Outlook's HTML rendering.
711async fn prepend_html_to_draft(
712    http: &reqwest::Client,
713    base_url: &str,
714    access_token: &str,
715    message_id: &str,
716    html_to_prepend: &str,
717) -> Result<(), ClientError> {
718    let get_url = format!("{base_url}/me/messages/{message_id}?$select=body");
719    let resp = http.get(&get_url).bearer_auth(access_token).send().await?;
720    let status = resp.status();
721    if !status.is_success() {
722        let text = resp.text().await.unwrap_or_default();
723        return Err(ClientError::Graph {
724            status: status.as_u16(),
725            message: text,
726        });
727    }
728    let existing: GraphBodyOnly = resp.json().await?;
729    let existing_content = existing.body.content;
730
731    // Insert right after the opening `<body...>` tag if present, otherwise
732    // prepend to the whole string. Case-insensitive match because Outlook
733    // sometimes serves uppercase `<BODY>`.
734    let new_content = match find_body_tag_end(&existing_content) {
735        Some(pos) => {
736            let mut s = String::with_capacity(existing_content.len() + html_to_prepend.len());
737            s.push_str(&existing_content[..pos]);
738            s.push_str(html_to_prepend);
739            s.push_str(&existing_content[pos..]);
740            s
741        }
742        None => format!("{html_to_prepend}{existing_content}"),
743    };
744
745    let patch_url = format!("{base_url}/me/messages/{message_id}");
746    let patch_body = serde_json::json!({
747        "body": {
748            "contentType": "HTML",
749            "content": new_content,
750        }
751    });
752    let resp = http
753        .patch(&patch_url)
754        .bearer_auth(access_token)
755        .json(&patch_body)
756        .send()
757        .await?;
758    let status = resp.status();
759    if !status.is_success() {
760        let text = resp.text().await.unwrap_or_default();
761        return Err(ClientError::Graph {
762            status: status.as_u16(),
763            message: text,
764        });
765    }
766    Ok(())
767}
768
769fn find_body_tag_end(html: &str) -> Option<usize> {
770    let lc = html.to_ascii_lowercase();
771    let start = lc.find("<body")?;
772    let after = &html[start..];
773    let close_rel = after.find('>')?;
774    Some(start + close_rel + 1)
775}
776
777/// Reply to a message — sends immediately. Uses createReply + body PATCH +
778/// send so the comment is delivered as HTML and the user's paragraph and
779/// line breaks survive Outlook's HTML rendering.
780pub async fn reply_message(
781    http: &reqwest::Client,
782    base_url: &str,
783    access_token: &str,
784    message_id: &str,
785    comment: &str,
786) -> Result<(), ClientError> {
787    let draft_id = create_reply_draft(http, base_url, access_token, message_id, comment).await?;
788    send_draft(http, base_url, access_token, &draft_id).await
789}
790
791/// Reply-all variant of `reply_message`.
792pub async fn reply_all_message(
793    http: &reqwest::Client,
794    base_url: &str,
795    access_token: &str,
796    message_id: &str,
797    comment: &str,
798) -> Result<(), ClientError> {
799    let draft_id =
800        create_reply_all_draft(http, base_url, access_token, message_id, comment).await?;
801    send_draft(http, base_url, access_token, &draft_id).await
802}
803
804/// Forward — sends immediately, with HTML-formatted comment.
805pub async fn forward_message(
806    http: &reqwest::Client,
807    base_url: &str,
808    access_token: &str,
809    message_id: &str,
810    to: &[String],
811    comment: &str,
812) -> Result<(), ClientError> {
813    let draft_id =
814        create_forward_draft(http, base_url, access_token, message_id, to, comment).await?;
815    send_draft(http, base_url, access_token, &draft_id).await
816}
817
818async fn post_no_body(
819    http: &reqwest::Client,
820    url: &str,
821    access_token: &str,
822    body: &serde_json::Value,
823) -> Result<(), ClientError> {
824    let resp = http
825        .post(url)
826        .bearer_auth(access_token)
827        .json(body)
828        .send()
829        .await?;
830    let status = resp.status();
831    if !status.is_success() {
832        let text = resp.text().await.unwrap_or_default();
833        return Err(ClientError::Graph {
834            status: status.as_u16(),
835            message: text,
836        });
837    }
838    Ok(())
839}
840
841/// POST /me/messages — create a draft message in the Drafts folder.
842/// Returns the new draft's Graph message ID.
843pub async fn create_draft(
844    http: &reqwest::Client,
845    base_url: &str,
846    access_token: &str,
847    message: &Outgoing,
848) -> Result<String, ClientError> {
849    let url = format!("{base_url}/me/messages");
850    let body = message.to_graph_json();
851    let resp = http
852        .post(&url)
853        .bearer_auth(access_token)
854        .json(&body)
855        .send()
856        .await?;
857    parse_id_from_response(resp).await
858}
859
860/// POST /me/messages/{id}/createReply — create a reply draft. Returns the
861/// new draft's Graph message ID.
862///
863/// The comment is converted from plain text to HTML and spliced into the
864/// draft body via PATCH, so paragraph- and line-breaks survive Outlook's
865/// HTML rendering. (Graph's own `comment` parameter would collapse them.)
866pub async fn create_reply_draft(
867    http: &reqwest::Client,
868    base_url: &str,
869    access_token: &str,
870    message_id: &str,
871    comment: &str,
872) -> Result<String, ClientError> {
873    let url = format!("{base_url}/me/messages/{message_id}/createReply");
874    let resp = http
875        .post(&url)
876        .bearer_auth(access_token)
877        .json(&serde_json::json!({}))
878        .send()
879        .await?;
880    let draft_id = parse_id_from_response(resp).await?;
881    if !comment.is_empty() {
882        prepend_html_to_draft(
883            http,
884            base_url,
885            access_token,
886            &draft_id,
887            &text_to_html(comment),
888        )
889        .await?;
890    }
891    Ok(draft_id)
892}
893
894/// POST /me/messages/{id}/createReplyAll — create a reply-all draft.
895pub async fn create_reply_all_draft(
896    http: &reqwest::Client,
897    base_url: &str,
898    access_token: &str,
899    message_id: &str,
900    comment: &str,
901) -> Result<String, ClientError> {
902    let url = format!("{base_url}/me/messages/{message_id}/createReplyAll");
903    let resp = http
904        .post(&url)
905        .bearer_auth(access_token)
906        .json(&serde_json::json!({}))
907        .send()
908        .await?;
909    let draft_id = parse_id_from_response(resp).await?;
910    if !comment.is_empty() {
911        prepend_html_to_draft(
912            http,
913            base_url,
914            access_token,
915            &draft_id,
916            &text_to_html(comment),
917        )
918        .await?;
919    }
920    Ok(draft_id)
921}
922
923/// POST /me/messages/{id}/createForward — create a forward draft with the
924/// given recipients already populated.
925pub async fn create_forward_draft(
926    http: &reqwest::Client,
927    base_url: &str,
928    access_token: &str,
929    message_id: &str,
930    to: &[String],
931    comment: &str,
932) -> Result<String, ClientError> {
933    let url = format!("{base_url}/me/messages/{message_id}/createForward");
934    let resp = http
935        .post(&url)
936        .bearer_auth(access_token)
937        .json(&serde_json::json!({
938            "toRecipients": to.iter().map(|addr| serde_json::json!({
939                "emailAddress": { "address": addr }
940            })).collect::<Vec<_>>(),
941        }))
942        .send()
943        .await?;
944    let draft_id = parse_id_from_response(resp).await?;
945    if !comment.is_empty() {
946        prepend_html_to_draft(
947            http,
948            base_url,
949            access_token,
950            &draft_id,
951            &text_to_html(comment),
952        )
953        .await?;
954    }
955    Ok(draft_id)
956}
957
958/// POST /me/messages/{id}/send — send an existing draft.
959pub async fn send_draft(
960    http: &reqwest::Client,
961    base_url: &str,
962    access_token: &str,
963    message_id: &str,
964) -> Result<(), ClientError> {
965    let url = format!("{base_url}/me/messages/{message_id}/send");
966    // Graph's /send takes no body but requires `Content-Length: 0`; reqwest
967    // omits that header for body-less requests, so the Graph edge layer
968    // responds with HTTP 411. Sending an explicit empty body forces the
969    // header to land.
970    let resp = http
971        .post(&url)
972        .bearer_auth(access_token)
973        .header(reqwest::header::CONTENT_LENGTH, 0)
974        .body(reqwest::Body::from(""))
975        .send()
976        .await?;
977    let status = resp.status();
978    if !status.is_success() {
979        let text = resp.text().await.unwrap_or_default();
980        return Err(ClientError::Graph {
981            status: status.as_u16(),
982            message: text,
983        });
984    }
985    Ok(())
986}
987
988/// PATCH /me/messages/{id} — overwrite a draft's editable fields. Only the
989/// fields in `Outgoing` are patched, since that's what our wizard owns.
990pub async fn update_draft(
991    http: &reqwest::Client,
992    base_url: &str,
993    access_token: &str,
994    message_id: &str,
995    message: &Outgoing,
996) -> Result<(), ClientError> {
997    let url = format!("{base_url}/me/messages/{message_id}");
998    let body = message.to_graph_json();
999    let resp = http
1000        .patch(&url)
1001        .bearer_auth(access_token)
1002        .json(&body)
1003        .send()
1004        .await?;
1005    let status = resp.status();
1006    if !status.is_success() {
1007        let text = resp.text().await.unwrap_or_default();
1008        return Err(ClientError::Graph {
1009            status: status.as_u16(),
1010            message: text,
1011        });
1012    }
1013    Ok(())
1014}
1015
1016/// DELETE /me/messages/{id} — moves the message to Deleted Items. Same call
1017/// works for drafts and for inbox messages; the destination folder differs
1018/// only by what the user is currently in.
1019pub async fn delete_message(
1020    http: &reqwest::Client,
1021    base_url: &str,
1022    access_token: &str,
1023    message_id: &str,
1024) -> Result<(), ClientError> {
1025    let url = format!("{base_url}/me/messages/{message_id}");
1026    let resp = http.delete(&url).bearer_auth(access_token).send().await?;
1027    let status = resp.status();
1028    if !status.is_success() {
1029        let text = resp.text().await.unwrap_or_default();
1030        return Err(ClientError::Graph {
1031            status: status.as_u16(),
1032            message: text,
1033        });
1034    }
1035    Ok(())
1036}
1037
1038/// POST /me/messages/{id}/attachments — attach a file to a draft.
1039///
1040/// Uses Graph's simple (non-resumable) upload, which is limited to ~3 MB per
1041/// attachment. Larger files require `createUploadSession`, which isn't wired
1042/// yet — the CLI rejects oversized attachments before calling this.
1043pub async fn add_attachment(
1044    http: &reqwest::Client,
1045    base_url: &str,
1046    access_token: &str,
1047    message_id: &str,
1048    name: &str,
1049    content_type: &str,
1050    bytes: &[u8],
1051) -> Result<String, ClientError> {
1052    use base64::Engine;
1053    use base64::engine::general_purpose::STANDARD;
1054
1055    let url = format!("{base_url}/me/messages/{message_id}/attachments");
1056    let body = serde_json::json!({
1057        "@odata.type": "#microsoft.graph.fileAttachment",
1058        "name": name,
1059        "contentType": content_type,
1060        "contentBytes": STANDARD.encode(bytes),
1061    });
1062    let resp = http
1063        .post(&url)
1064        .bearer_auth(access_token)
1065        .json(&body)
1066        .send()
1067        .await?;
1068    parse_id_from_response(resp).await
1069}
1070
1071/// DELETE /me/messages/{id}/attachments/{att_id}.
1072pub async fn delete_attachment(
1073    http: &reqwest::Client,
1074    base_url: &str,
1075    access_token: &str,
1076    message_id: &str,
1077    attachment_id: &str,
1078) -> Result<(), ClientError> {
1079    let url = format!("{base_url}/me/messages/{message_id}/attachments/{attachment_id}");
1080    let resp = http.delete(&url).bearer_auth(access_token).send().await?;
1081    let status = resp.status();
1082    if !status.is_success() {
1083        let text = resp.text().await.unwrap_or_default();
1084        return Err(ClientError::Graph {
1085            status: status.as_u16(),
1086            message: text,
1087        });
1088    }
1089    Ok(())
1090}
1091
1092async fn parse_id_from_response(resp: reqwest::Response) -> Result<String, ClientError> {
1093    let status = resp.status();
1094    if !status.is_success() {
1095        let text = resp.text().await.unwrap_or_default();
1096        return Err(ClientError::Graph {
1097            status: status.as_u16(),
1098            message: text,
1099        });
1100    }
1101    let v: serde_json::Value = resp.json().await?;
1102    v["id"]
1103        .as_str()
1104        .map(|s| s.to_string())
1105        .ok_or_else(|| ClientError::Graph {
1106            status: 200,
1107            message: "draft response missing 'id'".to_string(),
1108        })
1109}
1110
1111/// What the user is sending — pre-Graph-serialization shape.
1112#[derive(Debug, Clone)]
1113pub struct Outgoing {
1114    pub subject: String,
1115    pub body_text: String,
1116    pub to: Vec<String>,
1117    pub cc: Vec<String>,
1118    pub bcc: Vec<String>,
1119}
1120
1121impl Outgoing {
1122    fn to_graph_json(&self) -> serde_json::Value {
1123        fn addresses(list: &[String]) -> Vec<serde_json::Value> {
1124            list.iter()
1125                .map(|addr| serde_json::json!({ "emailAddress": { "address": addr } }))
1126                .collect()
1127        }
1128        serde_json::json!({
1129            "subject": self.subject,
1130            "body": {
1131                "contentType": "Text",
1132                "content": self.body_text,
1133            },
1134            "toRecipients": addresses(&self.to),
1135            "ccRecipients": addresses(&self.cc),
1136            "bccRecipients": addresses(&self.bcc),
1137        })
1138    }
1139}
1140
1141/// POST /me/messages/{id}/move — move the message to another folder.
1142///
1143/// `destination` is either a Graph folder ID or a well-known folder name
1144/// (`"archive"`, `"deleteditems"`, `"junkemail"`, …). Graph returns the new
1145/// message (it gets a new ID in the target folder); we discard that since
1146/// the caller's cache will be refreshed on the next list/search.
1147pub async fn move_message(
1148    http: &reqwest::Client,
1149    base_url: &str,
1150    access_token: &str,
1151    message_id: &str,
1152    destination: &str,
1153) -> Result<(), ClientError> {
1154    let url = format!("{base_url}/me/messages/{message_id}/move");
1155    let resp = http
1156        .post(&url)
1157        .bearer_auth(access_token)
1158        .json(&serde_json::json!({ "destinationId": destination }))
1159        .send()
1160        .await?;
1161    let status = resp.status();
1162    if !status.is_success() {
1163        let text = resp.text().await.unwrap_or_default();
1164        return Err(ClientError::Graph {
1165            status: status.as_u16(),
1166            message: text,
1167        });
1168    }
1169    Ok(())
1170}
1171
1172/// A mail folder as returned by Graph's `/me/mailFolders` endpoint.
1173#[derive(Debug, Clone, Deserialize)]
1174pub struct MailFolder {
1175    pub id: String,
1176    #[serde(rename = "displayName")]
1177    pub display_name: String,
1178    /// Total message count, present when selected. `None` if Graph omitted it.
1179    #[serde(rename = "totalItemCount", default)]
1180    pub total_item_count: Option<u64>,
1181    /// Unread message count, present when selected.
1182    #[serde(rename = "unreadItemCount", default)]
1183    pub unread_item_count: Option<u64>,
1184    /// Number of immediate child folders, present when selected. Lets callers
1185    /// skip a `childFolders` round-trip for folders that have none.
1186    #[serde(rename = "childFolderCount", default)]
1187    pub child_folder_count: Option<u64>,
1188}
1189
1190#[derive(Debug, Deserialize)]
1191struct GraphFolderList {
1192    value: Vec<MailFolder>,
1193    #[serde(rename = "@odata.nextLink", default)]
1194    next_link: Option<String>,
1195}
1196
1197/// Fields every folder listing selects — shared so top-level and child
1198/// listings return identically-shaped `MailFolder`s.
1199const FOLDER_SELECT: &str = "id,displayName,totalItemCount,unreadItemCount,childFolderCount";
1200
1201/// Page through a `mailFolders`/`childFolders` collection starting at `url`,
1202/// following `@odata.nextLink` until exhausted.
1203async fn fetch_folder_pages(
1204    http: &reqwest::Client,
1205    access_token: &str,
1206    mut url: String,
1207) -> Result<Vec<MailFolder>, ClientError> {
1208    let mut folders: Vec<MailFolder> = Vec::new();
1209    loop {
1210        let resp = http.get(&url).bearer_auth(access_token).send().await?;
1211        let status = resp.status();
1212        if !status.is_success() {
1213            let text = resp.text().await.unwrap_or_default();
1214            return Err(ClientError::Graph {
1215                status: status.as_u16(),
1216                message: text,
1217            });
1218        }
1219        let list: GraphFolderList = resp.json().await?;
1220        folders.extend(list.value);
1221        // `@odata.nextLink` is an absolute URL that already carries the
1222        // `$select`/`$top` query — follow it verbatim.
1223        match list.next_link {
1224            Some(next) => url = next,
1225            None => break,
1226        }
1227    }
1228    Ok(folders)
1229}
1230
1231/// GET /me/mailFolders — list the top-level mail folders (id, displayName,
1232/// counts). Pages through `@odata.nextLink` so mailboxes with many folders
1233/// are fully enumerated rather than silently truncated at one page.
1234pub async fn list_mail_folders(
1235    http: &reqwest::Client,
1236    base_url: &str,
1237    access_token: &str,
1238) -> Result<Vec<MailFolder>, ClientError> {
1239    let url = format!("{base_url}/me/mailFolders?$select={FOLDER_SELECT}&$top=100");
1240    fetch_folder_pages(http, access_token, url).await
1241}
1242
1243/// GET /me/mailFolders/{parent_id}/childFolders — list a folder's immediate
1244/// children. Same shape and paging as `list_mail_folders`.
1245pub async fn list_child_folders(
1246    http: &reqwest::Client,
1247    base_url: &str,
1248    access_token: &str,
1249    parent_id: &str,
1250) -> Result<Vec<MailFolder>, ClientError> {
1251    let url = format!(
1252        "{base_url}/me/mailFolders/{parent_id}/childFolders?$select={FOLDER_SELECT}&$top=100"
1253    );
1254    fetch_folder_pages(http, access_token, url).await
1255}
1256
1257async fn post_folder(
1258    http: &reqwest::Client,
1259    url: &str,
1260    access_token: &str,
1261    display_name: &str,
1262) -> Result<MailFolder, ClientError> {
1263    let resp = http
1264        .post(url)
1265        .bearer_auth(access_token)
1266        .json(&serde_json::json!({ "displayName": display_name }))
1267        .send()
1268        .await?;
1269    let status = resp.status();
1270    if !status.is_success() {
1271        let text = resp.text().await.unwrap_or_default();
1272        return Err(ClientError::Graph {
1273            status: status.as_u16(),
1274            message: text,
1275        });
1276    }
1277    let folder: MailFolder = resp.json().await?;
1278    Ok(folder)
1279}
1280
1281/// POST /me/mailFolders — create a new top-level folder, returning it.
1282///
1283/// Graph rejects a duplicate `displayName` with 409; callers that want
1284/// "create if missing" semantics should list first and only call this when
1285/// no case-insensitive match exists.
1286pub async fn create_mail_folder(
1287    http: &reqwest::Client,
1288    base_url: &str,
1289    access_token: &str,
1290    display_name: &str,
1291) -> Result<MailFolder, ClientError> {
1292    let url = format!("{base_url}/me/mailFolders");
1293    post_folder(http, &url, access_token, display_name).await
1294}
1295
1296/// POST /me/mailFolders/{parent_id}/childFolders — create a child folder
1297/// under `parent_id`, returning it.
1298pub async fn create_child_folder(
1299    http: &reqwest::Client,
1300    base_url: &str,
1301    access_token: &str,
1302    parent_id: &str,
1303    display_name: &str,
1304) -> Result<MailFolder, ClientError> {
1305    let url = format!("{base_url}/me/mailFolders/{parent_id}/childFolders");
1306    post_folder(http, &url, access_token, display_name).await
1307}
1308
1309/// DELETE /me/mailFolders/{id} — delete a folder. Outlook moves the folder
1310/// (and any contents) to Deleted Items, so this is recoverable. Works for
1311/// top-level and child folders alike.
1312pub async fn delete_mail_folder(
1313    http: &reqwest::Client,
1314    base_url: &str,
1315    access_token: &str,
1316    folder_id: &str,
1317) -> Result<(), ClientError> {
1318    let url = format!("{base_url}/me/mailFolders/{folder_id}");
1319    let resp = http.delete(&url).bearer_auth(access_token).send().await?;
1320    let status = resp.status();
1321    if !status.is_success() {
1322        let text = resp.text().await.unwrap_or_default();
1323        return Err(ClientError::Graph {
1324            status: status.as_u16(),
1325            message: text,
1326        });
1327    }
1328    Ok(())
1329}
1330
1331#[cfg(test)]
1332mod tests {
1333    use super::*;
1334    use wiremock::matchers::{header, method, path, path_regex, query_param};
1335    use wiremock::{Mock, MockServer, ResponseTemplate};
1336
1337    #[tokio::test]
1338    async fn list_inbox_parses_graph_response() {
1339        let server = MockServer::start().await;
1340        Mock::given(method("GET"))
1341            .and(path("/me/mailFolders/inbox/messages"))
1342            .and(header("authorization", "Bearer AT"))
1343            .and(query_param("$top", "5"))
1344            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1345                "value": [
1346                    {
1347                        "id": "AAAA",
1348                        "subject": "Hello",
1349                        "from": {
1350                            "emailAddress": {
1351                                "name": "Maria",
1352                                "address": "maria@mklab.se"
1353                            }
1354                        },
1355                        "receivedDateTime": "2026-05-13T22:00:00Z",
1356                        "isRead": false,
1357                        "bodyPreview": "Hi there"
1358                    }
1359                ]
1360            })))
1361            .mount(&server)
1362            .await;
1363
1364        let http = reqwest::Client::new();
1365        let page = list_inbox(&http, &server.uri(), "AT", "u@e.com", 5, 0, false)
1366            .await
1367            .unwrap();
1368        assert_eq!(page.messages.len(), 1);
1369        assert_eq!(page.messages[0].subject, "Hello");
1370        assert_eq!(page.messages[0].from.address, "maria@mklab.se");
1371        assert!(!page.messages[0].is_read);
1372        assert_eq!(page.messages[0].account, "u@e.com");
1373        assert!(!page.has_more);
1374    }
1375
1376    #[tokio::test]
1377    async fn list_inbox_adds_filter_when_unread_only() {
1378        let server = MockServer::start().await;
1379        Mock::given(method("GET"))
1380            .and(path("/me/mailFolders/inbox/messages"))
1381            .and(query_param("$filter", "isRead eq false"))
1382            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1383                "value": []
1384            })))
1385            .mount(&server)
1386            .await;
1387
1388        let http = reqwest::Client::new();
1389        let page = list_inbox(&http, &server.uri(), "AT", "u@e.com", 5, 0, true)
1390            .await
1391            .unwrap();
1392        assert!(page.messages.is_empty());
1393    }
1394
1395    #[tokio::test]
1396    async fn list_inbox_passes_skip_when_nonzero() {
1397        let server = MockServer::start().await;
1398        Mock::given(method("GET"))
1399            .and(path("/me/mailFolders/inbox/messages"))
1400            .and(query_param("$skip", "25"))
1401            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1402                "value": [],
1403                "@odata.nextLink": "https://graph.example/next"
1404            })))
1405            .mount(&server)
1406            .await;
1407
1408        let http = reqwest::Client::new();
1409        let page = list_inbox(&http, &server.uri(), "AT", "u@e.com", 25, 25, false)
1410            .await
1411            .unwrap();
1412        assert!(page.has_more);
1413    }
1414
1415    #[tokio::test]
1416    async fn search_messages_passes_search_query() {
1417        let server = MockServer::start().await;
1418        Mock::given(method("GET"))
1419            .and(path("/me/messages"))
1420            .and(query_param("$search", "\"alice budget\""))
1421            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1422                "value": [
1423                    {
1424                        "id": "S1",
1425                        "subject": "Q4 budget review",
1426                        "from": { "emailAddress": { "name": "Alice", "address": "alice@example.com" } },
1427                        "receivedDateTime": "2026-05-13T22:00:00Z",
1428                        "isRead": true,
1429                        "bodyPreview": "Numbers attached"
1430                    }
1431                ]
1432            })))
1433            .mount(&server)
1434            .await;
1435
1436        let http = reqwest::Client::new();
1437        let msgs = search_messages(&http, &server.uri(), "AT", "u@e.com", "alice budget", 25)
1438            .await
1439            .unwrap();
1440        assert_eq!(msgs.len(), 1);
1441        assert_eq!(msgs[0].subject, "Q4 budget review");
1442    }
1443
1444    #[tokio::test]
1445    async fn mark_unread_patches_isread_false() {
1446        let server = MockServer::start().await;
1447        Mock::given(method("PATCH"))
1448            .and(path_regex("/me/messages/[A-Za-z0-9]+"))
1449            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
1450            .mount(&server)
1451            .await;
1452        let http = reqwest::Client::new();
1453        mark_unread(&http, &server.uri(), "AT", "MSG")
1454            .await
1455            .unwrap();
1456    }
1457
1458    #[tokio::test]
1459    async fn set_flag_patches_flag_status() {
1460        let server = MockServer::start().await;
1461        Mock::given(method("PATCH"))
1462            .and(path_regex("/me/messages/[A-Za-z0-9]+"))
1463            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
1464            .mount(&server)
1465            .await;
1466        let http = reqwest::Client::new();
1467        set_flag(&http, &server.uri(), "AT", "MSG", true)
1468            .await
1469            .unwrap();
1470        set_flag(&http, &server.uri(), "AT", "MSG", false)
1471            .await
1472            .unwrap();
1473    }
1474
1475    #[tokio::test]
1476    async fn send_mail_wraps_outgoing_in_message_envelope() {
1477        use wiremock::matchers::body_partial_json;
1478        let server = MockServer::start().await;
1479        Mock::given(method("POST"))
1480            .and(path("/me/sendMail"))
1481            .and(body_partial_json(serde_json::json!({
1482                "saveToSentItems": true,
1483                "message": {
1484                    "subject": "Hello",
1485                    "body": { "contentType": "Text", "content": "Hi there" },
1486                    "toRecipients": [{ "emailAddress": { "address": "alice@example.com" } }]
1487                }
1488            })))
1489            .respond_with(ResponseTemplate::new(202))
1490            .mount(&server)
1491            .await;
1492        let http = reqwest::Client::new();
1493        let msg = Outgoing {
1494            subject: "Hello".into(),
1495            body_text: "Hi there".into(),
1496            to: vec!["alice@example.com".into()],
1497            cc: vec![],
1498            bcc: vec![],
1499        };
1500        send_mail(&http, &server.uri(), "AT", &msg).await.unwrap();
1501    }
1502
1503    #[tokio::test]
1504    async fn reply_message_creates_draft_patches_body_and_sends() {
1505        use wiremock::matchers::body_partial_json;
1506        let server = MockServer::start().await;
1507
1508        // 1. createReply → returns draft ID
1509        Mock::given(method("POST"))
1510            .and(path_regex("/me/messages/MSG/createReply"))
1511            .respond_with(
1512                ResponseTemplate::new(201).set_body_json(serde_json::json!({ "id": "DRAFT" })),
1513            )
1514            .mount(&server)
1515            .await;
1516        // 2. GET draft body
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><div></div></body></html>" }
1522            })))
1523            .mount(&server)
1524            .await;
1525        // 3. PATCH draft body — verify our HTML lands in `body.content`
1526        Mock::given(method("PATCH"))
1527            .and(path("/me/messages/DRAFT"))
1528            .and(body_partial_json(serde_json::json!({
1529                "body": { "contentType": "HTML" }
1530            })))
1531            .respond_with(ResponseTemplate::new(200))
1532            .mount(&server)
1533            .await;
1534        // 4. send
1535        Mock::given(method("POST"))
1536            .and(path("/me/messages/DRAFT/send"))
1537            .respond_with(ResponseTemplate::new(202))
1538            .mount(&server)
1539            .await;
1540
1541        let http = reqwest::Client::new();
1542        reply_message(&http, &server.uri(), "AT", "MSG", "Thanks!")
1543            .await
1544            .unwrap();
1545    }
1546
1547    #[tokio::test]
1548    async fn forward_message_creates_draft_with_recipients_and_sends() {
1549        use wiremock::matchers::body_partial_json;
1550        let server = MockServer::start().await;
1551
1552        Mock::given(method("POST"))
1553            .and(path_regex("/me/messages/MSG/createForward"))
1554            .and(body_partial_json(serde_json::json!({
1555                "toRecipients": [{ "emailAddress": { "address": "bob@example.com" } }]
1556            })))
1557            .respond_with(
1558                ResponseTemplate::new(201).set_body_json(serde_json::json!({ "id": "DRAFT" })),
1559            )
1560            .mount(&server)
1561            .await;
1562        Mock::given(method("GET"))
1563            .and(path("/me/messages/DRAFT"))
1564            .and(query_param("$select", "body"))
1565            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1566                "body": { "contentType": "HTML", "content": "<html><body></body></html>" }
1567            })))
1568            .mount(&server)
1569            .await;
1570        Mock::given(method("PATCH"))
1571            .and(path("/me/messages/DRAFT"))
1572            .respond_with(ResponseTemplate::new(200))
1573            .mount(&server)
1574            .await;
1575        Mock::given(method("POST"))
1576            .and(path("/me/messages/DRAFT/send"))
1577            .respond_with(ResponseTemplate::new(202))
1578            .mount(&server)
1579            .await;
1580
1581        let http = reqwest::Client::new();
1582        forward_message(
1583            &http,
1584            &server.uri(),
1585            "AT",
1586            "MSG",
1587            &["bob@example.com".into()],
1588            "FYI",
1589        )
1590        .await
1591        .unwrap();
1592    }
1593
1594    #[test]
1595    fn text_to_html_escapes_and_breaks_paragraphs() {
1596        let out = text_to_html("Hej Edward,\n\nLine 1\nLine 2\n\n<script>x</script>");
1597        assert!(out.contains("<p>Hej Edward,</p>"));
1598        assert!(out.contains("<p>Line 1<br>Line 2</p>"));
1599        assert!(out.contains("&lt;script&gt;x&lt;/script&gt;"));
1600        assert!(!out.contains("<script>"));
1601    }
1602
1603    #[test]
1604    fn text_to_html_normalizes_crlf() {
1605        let out = text_to_html("A\r\nB\r\n\r\nC");
1606        assert!(out.contains("<p>A<br>B</p>"));
1607        assert!(out.contains("<p>C</p>"));
1608    }
1609
1610    #[test]
1611    fn find_body_tag_end_handles_attributes_and_case() {
1612        let html = "<html><BODY class=\"x\">content</BODY></html>";
1613        let pos = find_body_tag_end(html).unwrap();
1614        assert_eq!(&html[pos..pos + 7], "content");
1615    }
1616
1617    #[tokio::test]
1618    async fn list_mail_folders_parses_and_pages() {
1619        let server = MockServer::start().await;
1620        // First page advertises a next link; second page closes it out.
1621        Mock::given(method("GET"))
1622            .and(path("/me/mailFolders"))
1623            .and(query_param("$top", "100"))
1624            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1625                "value": [
1626                    { "id": "F1", "displayName": "Biljetter", "totalItemCount": 3, "unreadItemCount": 0 }
1627                ],
1628                "@odata.nextLink": format!("{}/me/mailFolders?page=2", server.uri())
1629            })))
1630            .mount(&server)
1631            .await;
1632        Mock::given(method("GET"))
1633            .and(path("/me/mailFolders"))
1634            .and(query_param("page", "2"))
1635            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1636                "value": [
1637                    { "id": "F2", "displayName": "Kvitton", "totalItemCount": 7, "unreadItemCount": 2 }
1638                ]
1639            })))
1640            .mount(&server)
1641            .await;
1642
1643        let http = reqwest::Client::new();
1644        let folders = list_mail_folders(&http, &server.uri(), "AT").await.unwrap();
1645        assert_eq!(folders.len(), 2);
1646        assert_eq!(folders[0].display_name, "Biljetter");
1647        assert_eq!(folders[0].total_item_count, Some(3));
1648        assert_eq!(folders[1].display_name, "Kvitton");
1649        assert_eq!(folders[1].unread_item_count, Some(2));
1650    }
1651
1652    #[tokio::test]
1653    async fn create_mail_folder_posts_display_name() {
1654        use wiremock::matchers::body_partial_json;
1655        let server = MockServer::start().await;
1656        Mock::given(method("POST"))
1657            .and(path("/me/mailFolders"))
1658            .and(body_partial_json(
1659                serde_json::json!({ "displayName": "Biljetter" }),
1660            ))
1661            .respond_with(
1662                ResponseTemplate::new(201)
1663                    .set_body_json(serde_json::json!({ "id": "NEWF", "displayName": "Biljetter" })),
1664            )
1665            .mount(&server)
1666            .await;
1667
1668        let http = reqwest::Client::new();
1669        let folder = create_mail_folder(&http, &server.uri(), "AT", "Biljetter")
1670            .await
1671            .unwrap();
1672        assert_eq!(folder.id, "NEWF");
1673        assert_eq!(folder.display_name, "Biljetter");
1674    }
1675
1676    #[tokio::test]
1677    async fn list_child_folders_hits_child_endpoint() {
1678        let server = MockServer::start().await;
1679        Mock::given(method("GET"))
1680            .and(path("/me/mailFolders/PARENT/childFolders"))
1681            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1682                "value": [
1683                    { "id": "C1", "displayName": "MKLab", "totalItemCount": 5, "unreadItemCount": 0, "childFolderCount": 0 }
1684                ]
1685            })))
1686            .mount(&server)
1687            .await;
1688        let http = reqwest::Client::new();
1689        let children = list_child_folders(&http, &server.uri(), "AT", "PARENT")
1690            .await
1691            .unwrap();
1692        assert_eq!(children.len(), 1);
1693        assert_eq!(children[0].display_name, "MKLab");
1694    }
1695
1696    #[tokio::test]
1697    async fn create_child_folder_posts_to_parent() {
1698        use wiremock::matchers::body_partial_json;
1699        let server = MockServer::start().await;
1700        Mock::given(method("POST"))
1701            .and(path("/me/mailFolders/PARENT/childFolders"))
1702            .and(body_partial_json(
1703                serde_json::json!({ "displayName": "MKLab" }),
1704            ))
1705            .respond_with(
1706                ResponseTemplate::new(201)
1707                    .set_body_json(serde_json::json!({ "id": "C9", "displayName": "MKLab" })),
1708            )
1709            .mount(&server)
1710            .await;
1711        let http = reqwest::Client::new();
1712        let folder = create_child_folder(&http, &server.uri(), "AT", "PARENT", "MKLab")
1713            .await
1714            .unwrap();
1715        assert_eq!(folder.id, "C9");
1716    }
1717
1718    #[tokio::test]
1719    async fn delete_mail_folder_hits_delete_endpoint() {
1720        let server = MockServer::start().await;
1721        Mock::given(method("DELETE"))
1722            .and(path("/me/mailFolders/F1"))
1723            .respond_with(ResponseTemplate::new(204))
1724            .mount(&server)
1725            .await;
1726        let http = reqwest::Client::new();
1727        delete_mail_folder(&http, &server.uri(), "AT", "F1")
1728            .await
1729            .unwrap();
1730    }
1731
1732    #[tokio::test]
1733    async fn get_categories_parses_field() {
1734        let server = MockServer::start().await;
1735        Mock::given(method("GET"))
1736            .and(path_regex(r"^/me/messages/.+$"))
1737            .and(query_param("$select", "categories"))
1738            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1739                "categories": ["Receipts", "Urgent"]
1740            })))
1741            .mount(&server)
1742            .await;
1743        let http = reqwest::Client::new();
1744        let cats = get_categories(&http, &server.uri(), "AT", "MSG")
1745            .await
1746            .unwrap();
1747        assert_eq!(cats, vec!["Receipts", "Urgent"]);
1748    }
1749
1750    #[tokio::test]
1751    async fn set_categories_patches_array() {
1752        use wiremock::matchers::body_partial_json;
1753        let server = MockServer::start().await;
1754        Mock::given(method("PATCH"))
1755            .and(path_regex(r"^/me/messages/.+$"))
1756            .and(body_partial_json(
1757                serde_json::json!({ "categories": ["receipt", "ticket"] }),
1758            ))
1759            .respond_with(ResponseTemplate::new(200))
1760            .mount(&server)
1761            .await;
1762        let http = reqwest::Client::new();
1763        set_categories(
1764            &http,
1765            &server.uri(),
1766            "AT",
1767            "MSG",
1768            &["receipt".to_string(), "ticket".to_string()],
1769        )
1770        .await
1771        .unwrap();
1772    }
1773
1774    #[tokio::test]
1775    async fn move_message_posts_destination() {
1776        let server = MockServer::start().await;
1777        Mock::given(method("POST"))
1778            .and(path_regex("/me/messages/[A-Za-z0-9]+/move"))
1779            .respond_with(
1780                ResponseTemplate::new(201).set_body_json(serde_json::json!({"id": "NEW"})),
1781            )
1782            .mount(&server)
1783            .await;
1784        let http = reqwest::Client::new();
1785        move_message(&http, &server.uri(), "AT", "MSG", "archive")
1786            .await
1787            .unwrap();
1788    }
1789
1790    #[tokio::test]
1791    async fn get_message_parses_graph_response() {
1792        let server = MockServer::start().await;
1793        Mock::given(method("GET"))
1794            .and(path_regex("/me/messages/[A-Za-z0-9]+"))
1795            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1796                "id": "AAA",
1797                "subject": "Hello",
1798                "from": { "emailAddress": { "name": "Maria", "address": "maria@mklab.se" } },
1799                "toRecipients": [
1800                    { "emailAddress": { "name": "Kristofer", "address": "kristofer@mklab.se" } }
1801                ],
1802                "ccRecipients": [],
1803                "bccRecipients": [],
1804                "receivedDateTime": "2026-05-14T22:00:00Z",
1805                "sentDateTime": "2026-05-14T21:59:30Z",
1806                "isRead": false,
1807                "body": { "contentType": "html", "content": "<p>Hi</p>" },
1808                "hasAttachments": true
1809            })))
1810            .mount(&server)
1811            .await;
1812
1813        let http = reqwest::Client::new();
1814        let m = get_message(&http, &server.uri(), "AT", "u@e.com", "AAA")
1815            .await
1816            .unwrap();
1817        assert_eq!(m.id, "AAA");
1818        assert_eq!(m.subject, "Hello");
1819        assert_eq!(m.from.name, "Maria");
1820        assert_eq!(m.to.len(), 1);
1821        assert_eq!(m.to[0].address, "kristofer@mklab.se");
1822        assert!(matches!(
1823            m.body_content_type,
1824            pidge_core::BodyContentType::Html
1825        ));
1826        assert_eq!(m.body_content, "<p>Hi</p>");
1827        assert!(m.has_attachments);
1828    }
1829
1830    #[tokio::test]
1831    async fn list_attachments_filters_file_attachments() {
1832        let server = MockServer::start().await;
1833        Mock::given(method("GET"))
1834            .and(path_regex("/me/messages/[A-Za-z0-9]+/attachments"))
1835            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1836                "value": [
1837                    {
1838                        "@odata.type": "#microsoft.graph.fileAttachment",
1839                        "id": "att-1",
1840                        "name": "report.pdf",
1841                        "contentType": "application/pdf",
1842                        "size": 12345,
1843                        "isInline": false
1844                    },
1845                    {
1846                        "@odata.type": "#microsoft.graph.itemAttachment",
1847                        "id": "att-2",
1848                        "name": "an-email.eml",
1849                        "contentType": "message/rfc822",
1850                        "size": 7777,
1851                        "isInline": false
1852                    }
1853                ]
1854            })))
1855            .mount(&server)
1856            .await;
1857
1858        let http = reqwest::Client::new();
1859        let atts = list_attachments(&http, &server.uri(), "AT", "MSG")
1860            .await
1861            .unwrap();
1862        assert_eq!(atts.len(), 1);
1863        assert_eq!(atts[0].name, "report.pdf");
1864        assert_eq!(atts[0].size_bytes, 12345);
1865    }
1866
1867    #[tokio::test]
1868    async fn get_attachment_bytes_decodes_base64() {
1869        let server = MockServer::start().await;
1870        Mock::given(method("GET"))
1871            .and(path_regex(
1872                "/me/messages/[A-Za-z0-9]+/attachments/[A-Za-z0-9-]+",
1873            ))
1874            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1875                "id": "att-1",
1876                "name": "report.pdf",
1877                "contentType": "application/pdf",
1878                "size": 5,
1879                "isInline": false,
1880                "contentBytes": "aGVsbG8="
1881            })))
1882            .mount(&server)
1883            .await;
1884
1885        let http = reqwest::Client::new();
1886        let bytes = get_attachment_bytes(&http, &server.uri(), "AT", "MSG", "att-1")
1887            .await
1888            .unwrap();
1889        assert_eq!(bytes, b"hello");
1890    }
1891
1892    #[tokio::test]
1893    async fn mark_read_patches_isread_true() {
1894        let server = MockServer::start().await;
1895        Mock::given(method("PATCH"))
1896            .and(path_regex("/me/messages/[A-Za-z0-9]+"))
1897            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
1898            .mount(&server)
1899            .await;
1900
1901        let http = reqwest::Client::new();
1902        mark_read(&http, &server.uri(), "AT", "MSG").await.unwrap();
1903    }
1904
1905    #[tokio::test]
1906    async fn fetch_message_headers_parses_array() {
1907        let server = MockServer::start().await;
1908        Mock::given(method("GET"))
1909            .and(path_regex(r"^/me/messages/.+$"))
1910            .and(query_param("$select", "internetMessageHeaders"))
1911            .and(header("authorization", "Bearer AT"))
1912            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1913                "internetMessageHeaders": [
1914                    { "name": "List-Unsubscribe", "value": "<mailto:u@x>, <https://x/u>" },
1915                    { "name": "List-Unsubscribe-Post", "value": "List-Unsubscribe=One-Click" }
1916                ]
1917            })))
1918            .mount(&server)
1919            .await;
1920
1921        let http = reqwest::Client::new();
1922        let headers = fetch_message_headers(&http, &server.uri(), "AT", "MSGID")
1923            .await
1924            .unwrap();
1925        assert_eq!(headers.len(), 2);
1926        assert_eq!(headers[0].0, "List-Unsubscribe");
1927        assert_eq!(headers[0].1, "<mailto:u@x>, <https://x/u>");
1928        assert_eq!(headers[1].0, "List-Unsubscribe-Post");
1929    }
1930}