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/// POST /me/messages/{id}/reply — reply to a message with an optional comment
600/// prepended to Graph's auto-generated quoted text.
601pub async fn reply_message(
602    http: &reqwest::Client,
603    base_url: &str,
604    access_token: &str,
605    message_id: &str,
606    comment: &str,
607) -> Result<(), ClientError> {
608    let url = format!("{base_url}/me/messages/{message_id}/reply");
609    let body = serde_json::json!({ "comment": comment });
610    post_no_body(http, &url, access_token, &body).await
611}
612
613/// POST /me/messages/{id}/replyAll — reply to every recipient on the thread.
614pub async fn reply_all_message(
615    http: &reqwest::Client,
616    base_url: &str,
617    access_token: &str,
618    message_id: &str,
619    comment: &str,
620) -> Result<(), ClientError> {
621    let url = format!("{base_url}/me/messages/{message_id}/replyAll");
622    let body = serde_json::json!({ "comment": comment });
623    post_no_body(http, &url, access_token, &body).await
624}
625
626/// POST /me/messages/{id}/forward — forward to new recipients with optional comment.
627pub async fn forward_message(
628    http: &reqwest::Client,
629    base_url: &str,
630    access_token: &str,
631    message_id: &str,
632    to: &[String],
633    comment: &str,
634) -> Result<(), ClientError> {
635    let url = format!("{base_url}/me/messages/{message_id}/forward");
636    let body = serde_json::json!({
637        "comment": comment,
638        "toRecipients": to.iter().map(|addr| serde_json::json!({
639            "emailAddress": { "address": addr }
640        })).collect::<Vec<_>>(),
641    });
642    post_no_body(http, &url, access_token, &body).await
643}
644
645async fn post_no_body(
646    http: &reqwest::Client,
647    url: &str,
648    access_token: &str,
649    body: &serde_json::Value,
650) -> Result<(), ClientError> {
651    let resp = http
652        .post(url)
653        .bearer_auth(access_token)
654        .json(body)
655        .send()
656        .await?;
657    let status = resp.status();
658    if !status.is_success() {
659        let text = resp.text().await.unwrap_or_default();
660        return Err(ClientError::Graph {
661            status: status.as_u16(),
662            message: text,
663        });
664    }
665    Ok(())
666}
667
668/// POST /me/messages — create a draft message in the Drafts folder.
669/// Returns the new draft's Graph message ID.
670pub async fn create_draft(
671    http: &reqwest::Client,
672    base_url: &str,
673    access_token: &str,
674    message: &Outgoing,
675) -> Result<String, ClientError> {
676    let url = format!("{base_url}/me/messages");
677    let body = message.to_graph_json();
678    let resp = http
679        .post(&url)
680        .bearer_auth(access_token)
681        .json(&body)
682        .send()
683        .await?;
684    parse_id_from_response(resp).await
685}
686
687/// POST /me/messages/{id}/createReply — create a reply draft. Returns the
688/// new draft's Graph message ID.
689pub async fn create_reply_draft(
690    http: &reqwest::Client,
691    base_url: &str,
692    access_token: &str,
693    message_id: &str,
694    comment: &str,
695) -> Result<String, ClientError> {
696    let url = format!("{base_url}/me/messages/{message_id}/createReply");
697    let resp = http
698        .post(&url)
699        .bearer_auth(access_token)
700        .json(&serde_json::json!({ "comment": comment }))
701        .send()
702        .await?;
703    parse_id_from_response(resp).await
704}
705
706/// POST /me/messages/{id}/createReplyAll — create a reply-all draft.
707pub async fn create_reply_all_draft(
708    http: &reqwest::Client,
709    base_url: &str,
710    access_token: &str,
711    message_id: &str,
712    comment: &str,
713) -> Result<String, ClientError> {
714    let url = format!("{base_url}/me/messages/{message_id}/createReplyAll");
715    let resp = http
716        .post(&url)
717        .bearer_auth(access_token)
718        .json(&serde_json::json!({ "comment": comment }))
719        .send()
720        .await?;
721    parse_id_from_response(resp).await
722}
723
724/// POST /me/messages/{id}/createForward — create a forward draft with the
725/// given recipients already populated.
726pub async fn create_forward_draft(
727    http: &reqwest::Client,
728    base_url: &str,
729    access_token: &str,
730    message_id: &str,
731    to: &[String],
732    comment: &str,
733) -> Result<String, ClientError> {
734    let url = format!("{base_url}/me/messages/{message_id}/createForward");
735    let resp = http
736        .post(&url)
737        .bearer_auth(access_token)
738        .json(&serde_json::json!({
739            "comment": comment,
740            "toRecipients": to.iter().map(|addr| serde_json::json!({
741                "emailAddress": { "address": addr }
742            })).collect::<Vec<_>>(),
743        }))
744        .send()
745        .await?;
746    parse_id_from_response(resp).await
747}
748
749/// POST /me/messages/{id}/send — send an existing draft.
750pub async fn send_draft(
751    http: &reqwest::Client,
752    base_url: &str,
753    access_token: &str,
754    message_id: &str,
755) -> Result<(), ClientError> {
756    let url = format!("{base_url}/me/messages/{message_id}/send");
757    // Graph's /send takes no body but requires `Content-Length: 0`; reqwest
758    // omits that header for body-less requests, so the Graph edge layer
759    // responds with HTTP 411. Sending an explicit empty body forces the
760    // header to land.
761    let resp = http
762        .post(&url)
763        .bearer_auth(access_token)
764        .header(reqwest::header::CONTENT_LENGTH, 0)
765        .body(reqwest::Body::from(""))
766        .send()
767        .await?;
768    let status = resp.status();
769    if !status.is_success() {
770        let text = resp.text().await.unwrap_or_default();
771        return Err(ClientError::Graph {
772            status: status.as_u16(),
773            message: text,
774        });
775    }
776    Ok(())
777}
778
779/// PATCH /me/messages/{id} — overwrite a draft's editable fields. Only the
780/// fields in `Outgoing` are patched, since that's what our wizard owns.
781pub async fn update_draft(
782    http: &reqwest::Client,
783    base_url: &str,
784    access_token: &str,
785    message_id: &str,
786    message: &Outgoing,
787) -> Result<(), ClientError> {
788    let url = format!("{base_url}/me/messages/{message_id}");
789    let body = message.to_graph_json();
790    let resp = http
791        .patch(&url)
792        .bearer_auth(access_token)
793        .json(&body)
794        .send()
795        .await?;
796    let status = resp.status();
797    if !status.is_success() {
798        let text = resp.text().await.unwrap_or_default();
799        return Err(ClientError::Graph {
800            status: status.as_u16(),
801            message: text,
802        });
803    }
804    Ok(())
805}
806
807/// DELETE /me/messages/{id} — moves the message to Deleted Items. Same call
808/// works for drafts and for inbox messages; the destination folder differs
809/// only by what the user is currently in.
810pub async fn delete_message(
811    http: &reqwest::Client,
812    base_url: &str,
813    access_token: &str,
814    message_id: &str,
815) -> Result<(), ClientError> {
816    let url = format!("{base_url}/me/messages/{message_id}");
817    let resp = http.delete(&url).bearer_auth(access_token).send().await?;
818    let status = resp.status();
819    if !status.is_success() {
820        let text = resp.text().await.unwrap_or_default();
821        return Err(ClientError::Graph {
822            status: status.as_u16(),
823            message: text,
824        });
825    }
826    Ok(())
827}
828
829/// POST /me/messages/{id}/attachments — attach a file to a draft.
830///
831/// Uses Graph's simple (non-resumable) upload, which is limited to ~3 MB per
832/// attachment. Larger files require `createUploadSession`, which isn't wired
833/// yet — the CLI rejects oversized attachments before calling this.
834pub async fn add_attachment(
835    http: &reqwest::Client,
836    base_url: &str,
837    access_token: &str,
838    message_id: &str,
839    name: &str,
840    content_type: &str,
841    bytes: &[u8],
842) -> Result<String, ClientError> {
843    use base64::Engine;
844    use base64::engine::general_purpose::STANDARD;
845
846    let url = format!("{base_url}/me/messages/{message_id}/attachments");
847    let body = serde_json::json!({
848        "@odata.type": "#microsoft.graph.fileAttachment",
849        "name": name,
850        "contentType": content_type,
851        "contentBytes": STANDARD.encode(bytes),
852    });
853    let resp = http
854        .post(&url)
855        .bearer_auth(access_token)
856        .json(&body)
857        .send()
858        .await?;
859    parse_id_from_response(resp).await
860}
861
862/// DELETE /me/messages/{id}/attachments/{att_id}.
863pub async fn delete_attachment(
864    http: &reqwest::Client,
865    base_url: &str,
866    access_token: &str,
867    message_id: &str,
868    attachment_id: &str,
869) -> Result<(), ClientError> {
870    let url = format!("{base_url}/me/messages/{message_id}/attachments/{attachment_id}");
871    let resp = http.delete(&url).bearer_auth(access_token).send().await?;
872    let status = resp.status();
873    if !status.is_success() {
874        let text = resp.text().await.unwrap_or_default();
875        return Err(ClientError::Graph {
876            status: status.as_u16(),
877            message: text,
878        });
879    }
880    Ok(())
881}
882
883async fn parse_id_from_response(resp: reqwest::Response) -> Result<String, ClientError> {
884    let status = resp.status();
885    if !status.is_success() {
886        let text = resp.text().await.unwrap_or_default();
887        return Err(ClientError::Graph {
888            status: status.as_u16(),
889            message: text,
890        });
891    }
892    let v: serde_json::Value = resp.json().await?;
893    v["id"]
894        .as_str()
895        .map(|s| s.to_string())
896        .ok_or_else(|| ClientError::Graph {
897            status: 200,
898            message: "draft response missing 'id'".to_string(),
899        })
900}
901
902/// What the user is sending — pre-Graph-serialization shape.
903#[derive(Debug, Clone)]
904pub struct Outgoing {
905    pub subject: String,
906    pub body_text: String,
907    pub to: Vec<String>,
908    pub cc: Vec<String>,
909    pub bcc: Vec<String>,
910}
911
912impl Outgoing {
913    fn to_graph_json(&self) -> serde_json::Value {
914        fn addresses(list: &[String]) -> Vec<serde_json::Value> {
915            list.iter()
916                .map(|addr| serde_json::json!({ "emailAddress": { "address": addr } }))
917                .collect()
918        }
919        serde_json::json!({
920            "subject": self.subject,
921            "body": {
922                "contentType": "Text",
923                "content": self.body_text,
924            },
925            "toRecipients": addresses(&self.to),
926            "ccRecipients": addresses(&self.cc),
927            "bccRecipients": addresses(&self.bcc),
928        })
929    }
930}
931
932/// POST /me/messages/{id}/move — move the message to another folder.
933///
934/// `destination` is either a Graph folder ID or a well-known folder name
935/// (`"archive"`, `"deleteditems"`, `"junkemail"`, …). Graph returns the new
936/// message (it gets a new ID in the target folder); we discard that since
937/// the caller's cache will be refreshed on the next list/search.
938pub async fn move_message(
939    http: &reqwest::Client,
940    base_url: &str,
941    access_token: &str,
942    message_id: &str,
943    destination: &str,
944) -> Result<(), ClientError> {
945    let url = format!("{base_url}/me/messages/{message_id}/move");
946    let resp = http
947        .post(&url)
948        .bearer_auth(access_token)
949        .json(&serde_json::json!({ "destinationId": destination }))
950        .send()
951        .await?;
952    let status = resp.status();
953    if !status.is_success() {
954        let text = resp.text().await.unwrap_or_default();
955        return Err(ClientError::Graph {
956            status: status.as_u16(),
957            message: text,
958        });
959    }
960    Ok(())
961}
962
963#[cfg(test)]
964mod tests {
965    use super::*;
966    use wiremock::matchers::{header, method, path, path_regex, query_param};
967    use wiremock::{Mock, MockServer, ResponseTemplate};
968
969    #[tokio::test]
970    async fn list_inbox_parses_graph_response() {
971        let server = MockServer::start().await;
972        Mock::given(method("GET"))
973            .and(path("/me/mailFolders/inbox/messages"))
974            .and(header("authorization", "Bearer AT"))
975            .and(query_param("$top", "5"))
976            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
977                "value": [
978                    {
979                        "id": "AAAA",
980                        "subject": "Hello",
981                        "from": {
982                            "emailAddress": {
983                                "name": "Maria",
984                                "address": "maria@mklab.se"
985                            }
986                        },
987                        "receivedDateTime": "2026-05-13T22:00:00Z",
988                        "isRead": false,
989                        "bodyPreview": "Hi there"
990                    }
991                ]
992            })))
993            .mount(&server)
994            .await;
995
996        let http = reqwest::Client::new();
997        let page = list_inbox(&http, &server.uri(), "AT", "u@e.com", 5, 0, false)
998            .await
999            .unwrap();
1000        assert_eq!(page.messages.len(), 1);
1001        assert_eq!(page.messages[0].subject, "Hello");
1002        assert_eq!(page.messages[0].from.address, "maria@mklab.se");
1003        assert!(!page.messages[0].is_read);
1004        assert_eq!(page.messages[0].account, "u@e.com");
1005        assert!(!page.has_more);
1006    }
1007
1008    #[tokio::test]
1009    async fn list_inbox_adds_filter_when_unread_only() {
1010        let server = MockServer::start().await;
1011        Mock::given(method("GET"))
1012            .and(path("/me/mailFolders/inbox/messages"))
1013            .and(query_param("$filter", "isRead eq false"))
1014            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1015                "value": []
1016            })))
1017            .mount(&server)
1018            .await;
1019
1020        let http = reqwest::Client::new();
1021        let page = list_inbox(&http, &server.uri(), "AT", "u@e.com", 5, 0, true)
1022            .await
1023            .unwrap();
1024        assert!(page.messages.is_empty());
1025    }
1026
1027    #[tokio::test]
1028    async fn list_inbox_passes_skip_when_nonzero() {
1029        let server = MockServer::start().await;
1030        Mock::given(method("GET"))
1031            .and(path("/me/mailFolders/inbox/messages"))
1032            .and(query_param("$skip", "25"))
1033            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1034                "value": [],
1035                "@odata.nextLink": "https://graph.example/next"
1036            })))
1037            .mount(&server)
1038            .await;
1039
1040        let http = reqwest::Client::new();
1041        let page = list_inbox(&http, &server.uri(), "AT", "u@e.com", 25, 25, false)
1042            .await
1043            .unwrap();
1044        assert!(page.has_more);
1045    }
1046
1047    #[tokio::test]
1048    async fn search_messages_passes_search_query() {
1049        let server = MockServer::start().await;
1050        Mock::given(method("GET"))
1051            .and(path("/me/messages"))
1052            .and(query_param("$search", "\"alice budget\""))
1053            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1054                "value": [
1055                    {
1056                        "id": "S1",
1057                        "subject": "Q4 budget review",
1058                        "from": { "emailAddress": { "name": "Alice", "address": "alice@example.com" } },
1059                        "receivedDateTime": "2026-05-13T22:00:00Z",
1060                        "isRead": true,
1061                        "bodyPreview": "Numbers attached"
1062                    }
1063                ]
1064            })))
1065            .mount(&server)
1066            .await;
1067
1068        let http = reqwest::Client::new();
1069        let msgs = search_messages(&http, &server.uri(), "AT", "u@e.com", "alice budget", 25)
1070            .await
1071            .unwrap();
1072        assert_eq!(msgs.len(), 1);
1073        assert_eq!(msgs[0].subject, "Q4 budget review");
1074    }
1075
1076    #[tokio::test]
1077    async fn mark_unread_patches_isread_false() {
1078        let server = MockServer::start().await;
1079        Mock::given(method("PATCH"))
1080            .and(path_regex("/me/messages/[A-Za-z0-9]+"))
1081            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
1082            .mount(&server)
1083            .await;
1084        let http = reqwest::Client::new();
1085        mark_unread(&http, &server.uri(), "AT", "MSG")
1086            .await
1087            .unwrap();
1088    }
1089
1090    #[tokio::test]
1091    async fn set_flag_patches_flag_status() {
1092        let server = MockServer::start().await;
1093        Mock::given(method("PATCH"))
1094            .and(path_regex("/me/messages/[A-Za-z0-9]+"))
1095            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
1096            .mount(&server)
1097            .await;
1098        let http = reqwest::Client::new();
1099        set_flag(&http, &server.uri(), "AT", "MSG", true)
1100            .await
1101            .unwrap();
1102        set_flag(&http, &server.uri(), "AT", "MSG", false)
1103            .await
1104            .unwrap();
1105    }
1106
1107    #[tokio::test]
1108    async fn send_mail_wraps_outgoing_in_message_envelope() {
1109        use wiremock::matchers::body_partial_json;
1110        let server = MockServer::start().await;
1111        Mock::given(method("POST"))
1112            .and(path("/me/sendMail"))
1113            .and(body_partial_json(serde_json::json!({
1114                "saveToSentItems": true,
1115                "message": {
1116                    "subject": "Hello",
1117                    "body": { "contentType": "Text", "content": "Hi there" },
1118                    "toRecipients": [{ "emailAddress": { "address": "alice@example.com" } }]
1119                }
1120            })))
1121            .respond_with(ResponseTemplate::new(202))
1122            .mount(&server)
1123            .await;
1124        let http = reqwest::Client::new();
1125        let msg = Outgoing {
1126            subject: "Hello".into(),
1127            body_text: "Hi there".into(),
1128            to: vec!["alice@example.com".into()],
1129            cc: vec![],
1130            bcc: vec![],
1131        };
1132        send_mail(&http, &server.uri(), "AT", &msg).await.unwrap();
1133    }
1134
1135    #[tokio::test]
1136    async fn reply_message_posts_comment() {
1137        use wiremock::matchers::body_partial_json;
1138        let server = MockServer::start().await;
1139        Mock::given(method("POST"))
1140            .and(path_regex("/me/messages/[A-Za-z0-9]+/reply"))
1141            .and(body_partial_json(
1142                serde_json::json!({ "comment": "Thanks!" }),
1143            ))
1144            .respond_with(ResponseTemplate::new(202))
1145            .mount(&server)
1146            .await;
1147        let http = reqwest::Client::new();
1148        reply_message(&http, &server.uri(), "AT", "MSG", "Thanks!")
1149            .await
1150            .unwrap();
1151    }
1152
1153    #[tokio::test]
1154    async fn forward_message_posts_recipients_and_comment() {
1155        use wiremock::matchers::body_partial_json;
1156        let server = MockServer::start().await;
1157        Mock::given(method("POST"))
1158            .and(path_regex("/me/messages/[A-Za-z0-9]+/forward"))
1159            .and(body_partial_json(serde_json::json!({
1160                "comment": "FYI",
1161                "toRecipients": [{ "emailAddress": { "address": "bob@example.com" } }]
1162            })))
1163            .respond_with(ResponseTemplate::new(202))
1164            .mount(&server)
1165            .await;
1166        let http = reqwest::Client::new();
1167        forward_message(
1168            &http,
1169            &server.uri(),
1170            "AT",
1171            "MSG",
1172            &["bob@example.com".into()],
1173            "FYI",
1174        )
1175        .await
1176        .unwrap();
1177    }
1178
1179    #[tokio::test]
1180    async fn move_message_posts_destination() {
1181        let server = MockServer::start().await;
1182        Mock::given(method("POST"))
1183            .and(path_regex("/me/messages/[A-Za-z0-9]+/move"))
1184            .respond_with(
1185                ResponseTemplate::new(201).set_body_json(serde_json::json!({"id": "NEW"})),
1186            )
1187            .mount(&server)
1188            .await;
1189        let http = reqwest::Client::new();
1190        move_message(&http, &server.uri(), "AT", "MSG", "archive")
1191            .await
1192            .unwrap();
1193    }
1194
1195    #[tokio::test]
1196    async fn get_message_parses_graph_response() {
1197        let server = MockServer::start().await;
1198        Mock::given(method("GET"))
1199            .and(path_regex("/me/messages/[A-Za-z0-9]+"))
1200            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1201                "id": "AAA",
1202                "subject": "Hello",
1203                "from": { "emailAddress": { "name": "Maria", "address": "maria@mklab.se" } },
1204                "toRecipients": [
1205                    { "emailAddress": { "name": "Kristofer", "address": "kristofer@mklab.se" } }
1206                ],
1207                "ccRecipients": [],
1208                "bccRecipients": [],
1209                "receivedDateTime": "2026-05-14T22:00:00Z",
1210                "sentDateTime": "2026-05-14T21:59:30Z",
1211                "isRead": false,
1212                "body": { "contentType": "html", "content": "<p>Hi</p>" },
1213                "hasAttachments": true
1214            })))
1215            .mount(&server)
1216            .await;
1217
1218        let http = reqwest::Client::new();
1219        let m = get_message(&http, &server.uri(), "AT", "u@e.com", "AAA")
1220            .await
1221            .unwrap();
1222        assert_eq!(m.id, "AAA");
1223        assert_eq!(m.subject, "Hello");
1224        assert_eq!(m.from.name, "Maria");
1225        assert_eq!(m.to.len(), 1);
1226        assert_eq!(m.to[0].address, "kristofer@mklab.se");
1227        assert!(matches!(
1228            m.body_content_type,
1229            pidge_core::BodyContentType::Html
1230        ));
1231        assert_eq!(m.body_content, "<p>Hi</p>");
1232        assert!(m.has_attachments);
1233    }
1234
1235    #[tokio::test]
1236    async fn list_attachments_filters_file_attachments() {
1237        let server = MockServer::start().await;
1238        Mock::given(method("GET"))
1239            .and(path_regex("/me/messages/[A-Za-z0-9]+/attachments"))
1240            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1241                "value": [
1242                    {
1243                        "@odata.type": "#microsoft.graph.fileAttachment",
1244                        "id": "att-1",
1245                        "name": "report.pdf",
1246                        "contentType": "application/pdf",
1247                        "size": 12345,
1248                        "isInline": false
1249                    },
1250                    {
1251                        "@odata.type": "#microsoft.graph.itemAttachment",
1252                        "id": "att-2",
1253                        "name": "an-email.eml",
1254                        "contentType": "message/rfc822",
1255                        "size": 7777,
1256                        "isInline": false
1257                    }
1258                ]
1259            })))
1260            .mount(&server)
1261            .await;
1262
1263        let http = reqwest::Client::new();
1264        let atts = list_attachments(&http, &server.uri(), "AT", "MSG")
1265            .await
1266            .unwrap();
1267        assert_eq!(atts.len(), 1);
1268        assert_eq!(atts[0].name, "report.pdf");
1269        assert_eq!(atts[0].size_bytes, 12345);
1270    }
1271
1272    #[tokio::test]
1273    async fn get_attachment_bytes_decodes_base64() {
1274        let server = MockServer::start().await;
1275        Mock::given(method("GET"))
1276            .and(path_regex(
1277                "/me/messages/[A-Za-z0-9]+/attachments/[A-Za-z0-9-]+",
1278            ))
1279            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1280                "id": "att-1",
1281                "name": "report.pdf",
1282                "contentType": "application/pdf",
1283                "size": 5,
1284                "isInline": false,
1285                "contentBytes": "aGVsbG8="
1286            })))
1287            .mount(&server)
1288            .await;
1289
1290        let http = reqwest::Client::new();
1291        let bytes = get_attachment_bytes(&http, &server.uri(), "AT", "MSG", "att-1")
1292            .await
1293            .unwrap();
1294        assert_eq!(bytes, b"hello");
1295    }
1296
1297    #[tokio::test]
1298    async fn mark_read_patches_isread_true() {
1299        let server = MockServer::start().await;
1300        Mock::given(method("PATCH"))
1301            .and(path_regex("/me/messages/[A-Za-z0-9]+"))
1302            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
1303            .mount(&server)
1304            .await;
1305
1306        let http = reqwest::Client::new();
1307        mark_read(&http, &server.uri(), "AT", "MSG").await.unwrap();
1308    }
1309
1310    #[tokio::test]
1311    async fn fetch_message_headers_parses_array() {
1312        let server = MockServer::start().await;
1313        Mock::given(method("GET"))
1314            .and(path_regex(r"^/me/messages/.+$"))
1315            .and(query_param("$select", "internetMessageHeaders"))
1316            .and(header("authorization", "Bearer AT"))
1317            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1318                "internetMessageHeaders": [
1319                    { "name": "List-Unsubscribe", "value": "<mailto:u@x>, <https://x/u>" },
1320                    { "name": "List-Unsubscribe-Post", "value": "List-Unsubscribe=One-Click" }
1321                ]
1322            })))
1323            .mount(&server)
1324            .await;
1325
1326        let http = reqwest::Client::new();
1327        let headers = fetch_message_headers(&http, &server.uri(), "AT", "MSGID")
1328            .await
1329            .unwrap();
1330        assert_eq!(headers.len(), 2);
1331        assert_eq!(headers[0].0, "List-Unsubscribe");
1332        assert_eq!(headers[0].1, "<mailto:u@x>, <https://x/u>");
1333        assert_eq!(headers[1].0, "List-Unsubscribe-Post");
1334    }
1335}