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 drafts from the Drafts folder. Drafts don't have a real
152/// "received" time, so Graph sorts by `lastModifiedDateTime desc` here.
153pub async fn list_drafts(
154    http: &reqwest::Client,
155    base_url: &str,
156    access_token: &str,
157    account: &str,
158    limit: usize,
159    skip: usize,
160) -> Result<InboxPage, ClientError> {
161    list_folder(
162        http,
163        base_url,
164        access_token,
165        account,
166        "drafts",
167        limit,
168        skip,
169        false,
170    )
171    .await
172}
173
174#[allow(clippy::too_many_arguments)]
175async fn list_folder(
176    http: &reqwest::Client,
177    base_url: &str,
178    access_token: &str,
179    account: &str,
180    folder: &str,
181    limit: usize,
182    skip: usize,
183    unread_only: bool,
184) -> Result<InboxPage, ClientError> {
185    let url = format!("{base_url}/me/mailFolders/{folder}/messages");
186    // Body is fetched in its native content type (HTML or text) so the
187    // renderer can use html2text to extract anchor text + click targets —
188    // making LINK TEXT (not URLs) the clickable surface in previews.
189    let mut req = http.get(&url).bearer_auth(access_token).query(&[
190        (
191            "$select",
192            "id,subject,from,receivedDateTime,isRead,bodyPreview,body,hasAttachments,flag",
193        ),
194        ("$orderby", "receivedDateTime desc"),
195        ("$top", &limit.to_string()),
196    ]);
197    if skip > 0 {
198        req = req.query(&[("$skip", &skip.to_string())]);
199    }
200    if unread_only {
201        req = req.query(&[("$filter", "isRead eq false")]);
202    }
203
204    let resp = req.send().await?;
205    let status = resp.status();
206    if !status.is_success() {
207        let text = resp.text().await.unwrap_or_default();
208        return Err(ClientError::Graph {
209            status: status.as_u16(),
210            message: text,
211        });
212    }
213
214    let list: GraphList = resp.json().await?;
215    Ok(InboxPage {
216        has_more: list.next_link.is_some(),
217        messages: list
218            .value
219            .into_iter()
220            .map(|g| to_message(g, account))
221            .collect(),
222    })
223}
224
225/// Search messages across all folders using Graph's `$search` KQL query.
226///
227/// `$search` doesn't combine with `$filter` or `$orderby` — results come back
228/// in Graph's relevance ranking, not date order. Common query forms users can
229/// pass:
230///
231/// - `alice budget`          → matches anywhere in subject/body/sender
232/// - `from:alice@example.com`
233/// - `subject:"q4 review"`
234/// - `from:alice AND subject:budget`
235pub async fn search_messages(
236    http: &reqwest::Client,
237    base_url: &str,
238    access_token: &str,
239    account: &str,
240    query: &str,
241    limit: usize,
242) -> Result<Vec<Message>, ClientError> {
243    // $search expects a quoted KQL string; the user passes the raw query.
244    let quoted = format!("\"{}\"", query.replace('"', "\\\""));
245    let url = format!("{base_url}/me/messages");
246    let resp = http
247        .get(&url)
248        .bearer_auth(access_token)
249        .header("Prefer", "outlook.body-content-type=\"text\"")
250        .query(&[
251            (
252                "$select",
253                "id,subject,from,receivedDateTime,isRead,bodyPreview,body,hasAttachments,flag",
254            ),
255            ("$top", &limit.to_string()),
256            ("$search", &quoted),
257        ])
258        .send()
259        .await?;
260    let status = resp.status();
261    if !status.is_success() {
262        let text = resp.text().await.unwrap_or_default();
263        return Err(ClientError::Graph {
264            status: status.as_u16(),
265            message: text,
266        });
267    }
268    let list: GraphList = resp.json().await?;
269    Ok(list
270        .value
271        .into_iter()
272        .map(|g| to_message(g, account))
273        .collect())
274}
275
276fn to_message(g: GraphMessage, account: &str) -> Message {
277    let (body, body_content_type) = match g.body {
278        Some(b) => {
279            let kind = if b.content_type.eq_ignore_ascii_case("html") {
280                pidge_core::BodyContentType::Html
281            } else {
282                pidge_core::BodyContentType::Text
283            };
284            (b.content, kind)
285        }
286        None => (String::new(), pidge_core::BodyContentType::Text),
287    };
288    Message {
289        account: account.to_string(),
290        id: g.id,
291        from: MessageFrom {
292            name: g
293                .from
294                .as_ref()
295                .and_then(|f| f.email_address.name.clone())
296                .unwrap_or_default(),
297            address: g
298                .from
299                .as_ref()
300                .and_then(|f| f.email_address.address.clone())
301                .unwrap_or_default(),
302        },
303        subject: g.subject.unwrap_or_default(),
304        received_at: g.received_date_time,
305        is_read: g.is_read.unwrap_or(true),
306        // `preview` keeps the 255-char plain-text snippet Graph computes
307        // (bodyPreview). It's used as a cheap fallback when body is absent
308        // and as the plain-text source for `--json` output (AI agents and
309        // scripts that don't want to deal with HTML).
310        preview: g.body_preview.unwrap_or_default(),
311        flag_status: flag_status_from(g.flag),
312        has_attachments: g.has_attachments.unwrap_or(false),
313        body,
314        body_content_type,
315    }
316}
317
318/// GET /me/messages/{id} — fetch a single message with full body.
319pub async fn get_message(
320    http: &reqwest::Client,
321    base_url: &str,
322    access_token: &str,
323    account: &str,
324    message_id: &str,
325) -> Result<pidge_core::FullMessage, ClientError> {
326    let url = format!(
327        "{base_url}/me/messages/{message_id}\
328         ?$select=id,subject,from,toRecipients,ccRecipients,bccRecipients,\
329receivedDateTime,sentDateTime,isRead,body,hasAttachments,flag"
330    );
331    let resp = http.get(&url).bearer_auth(access_token).send().await?;
332    let status = resp.status();
333    if !status.is_success() {
334        let text = resp.text().await.unwrap_or_default();
335        return Err(ClientError::Graph {
336            status: status.as_u16(),
337            message: text,
338        });
339    }
340    let g: GraphFullMessage = resp.json().await?;
341
342    fn from(addr: GraphFromAddress) -> pidge_core::MessageFrom {
343        pidge_core::MessageFrom {
344            name: addr.name.unwrap_or_default(),
345            address: addr.address.unwrap_or_default(),
346        }
347    }
348    fn unwrap_recipients(rs: Vec<GraphFromWrapper>) -> Vec<pidge_core::MessageFrom> {
349        rs.into_iter().map(|w| from(w.email_address)).collect()
350    }
351
352    let content_type = match g.body.content_type.to_lowercase().as_str() {
353        "html" => pidge_core::BodyContentType::Html,
354        _ => pidge_core::BodyContentType::Text,
355    };
356
357    Ok(pidge_core::FullMessage {
358        account: account.to_string(),
359        id: g.id,
360        from: g
361            .from
362            .map(|w| from(w.email_address))
363            .unwrap_or_else(|| pidge_core::MessageFrom {
364                name: String::new(),
365                address: String::new(),
366            }),
367        to: unwrap_recipients(g.to_recipients),
368        cc: unwrap_recipients(g.cc_recipients),
369        bcc: unwrap_recipients(g.bcc_recipients),
370        subject: g.subject.unwrap_or_default(),
371        received_at: g.received_date_time,
372        sent_at: g.sent_date_time,
373        is_read: g.is_read.unwrap_or(true),
374        body_content_type: content_type,
375        body_content: g.body.content,
376        has_attachments: g.has_attachments.unwrap_or(false),
377        flag_status: flag_status_from(g.flag),
378    })
379}
380
381/// GET /me/messages/{id}?$select=internetMessageHeaders — fetch just the
382/// raw RFC 5322 headers for a message. Used by `pidge mail unsubscribe`
383/// to locate `List-Unsubscribe` / `List-Unsubscribe-Post`.
384pub async fn fetch_message_headers(
385    http: &reqwest::Client,
386    base_url: &str,
387    access_token: &str,
388    message_id: &str,
389) -> Result<Vec<(String, String)>, ClientError> {
390    let url = format!("{base_url}/me/messages/{message_id}?$select=internetMessageHeaders");
391    let resp = http.get(&url).bearer_auth(access_token).send().await?;
392    let status = resp.status();
393    if !status.is_success() {
394        let text = resp.text().await.unwrap_or_default();
395        return Err(ClientError::Graph {
396            status: status.as_u16(),
397            message: text,
398        });
399    }
400    let body: GraphHeadersResponse = resp.json().await?;
401    Ok(body
402        .internet_message_headers
403        .unwrap_or_default()
404        .into_iter()
405        .map(|h| (h.name, h.value))
406        .collect())
407}
408
409#[derive(serde::Deserialize)]
410struct GraphHeadersResponse {
411    #[serde(rename = "internetMessageHeaders", default)]
412    internet_message_headers: Option<Vec<GraphHeader>>,
413}
414
415#[derive(serde::Deserialize)]
416struct GraphHeader {
417    name: String,
418    value: String,
419}
420
421/// GET /me/messages/{id}/attachments — list attachments without fetching bytes.
422/// Filters to file attachments only.
423pub async fn list_attachments(
424    http: &reqwest::Client,
425    base_url: &str,
426    access_token: &str,
427    message_id: &str,
428) -> Result<Vec<pidge_core::Attachment>, ClientError> {
429    // contentId is intentionally NOT in $select: it lives on the
430    // `microsoft.graph.fileAttachment` subtype, not the base attachment
431    // type, so Graph rejects the query with a 400 when it's in a flat
432    // select clause. We don't currently use content_id in the CLI; if we
433    // ever need it (e.g. to match inline `cid:` references), per-attachment
434    // GETs return it without a cast.
435    let url = format!(
436        "{base_url}/me/messages/{message_id}/attachments\
437         ?$select=id,name,contentType,size,isInline"
438    );
439    let resp = http.get(&url).bearer_auth(access_token).send().await?;
440    let status = resp.status();
441    if !status.is_success() {
442        let text = resp.text().await.unwrap_or_default();
443        return Err(ClientError::Graph {
444            status: status.as_u16(),
445            message: text,
446        });
447    }
448    let list: GraphAttachmentList = resp.json().await?;
449    Ok(list
450        .value
451        .into_iter()
452        .filter(|a| {
453            a.odata_type
454                .as_deref()
455                .map(|t| t == "#microsoft.graph.fileAttachment")
456                .unwrap_or(true)
457        })
458        .map(|a| pidge_core::Attachment {
459            id: a.id,
460            name: a.name.unwrap_or_default(),
461            content_type: a.content_type.unwrap_or_default(),
462            size_bytes: a.size.unwrap_or(0),
463            is_inline: a.is_inline.unwrap_or(false),
464            content_id: a.content_id,
465        })
466        .collect())
467}
468
469/// GET /me/messages/{id}/attachments/{attachment_id} — fetch a single attachment
470/// with its base64 contentBytes. Returns the decoded bytes.
471pub async fn get_attachment_bytes(
472    http: &reqwest::Client,
473    base_url: &str,
474    access_token: &str,
475    message_id: &str,
476    attachment_id: &str,
477) -> Result<Vec<u8>, ClientError> {
478    use base64::Engine;
479    use base64::engine::general_purpose::STANDARD;
480
481    let url = format!("{base_url}/me/messages/{message_id}/attachments/{attachment_id}");
482    let resp = http.get(&url).bearer_auth(access_token).send().await?;
483    let status = resp.status();
484    if !status.is_success() {
485        let text = resp.text().await.unwrap_or_default();
486        return Err(ClientError::Graph {
487            status: status.as_u16(),
488            message: text,
489        });
490    }
491    let g: GraphAttachment = resp.json().await?;
492    let b64 = g.content_bytes.ok_or_else(|| ClientError::Graph {
493        status: 200,
494        message: "attachment response missing contentBytes".to_string(),
495    })?;
496    STANDARD.decode(&b64).map_err(|e| ClientError::Graph {
497        status: 200,
498        message: format!("attachment base64 decode: {e}"),
499    })
500}
501
502/// PATCH /me/messages/{id} — mark the message as read.
503pub async fn mark_read(
504    http: &reqwest::Client,
505    base_url: &str,
506    access_token: &str,
507    message_id: &str,
508) -> Result<(), ClientError> {
509    patch_message(
510        http,
511        base_url,
512        access_token,
513        message_id,
514        &serde_json::json!({ "isRead": true }),
515    )
516    .await
517}
518
519/// PATCH /me/messages/{id} — mark the message as unread.
520pub async fn mark_unread(
521    http: &reqwest::Client,
522    base_url: &str,
523    access_token: &str,
524    message_id: &str,
525) -> Result<(), ClientError> {
526    patch_message(
527        http,
528        base_url,
529        access_token,
530        message_id,
531        &serde_json::json!({ "isRead": false }),
532    )
533    .await
534}
535
536/// PATCH /me/messages/{id} — set or clear the follow-up flag.
537pub async fn set_flag(
538    http: &reqwest::Client,
539    base_url: &str,
540    access_token: &str,
541    message_id: &str,
542    flagged: bool,
543) -> Result<(), ClientError> {
544    let status = if flagged { "flagged" } else { "notFlagged" };
545    patch_message(
546        http,
547        base_url,
548        access_token,
549        message_id,
550        &serde_json::json!({ "flag": { "flagStatus": status } }),
551    )
552    .await
553}
554
555async fn patch_message(
556    http: &reqwest::Client,
557    base_url: &str,
558    access_token: &str,
559    message_id: &str,
560    body: &serde_json::Value,
561) -> Result<(), ClientError> {
562    let url = format!("{base_url}/me/messages/{message_id}");
563    let resp = http
564        .patch(&url)
565        .bearer_auth(access_token)
566        .json(body)
567        .send()
568        .await?;
569    let status = resp.status();
570    if !status.is_success() {
571        let text = resp.text().await.unwrap_or_default();
572        return Err(ClientError::Graph {
573            status: status.as_u16(),
574            message: text,
575        });
576    }
577    Ok(())
578}
579
580/// POST /me/sendMail — compose-and-send a new message in one call.
581///
582/// The Graph endpoint takes ownership of the body — we wrap the `Outgoing`
583/// in `{ "message": ..., "saveToSentItems": true }` so a copy lands in the
584/// sender's Sent Items folder. Returns 202 Accepted on success.
585pub async fn send_mail(
586    http: &reqwest::Client,
587    base_url: &str,
588    access_token: &str,
589    message: &Outgoing,
590) -> Result<(), ClientError> {
591    let url = format!("{base_url}/me/sendMail");
592    let body = serde_json::json!({
593        "message": message.to_graph_json(),
594        "saveToSentItems": true,
595    });
596    post_no_body(http, &url, access_token, &body).await
597}
598
599/// Convert plain-text body to HTML, escaping special chars and preserving
600/// the line- and paragraph-breaks the user typed.
601///
602/// Graph's `/reply` and `/forward` endpoints accept a `comment` string and
603/// insert it into the reply body — but when the source message body is HTML
604/// (which Outlook always serves), newlines in `comment` collapse to spaces.
605/// To keep the user's formatting, we send the body as HTML via a
606/// `createReply` + PATCH dance (see `prepend_html_to_draft`), and this helper
607/// is the text→HTML conversion that feeds it.
608fn text_to_html(text: &str) -> String {
609    fn escape(s: &str) -> String {
610        s.replace('&', "&amp;")
611            .replace('<', "&lt;")
612            .replace('>', "&gt;")
613            .replace('"', "&quot;")
614    }
615    let normalized = text.replace("\r\n", "\n").replace('\r', "\n");
616    normalized
617        .split("\n\n")
618        .filter(|p| !p.trim().is_empty())
619        .map(|paragraph| {
620            let lines: Vec<String> = paragraph
621                .trim_matches('\n')
622                .split('\n')
623                .map(escape)
624                .collect();
625            format!("<p>{}</p>", lines.join("<br>"))
626        })
627        .collect::<Vec<_>>()
628        .join("")
629}
630
631#[derive(Debug, Deserialize)]
632struct GraphBodyOnly {
633    body: GraphBody,
634}
635
636/// GET a draft's body, splice `html_to_prepend` in above Graph's auto-quoted
637/// text, and PATCH the draft. Used by reply/reply-all/forward to deliver
638/// HTML-formatted comments that survive Outlook's HTML rendering.
639async fn prepend_html_to_draft(
640    http: &reqwest::Client,
641    base_url: &str,
642    access_token: &str,
643    message_id: &str,
644    html_to_prepend: &str,
645) -> Result<(), ClientError> {
646    let get_url = format!("{base_url}/me/messages/{message_id}?$select=body");
647    let resp = http.get(&get_url).bearer_auth(access_token).send().await?;
648    let status = resp.status();
649    if !status.is_success() {
650        let text = resp.text().await.unwrap_or_default();
651        return Err(ClientError::Graph {
652            status: status.as_u16(),
653            message: text,
654        });
655    }
656    let existing: GraphBodyOnly = resp.json().await?;
657    let existing_content = existing.body.content;
658
659    // Insert right after the opening `<body...>` tag if present, otherwise
660    // prepend to the whole string. Case-insensitive match because Outlook
661    // sometimes serves uppercase `<BODY>`.
662    let new_content = match find_body_tag_end(&existing_content) {
663        Some(pos) => {
664            let mut s = String::with_capacity(existing_content.len() + html_to_prepend.len());
665            s.push_str(&existing_content[..pos]);
666            s.push_str(html_to_prepend);
667            s.push_str(&existing_content[pos..]);
668            s
669        }
670        None => format!("{html_to_prepend}{existing_content}"),
671    };
672
673    let patch_url = format!("{base_url}/me/messages/{message_id}");
674    let patch_body = serde_json::json!({
675        "body": {
676            "contentType": "HTML",
677            "content": new_content,
678        }
679    });
680    let resp = http
681        .patch(&patch_url)
682        .bearer_auth(access_token)
683        .json(&patch_body)
684        .send()
685        .await?;
686    let status = resp.status();
687    if !status.is_success() {
688        let text = resp.text().await.unwrap_or_default();
689        return Err(ClientError::Graph {
690            status: status.as_u16(),
691            message: text,
692        });
693    }
694    Ok(())
695}
696
697fn find_body_tag_end(html: &str) -> Option<usize> {
698    let lc = html.to_ascii_lowercase();
699    let start = lc.find("<body")?;
700    let after = &html[start..];
701    let close_rel = after.find('>')?;
702    Some(start + close_rel + 1)
703}
704
705/// Reply to a message — sends immediately. Uses createReply + body PATCH +
706/// send so the comment is delivered as HTML and the user's paragraph and
707/// line breaks survive Outlook's HTML rendering.
708pub async fn reply_message(
709    http: &reqwest::Client,
710    base_url: &str,
711    access_token: &str,
712    message_id: &str,
713    comment: &str,
714) -> Result<(), ClientError> {
715    let draft_id = create_reply_draft(http, base_url, access_token, message_id, comment).await?;
716    send_draft(http, base_url, access_token, &draft_id).await
717}
718
719/// Reply-all variant of `reply_message`.
720pub async fn reply_all_message(
721    http: &reqwest::Client,
722    base_url: &str,
723    access_token: &str,
724    message_id: &str,
725    comment: &str,
726) -> Result<(), ClientError> {
727    let draft_id =
728        create_reply_all_draft(http, base_url, access_token, message_id, comment).await?;
729    send_draft(http, base_url, access_token, &draft_id).await
730}
731
732/// Forward — sends immediately, with HTML-formatted comment.
733pub async fn forward_message(
734    http: &reqwest::Client,
735    base_url: &str,
736    access_token: &str,
737    message_id: &str,
738    to: &[String],
739    comment: &str,
740) -> Result<(), ClientError> {
741    let draft_id =
742        create_forward_draft(http, base_url, access_token, message_id, to, comment).await?;
743    send_draft(http, base_url, access_token, &draft_id).await
744}
745
746async fn post_no_body(
747    http: &reqwest::Client,
748    url: &str,
749    access_token: &str,
750    body: &serde_json::Value,
751) -> Result<(), ClientError> {
752    let resp = http
753        .post(url)
754        .bearer_auth(access_token)
755        .json(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
769/// POST /me/messages — create a draft message in the Drafts folder.
770/// Returns the new draft's Graph message ID.
771pub async fn create_draft(
772    http: &reqwest::Client,
773    base_url: &str,
774    access_token: &str,
775    message: &Outgoing,
776) -> Result<String, ClientError> {
777    let url = format!("{base_url}/me/messages");
778    let body = message.to_graph_json();
779    let resp = http
780        .post(&url)
781        .bearer_auth(access_token)
782        .json(&body)
783        .send()
784        .await?;
785    parse_id_from_response(resp).await
786}
787
788/// POST /me/messages/{id}/createReply — create a reply draft. Returns the
789/// new draft's Graph message ID.
790///
791/// The comment is converted from plain text to HTML and spliced into the
792/// draft body via PATCH, so paragraph- and line-breaks survive Outlook's
793/// HTML rendering. (Graph's own `comment` parameter would collapse them.)
794pub async fn create_reply_draft(
795    http: &reqwest::Client,
796    base_url: &str,
797    access_token: &str,
798    message_id: &str,
799    comment: &str,
800) -> Result<String, ClientError> {
801    let url = format!("{base_url}/me/messages/{message_id}/createReply");
802    let resp = http
803        .post(&url)
804        .bearer_auth(access_token)
805        .json(&serde_json::json!({}))
806        .send()
807        .await?;
808    let draft_id = parse_id_from_response(resp).await?;
809    if !comment.is_empty() {
810        prepend_html_to_draft(
811            http,
812            base_url,
813            access_token,
814            &draft_id,
815            &text_to_html(comment),
816        )
817        .await?;
818    }
819    Ok(draft_id)
820}
821
822/// POST /me/messages/{id}/createReplyAll — create a reply-all draft.
823pub async fn create_reply_all_draft(
824    http: &reqwest::Client,
825    base_url: &str,
826    access_token: &str,
827    message_id: &str,
828    comment: &str,
829) -> Result<String, ClientError> {
830    let url = format!("{base_url}/me/messages/{message_id}/createReplyAll");
831    let resp = http
832        .post(&url)
833        .bearer_auth(access_token)
834        .json(&serde_json::json!({}))
835        .send()
836        .await?;
837    let draft_id = parse_id_from_response(resp).await?;
838    if !comment.is_empty() {
839        prepend_html_to_draft(
840            http,
841            base_url,
842            access_token,
843            &draft_id,
844            &text_to_html(comment),
845        )
846        .await?;
847    }
848    Ok(draft_id)
849}
850
851/// POST /me/messages/{id}/createForward — create a forward draft with the
852/// given recipients already populated.
853pub async fn create_forward_draft(
854    http: &reqwest::Client,
855    base_url: &str,
856    access_token: &str,
857    message_id: &str,
858    to: &[String],
859    comment: &str,
860) -> Result<String, ClientError> {
861    let url = format!("{base_url}/me/messages/{message_id}/createForward");
862    let resp = http
863        .post(&url)
864        .bearer_auth(access_token)
865        .json(&serde_json::json!({
866            "toRecipients": to.iter().map(|addr| serde_json::json!({
867                "emailAddress": { "address": addr }
868            })).collect::<Vec<_>>(),
869        }))
870        .send()
871        .await?;
872    let draft_id = parse_id_from_response(resp).await?;
873    if !comment.is_empty() {
874        prepend_html_to_draft(
875            http,
876            base_url,
877            access_token,
878            &draft_id,
879            &text_to_html(comment),
880        )
881        .await?;
882    }
883    Ok(draft_id)
884}
885
886/// POST /me/messages/{id}/send — send an existing draft.
887pub async fn send_draft(
888    http: &reqwest::Client,
889    base_url: &str,
890    access_token: &str,
891    message_id: &str,
892) -> Result<(), ClientError> {
893    let url = format!("{base_url}/me/messages/{message_id}/send");
894    // Graph's /send takes no body but requires `Content-Length: 0`; reqwest
895    // omits that header for body-less requests, so the Graph edge layer
896    // responds with HTTP 411. Sending an explicit empty body forces the
897    // header to land.
898    let resp = http
899        .post(&url)
900        .bearer_auth(access_token)
901        .header(reqwest::header::CONTENT_LENGTH, 0)
902        .body(reqwest::Body::from(""))
903        .send()
904        .await?;
905    let status = resp.status();
906    if !status.is_success() {
907        let text = resp.text().await.unwrap_or_default();
908        return Err(ClientError::Graph {
909            status: status.as_u16(),
910            message: text,
911        });
912    }
913    Ok(())
914}
915
916/// PATCH /me/messages/{id} — overwrite a draft's editable fields. Only the
917/// fields in `Outgoing` are patched, since that's what our wizard owns.
918pub async fn update_draft(
919    http: &reqwest::Client,
920    base_url: &str,
921    access_token: &str,
922    message_id: &str,
923    message: &Outgoing,
924) -> Result<(), ClientError> {
925    let url = format!("{base_url}/me/messages/{message_id}");
926    let body = message.to_graph_json();
927    let resp = http
928        .patch(&url)
929        .bearer_auth(access_token)
930        .json(&body)
931        .send()
932        .await?;
933    let status = resp.status();
934    if !status.is_success() {
935        let text = resp.text().await.unwrap_or_default();
936        return Err(ClientError::Graph {
937            status: status.as_u16(),
938            message: text,
939        });
940    }
941    Ok(())
942}
943
944/// DELETE /me/messages/{id} — moves the message to Deleted Items. Same call
945/// works for drafts and for inbox messages; the destination folder differs
946/// only by what the user is currently in.
947pub async fn delete_message(
948    http: &reqwest::Client,
949    base_url: &str,
950    access_token: &str,
951    message_id: &str,
952) -> Result<(), ClientError> {
953    let url = format!("{base_url}/me/messages/{message_id}");
954    let resp = http.delete(&url).bearer_auth(access_token).send().await?;
955    let status = resp.status();
956    if !status.is_success() {
957        let text = resp.text().await.unwrap_or_default();
958        return Err(ClientError::Graph {
959            status: status.as_u16(),
960            message: text,
961        });
962    }
963    Ok(())
964}
965
966/// POST /me/messages/{id}/attachments — attach a file to a draft.
967///
968/// Uses Graph's simple (non-resumable) upload, which is limited to ~3 MB per
969/// attachment. Larger files require `createUploadSession`, which isn't wired
970/// yet — the CLI rejects oversized attachments before calling this.
971pub async fn add_attachment(
972    http: &reqwest::Client,
973    base_url: &str,
974    access_token: &str,
975    message_id: &str,
976    name: &str,
977    content_type: &str,
978    bytes: &[u8],
979) -> Result<String, ClientError> {
980    use base64::Engine;
981    use base64::engine::general_purpose::STANDARD;
982
983    let url = format!("{base_url}/me/messages/{message_id}/attachments");
984    let body = serde_json::json!({
985        "@odata.type": "#microsoft.graph.fileAttachment",
986        "name": name,
987        "contentType": content_type,
988        "contentBytes": STANDARD.encode(bytes),
989    });
990    let resp = http
991        .post(&url)
992        .bearer_auth(access_token)
993        .json(&body)
994        .send()
995        .await?;
996    parse_id_from_response(resp).await
997}
998
999/// DELETE /me/messages/{id}/attachments/{att_id}.
1000pub async fn delete_attachment(
1001    http: &reqwest::Client,
1002    base_url: &str,
1003    access_token: &str,
1004    message_id: &str,
1005    attachment_id: &str,
1006) -> Result<(), ClientError> {
1007    let url = format!("{base_url}/me/messages/{message_id}/attachments/{attachment_id}");
1008    let resp = http.delete(&url).bearer_auth(access_token).send().await?;
1009    let status = resp.status();
1010    if !status.is_success() {
1011        let text = resp.text().await.unwrap_or_default();
1012        return Err(ClientError::Graph {
1013            status: status.as_u16(),
1014            message: text,
1015        });
1016    }
1017    Ok(())
1018}
1019
1020async fn parse_id_from_response(resp: reqwest::Response) -> Result<String, ClientError> {
1021    let status = resp.status();
1022    if !status.is_success() {
1023        let text = resp.text().await.unwrap_or_default();
1024        return Err(ClientError::Graph {
1025            status: status.as_u16(),
1026            message: text,
1027        });
1028    }
1029    let v: serde_json::Value = resp.json().await?;
1030    v["id"]
1031        .as_str()
1032        .map(|s| s.to_string())
1033        .ok_or_else(|| ClientError::Graph {
1034            status: 200,
1035            message: "draft response missing 'id'".to_string(),
1036        })
1037}
1038
1039/// What the user is sending — pre-Graph-serialization shape.
1040#[derive(Debug, Clone)]
1041pub struct Outgoing {
1042    pub subject: String,
1043    pub body_text: String,
1044    pub to: Vec<String>,
1045    pub cc: Vec<String>,
1046    pub bcc: Vec<String>,
1047}
1048
1049impl Outgoing {
1050    fn to_graph_json(&self) -> serde_json::Value {
1051        fn addresses(list: &[String]) -> Vec<serde_json::Value> {
1052            list.iter()
1053                .map(|addr| serde_json::json!({ "emailAddress": { "address": addr } }))
1054                .collect()
1055        }
1056        serde_json::json!({
1057            "subject": self.subject,
1058            "body": {
1059                "contentType": "Text",
1060                "content": self.body_text,
1061            },
1062            "toRecipients": addresses(&self.to),
1063            "ccRecipients": addresses(&self.cc),
1064            "bccRecipients": addresses(&self.bcc),
1065        })
1066    }
1067}
1068
1069/// POST /me/messages/{id}/move — move the message to another folder.
1070///
1071/// `destination` is either a Graph folder ID or a well-known folder name
1072/// (`"archive"`, `"deleteditems"`, `"junkemail"`, …). Graph returns the new
1073/// message (it gets a new ID in the target folder); we discard that since
1074/// the caller's cache will be refreshed on the next list/search.
1075pub async fn move_message(
1076    http: &reqwest::Client,
1077    base_url: &str,
1078    access_token: &str,
1079    message_id: &str,
1080    destination: &str,
1081) -> Result<(), ClientError> {
1082    let url = format!("{base_url}/me/messages/{message_id}/move");
1083    let resp = http
1084        .post(&url)
1085        .bearer_auth(access_token)
1086        .json(&serde_json::json!({ "destinationId": destination }))
1087        .send()
1088        .await?;
1089    let status = resp.status();
1090    if !status.is_success() {
1091        let text = resp.text().await.unwrap_or_default();
1092        return Err(ClientError::Graph {
1093            status: status.as_u16(),
1094            message: text,
1095        });
1096    }
1097    Ok(())
1098}
1099
1100#[cfg(test)]
1101mod tests {
1102    use super::*;
1103    use wiremock::matchers::{header, method, path, path_regex, query_param};
1104    use wiremock::{Mock, MockServer, ResponseTemplate};
1105
1106    #[tokio::test]
1107    async fn list_inbox_parses_graph_response() {
1108        let server = MockServer::start().await;
1109        Mock::given(method("GET"))
1110            .and(path("/me/mailFolders/inbox/messages"))
1111            .and(header("authorization", "Bearer AT"))
1112            .and(query_param("$top", "5"))
1113            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1114                "value": [
1115                    {
1116                        "id": "AAAA",
1117                        "subject": "Hello",
1118                        "from": {
1119                            "emailAddress": {
1120                                "name": "Maria",
1121                                "address": "maria@mklab.se"
1122                            }
1123                        },
1124                        "receivedDateTime": "2026-05-13T22:00:00Z",
1125                        "isRead": false,
1126                        "bodyPreview": "Hi there"
1127                    }
1128                ]
1129            })))
1130            .mount(&server)
1131            .await;
1132
1133        let http = reqwest::Client::new();
1134        let page = list_inbox(&http, &server.uri(), "AT", "u@e.com", 5, 0, false)
1135            .await
1136            .unwrap();
1137        assert_eq!(page.messages.len(), 1);
1138        assert_eq!(page.messages[0].subject, "Hello");
1139        assert_eq!(page.messages[0].from.address, "maria@mklab.se");
1140        assert!(!page.messages[0].is_read);
1141        assert_eq!(page.messages[0].account, "u@e.com");
1142        assert!(!page.has_more);
1143    }
1144
1145    #[tokio::test]
1146    async fn list_inbox_adds_filter_when_unread_only() {
1147        let server = MockServer::start().await;
1148        Mock::given(method("GET"))
1149            .and(path("/me/mailFolders/inbox/messages"))
1150            .and(query_param("$filter", "isRead eq false"))
1151            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1152                "value": []
1153            })))
1154            .mount(&server)
1155            .await;
1156
1157        let http = reqwest::Client::new();
1158        let page = list_inbox(&http, &server.uri(), "AT", "u@e.com", 5, 0, true)
1159            .await
1160            .unwrap();
1161        assert!(page.messages.is_empty());
1162    }
1163
1164    #[tokio::test]
1165    async fn list_inbox_passes_skip_when_nonzero() {
1166        let server = MockServer::start().await;
1167        Mock::given(method("GET"))
1168            .and(path("/me/mailFolders/inbox/messages"))
1169            .and(query_param("$skip", "25"))
1170            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1171                "value": [],
1172                "@odata.nextLink": "https://graph.example/next"
1173            })))
1174            .mount(&server)
1175            .await;
1176
1177        let http = reqwest::Client::new();
1178        let page = list_inbox(&http, &server.uri(), "AT", "u@e.com", 25, 25, false)
1179            .await
1180            .unwrap();
1181        assert!(page.has_more);
1182    }
1183
1184    #[tokio::test]
1185    async fn search_messages_passes_search_query() {
1186        let server = MockServer::start().await;
1187        Mock::given(method("GET"))
1188            .and(path("/me/messages"))
1189            .and(query_param("$search", "\"alice budget\""))
1190            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1191                "value": [
1192                    {
1193                        "id": "S1",
1194                        "subject": "Q4 budget review",
1195                        "from": { "emailAddress": { "name": "Alice", "address": "alice@example.com" } },
1196                        "receivedDateTime": "2026-05-13T22:00:00Z",
1197                        "isRead": true,
1198                        "bodyPreview": "Numbers attached"
1199                    }
1200                ]
1201            })))
1202            .mount(&server)
1203            .await;
1204
1205        let http = reqwest::Client::new();
1206        let msgs = search_messages(&http, &server.uri(), "AT", "u@e.com", "alice budget", 25)
1207            .await
1208            .unwrap();
1209        assert_eq!(msgs.len(), 1);
1210        assert_eq!(msgs[0].subject, "Q4 budget review");
1211    }
1212
1213    #[tokio::test]
1214    async fn mark_unread_patches_isread_false() {
1215        let server = MockServer::start().await;
1216        Mock::given(method("PATCH"))
1217            .and(path_regex("/me/messages/[A-Za-z0-9]+"))
1218            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
1219            .mount(&server)
1220            .await;
1221        let http = reqwest::Client::new();
1222        mark_unread(&http, &server.uri(), "AT", "MSG")
1223            .await
1224            .unwrap();
1225    }
1226
1227    #[tokio::test]
1228    async fn set_flag_patches_flag_status() {
1229        let server = MockServer::start().await;
1230        Mock::given(method("PATCH"))
1231            .and(path_regex("/me/messages/[A-Za-z0-9]+"))
1232            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
1233            .mount(&server)
1234            .await;
1235        let http = reqwest::Client::new();
1236        set_flag(&http, &server.uri(), "AT", "MSG", true)
1237            .await
1238            .unwrap();
1239        set_flag(&http, &server.uri(), "AT", "MSG", false)
1240            .await
1241            .unwrap();
1242    }
1243
1244    #[tokio::test]
1245    async fn send_mail_wraps_outgoing_in_message_envelope() {
1246        use wiremock::matchers::body_partial_json;
1247        let server = MockServer::start().await;
1248        Mock::given(method("POST"))
1249            .and(path("/me/sendMail"))
1250            .and(body_partial_json(serde_json::json!({
1251                "saveToSentItems": true,
1252                "message": {
1253                    "subject": "Hello",
1254                    "body": { "contentType": "Text", "content": "Hi there" },
1255                    "toRecipients": [{ "emailAddress": { "address": "alice@example.com" } }]
1256                }
1257            })))
1258            .respond_with(ResponseTemplate::new(202))
1259            .mount(&server)
1260            .await;
1261        let http = reqwest::Client::new();
1262        let msg = Outgoing {
1263            subject: "Hello".into(),
1264            body_text: "Hi there".into(),
1265            to: vec!["alice@example.com".into()],
1266            cc: vec![],
1267            bcc: vec![],
1268        };
1269        send_mail(&http, &server.uri(), "AT", &msg).await.unwrap();
1270    }
1271
1272    #[tokio::test]
1273    async fn reply_message_creates_draft_patches_body_and_sends() {
1274        use wiremock::matchers::body_partial_json;
1275        let server = MockServer::start().await;
1276
1277        // 1. createReply → returns draft ID
1278        Mock::given(method("POST"))
1279            .and(path_regex("/me/messages/MSG/createReply"))
1280            .respond_with(
1281                ResponseTemplate::new(201).set_body_json(serde_json::json!({ "id": "DRAFT" })),
1282            )
1283            .mount(&server)
1284            .await;
1285        // 2. GET draft body
1286        Mock::given(method("GET"))
1287            .and(path("/me/messages/DRAFT"))
1288            .and(query_param("$select", "body"))
1289            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1290                "body": { "contentType": "HTML", "content": "<html><body><div></div></body></html>" }
1291            })))
1292            .mount(&server)
1293            .await;
1294        // 3. PATCH draft body — verify our HTML lands in `body.content`
1295        Mock::given(method("PATCH"))
1296            .and(path("/me/messages/DRAFT"))
1297            .and(body_partial_json(serde_json::json!({
1298                "body": { "contentType": "HTML" }
1299            })))
1300            .respond_with(ResponseTemplate::new(200))
1301            .mount(&server)
1302            .await;
1303        // 4. send
1304        Mock::given(method("POST"))
1305            .and(path("/me/messages/DRAFT/send"))
1306            .respond_with(ResponseTemplate::new(202))
1307            .mount(&server)
1308            .await;
1309
1310        let http = reqwest::Client::new();
1311        reply_message(&http, &server.uri(), "AT", "MSG", "Thanks!")
1312            .await
1313            .unwrap();
1314    }
1315
1316    #[tokio::test]
1317    async fn forward_message_creates_draft_with_recipients_and_sends() {
1318        use wiremock::matchers::body_partial_json;
1319        let server = MockServer::start().await;
1320
1321        Mock::given(method("POST"))
1322            .and(path_regex("/me/messages/MSG/createForward"))
1323            .and(body_partial_json(serde_json::json!({
1324                "toRecipients": [{ "emailAddress": { "address": "bob@example.com" } }]
1325            })))
1326            .respond_with(
1327                ResponseTemplate::new(201).set_body_json(serde_json::json!({ "id": "DRAFT" })),
1328            )
1329            .mount(&server)
1330            .await;
1331        Mock::given(method("GET"))
1332            .and(path("/me/messages/DRAFT"))
1333            .and(query_param("$select", "body"))
1334            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1335                "body": { "contentType": "HTML", "content": "<html><body></body></html>" }
1336            })))
1337            .mount(&server)
1338            .await;
1339        Mock::given(method("PATCH"))
1340            .and(path("/me/messages/DRAFT"))
1341            .respond_with(ResponseTemplate::new(200))
1342            .mount(&server)
1343            .await;
1344        Mock::given(method("POST"))
1345            .and(path("/me/messages/DRAFT/send"))
1346            .respond_with(ResponseTemplate::new(202))
1347            .mount(&server)
1348            .await;
1349
1350        let http = reqwest::Client::new();
1351        forward_message(
1352            &http,
1353            &server.uri(),
1354            "AT",
1355            "MSG",
1356            &["bob@example.com".into()],
1357            "FYI",
1358        )
1359        .await
1360        .unwrap();
1361    }
1362
1363    #[test]
1364    fn text_to_html_escapes_and_breaks_paragraphs() {
1365        let out = text_to_html("Hej Edward,\n\nLine 1\nLine 2\n\n<script>x</script>");
1366        assert!(out.contains("<p>Hej Edward,</p>"));
1367        assert!(out.contains("<p>Line 1<br>Line 2</p>"));
1368        assert!(out.contains("&lt;script&gt;x&lt;/script&gt;"));
1369        assert!(!out.contains("<script>"));
1370    }
1371
1372    #[test]
1373    fn text_to_html_normalizes_crlf() {
1374        let out = text_to_html("A\r\nB\r\n\r\nC");
1375        assert!(out.contains("<p>A<br>B</p>"));
1376        assert!(out.contains("<p>C</p>"));
1377    }
1378
1379    #[test]
1380    fn find_body_tag_end_handles_attributes_and_case() {
1381        let html = "<html><BODY class=\"x\">content</BODY></html>";
1382        let pos = find_body_tag_end(html).unwrap();
1383        assert_eq!(&html[pos..pos + 7], "content");
1384    }
1385
1386    #[tokio::test]
1387    async fn move_message_posts_destination() {
1388        let server = MockServer::start().await;
1389        Mock::given(method("POST"))
1390            .and(path_regex("/me/messages/[A-Za-z0-9]+/move"))
1391            .respond_with(
1392                ResponseTemplate::new(201).set_body_json(serde_json::json!({"id": "NEW"})),
1393            )
1394            .mount(&server)
1395            .await;
1396        let http = reqwest::Client::new();
1397        move_message(&http, &server.uri(), "AT", "MSG", "archive")
1398            .await
1399            .unwrap();
1400    }
1401
1402    #[tokio::test]
1403    async fn get_message_parses_graph_response() {
1404        let server = MockServer::start().await;
1405        Mock::given(method("GET"))
1406            .and(path_regex("/me/messages/[A-Za-z0-9]+"))
1407            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1408                "id": "AAA",
1409                "subject": "Hello",
1410                "from": { "emailAddress": { "name": "Maria", "address": "maria@mklab.se" } },
1411                "toRecipients": [
1412                    { "emailAddress": { "name": "Kristofer", "address": "kristofer@mklab.se" } }
1413                ],
1414                "ccRecipients": [],
1415                "bccRecipients": [],
1416                "receivedDateTime": "2026-05-14T22:00:00Z",
1417                "sentDateTime": "2026-05-14T21:59:30Z",
1418                "isRead": false,
1419                "body": { "contentType": "html", "content": "<p>Hi</p>" },
1420                "hasAttachments": true
1421            })))
1422            .mount(&server)
1423            .await;
1424
1425        let http = reqwest::Client::new();
1426        let m = get_message(&http, &server.uri(), "AT", "u@e.com", "AAA")
1427            .await
1428            .unwrap();
1429        assert_eq!(m.id, "AAA");
1430        assert_eq!(m.subject, "Hello");
1431        assert_eq!(m.from.name, "Maria");
1432        assert_eq!(m.to.len(), 1);
1433        assert_eq!(m.to[0].address, "kristofer@mklab.se");
1434        assert!(matches!(
1435            m.body_content_type,
1436            pidge_core::BodyContentType::Html
1437        ));
1438        assert_eq!(m.body_content, "<p>Hi</p>");
1439        assert!(m.has_attachments);
1440    }
1441
1442    #[tokio::test]
1443    async fn list_attachments_filters_file_attachments() {
1444        let server = MockServer::start().await;
1445        Mock::given(method("GET"))
1446            .and(path_regex("/me/messages/[A-Za-z0-9]+/attachments"))
1447            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1448                "value": [
1449                    {
1450                        "@odata.type": "#microsoft.graph.fileAttachment",
1451                        "id": "att-1",
1452                        "name": "report.pdf",
1453                        "contentType": "application/pdf",
1454                        "size": 12345,
1455                        "isInline": false
1456                    },
1457                    {
1458                        "@odata.type": "#microsoft.graph.itemAttachment",
1459                        "id": "att-2",
1460                        "name": "an-email.eml",
1461                        "contentType": "message/rfc822",
1462                        "size": 7777,
1463                        "isInline": false
1464                    }
1465                ]
1466            })))
1467            .mount(&server)
1468            .await;
1469
1470        let http = reqwest::Client::new();
1471        let atts = list_attachments(&http, &server.uri(), "AT", "MSG")
1472            .await
1473            .unwrap();
1474        assert_eq!(atts.len(), 1);
1475        assert_eq!(atts[0].name, "report.pdf");
1476        assert_eq!(atts[0].size_bytes, 12345);
1477    }
1478
1479    #[tokio::test]
1480    async fn get_attachment_bytes_decodes_base64() {
1481        let server = MockServer::start().await;
1482        Mock::given(method("GET"))
1483            .and(path_regex(
1484                "/me/messages/[A-Za-z0-9]+/attachments/[A-Za-z0-9-]+",
1485            ))
1486            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1487                "id": "att-1",
1488                "name": "report.pdf",
1489                "contentType": "application/pdf",
1490                "size": 5,
1491                "isInline": false,
1492                "contentBytes": "aGVsbG8="
1493            })))
1494            .mount(&server)
1495            .await;
1496
1497        let http = reqwest::Client::new();
1498        let bytes = get_attachment_bytes(&http, &server.uri(), "AT", "MSG", "att-1")
1499            .await
1500            .unwrap();
1501        assert_eq!(bytes, b"hello");
1502    }
1503
1504    #[tokio::test]
1505    async fn mark_read_patches_isread_true() {
1506        let server = MockServer::start().await;
1507        Mock::given(method("PATCH"))
1508            .and(path_regex("/me/messages/[A-Za-z0-9]+"))
1509            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
1510            .mount(&server)
1511            .await;
1512
1513        let http = reqwest::Client::new();
1514        mark_read(&http, &server.uri(), "AT", "MSG").await.unwrap();
1515    }
1516
1517    #[tokio::test]
1518    async fn fetch_message_headers_parses_array() {
1519        let server = MockServer::start().await;
1520        Mock::given(method("GET"))
1521            .and(path_regex(r"^/me/messages/.+$"))
1522            .and(query_param("$select", "internetMessageHeaders"))
1523            .and(header("authorization", "Bearer AT"))
1524            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1525                "internetMessageHeaders": [
1526                    { "name": "List-Unsubscribe", "value": "<mailto:u@x>, <https://x/u>" },
1527                    { "name": "List-Unsubscribe-Post", "value": "List-Unsubscribe=One-Click" }
1528                ]
1529            })))
1530            .mount(&server)
1531            .await;
1532
1533        let http = reqwest::Client::new();
1534        let headers = fetch_message_headers(&http, &server.uri(), "AT", "MSGID")
1535            .await
1536            .unwrap();
1537        assert_eq!(headers.len(), 2);
1538        assert_eq!(headers[0].0, "List-Unsubscribe");
1539        assert_eq!(headers[0].1, "<mailto:u@x>, <https://x/u>");
1540        assert_eq!(headers[1].0, "List-Unsubscribe-Post");
1541    }
1542}