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}/attachments — list attachments without fetching bytes.
382/// Filters to file attachments only.
383pub async fn list_attachments(
384    http: &reqwest::Client,
385    base_url: &str,
386    access_token: &str,
387    message_id: &str,
388) -> Result<Vec<pidge_core::Attachment>, ClientError> {
389    // contentId is intentionally NOT in $select: it lives on the
390    // `microsoft.graph.fileAttachment` subtype, not the base attachment
391    // type, so Graph rejects the query with a 400 when it's in a flat
392    // select clause. We don't currently use content_id in the CLI; if we
393    // ever need it (e.g. to match inline `cid:` references), per-attachment
394    // GETs return it without a cast.
395    let url = format!(
396        "{base_url}/me/messages/{message_id}/attachments\
397         ?$select=id,name,contentType,size,isInline"
398    );
399    let resp = http.get(&url).bearer_auth(access_token).send().await?;
400    let status = resp.status();
401    if !status.is_success() {
402        let text = resp.text().await.unwrap_or_default();
403        return Err(ClientError::Graph {
404            status: status.as_u16(),
405            message: text,
406        });
407    }
408    let list: GraphAttachmentList = resp.json().await?;
409    Ok(list
410        .value
411        .into_iter()
412        .filter(|a| {
413            a.odata_type
414                .as_deref()
415                .map(|t| t == "#microsoft.graph.fileAttachment")
416                .unwrap_or(true)
417        })
418        .map(|a| pidge_core::Attachment {
419            id: a.id,
420            name: a.name.unwrap_or_default(),
421            content_type: a.content_type.unwrap_or_default(),
422            size_bytes: a.size.unwrap_or(0),
423            is_inline: a.is_inline.unwrap_or(false),
424            content_id: a.content_id,
425        })
426        .collect())
427}
428
429/// GET /me/messages/{id}/attachments/{attachment_id} — fetch a single attachment
430/// with its base64 contentBytes. Returns the decoded bytes.
431pub async fn get_attachment_bytes(
432    http: &reqwest::Client,
433    base_url: &str,
434    access_token: &str,
435    message_id: &str,
436    attachment_id: &str,
437) -> Result<Vec<u8>, ClientError> {
438    use base64::Engine;
439    use base64::engine::general_purpose::STANDARD;
440
441    let url = format!("{base_url}/me/messages/{message_id}/attachments/{attachment_id}");
442    let resp = http.get(&url).bearer_auth(access_token).send().await?;
443    let status = resp.status();
444    if !status.is_success() {
445        let text = resp.text().await.unwrap_or_default();
446        return Err(ClientError::Graph {
447            status: status.as_u16(),
448            message: text,
449        });
450    }
451    let g: GraphAttachment = resp.json().await?;
452    let b64 = g.content_bytes.ok_or_else(|| ClientError::Graph {
453        status: 200,
454        message: "attachment response missing contentBytes".to_string(),
455    })?;
456    STANDARD.decode(&b64).map_err(|e| ClientError::Graph {
457        status: 200,
458        message: format!("attachment base64 decode: {e}"),
459    })
460}
461
462/// PATCH /me/messages/{id} — mark the message as read.
463pub async fn mark_read(
464    http: &reqwest::Client,
465    base_url: &str,
466    access_token: &str,
467    message_id: &str,
468) -> Result<(), ClientError> {
469    patch_message(
470        http,
471        base_url,
472        access_token,
473        message_id,
474        &serde_json::json!({ "isRead": true }),
475    )
476    .await
477}
478
479/// PATCH /me/messages/{id} — mark the message as unread.
480pub async fn mark_unread(
481    http: &reqwest::Client,
482    base_url: &str,
483    access_token: &str,
484    message_id: &str,
485) -> Result<(), ClientError> {
486    patch_message(
487        http,
488        base_url,
489        access_token,
490        message_id,
491        &serde_json::json!({ "isRead": false }),
492    )
493    .await
494}
495
496/// PATCH /me/messages/{id} — set or clear the follow-up flag.
497pub async fn set_flag(
498    http: &reqwest::Client,
499    base_url: &str,
500    access_token: &str,
501    message_id: &str,
502    flagged: bool,
503) -> Result<(), ClientError> {
504    let status = if flagged { "flagged" } else { "notFlagged" };
505    patch_message(
506        http,
507        base_url,
508        access_token,
509        message_id,
510        &serde_json::json!({ "flag": { "flagStatus": status } }),
511    )
512    .await
513}
514
515async fn patch_message(
516    http: &reqwest::Client,
517    base_url: &str,
518    access_token: &str,
519    message_id: &str,
520    body: &serde_json::Value,
521) -> Result<(), ClientError> {
522    let url = format!("{base_url}/me/messages/{message_id}");
523    let resp = http
524        .patch(&url)
525        .bearer_auth(access_token)
526        .json(body)
527        .send()
528        .await?;
529    let status = resp.status();
530    if !status.is_success() {
531        let text = resp.text().await.unwrap_or_default();
532        return Err(ClientError::Graph {
533            status: status.as_u16(),
534            message: text,
535        });
536    }
537    Ok(())
538}
539
540/// POST /me/sendMail — compose-and-send a new message in one call.
541///
542/// The Graph endpoint takes ownership of the body — we wrap the `Outgoing`
543/// in `{ "message": ..., "saveToSentItems": true }` so a copy lands in the
544/// sender's Sent Items folder. Returns 202 Accepted on success.
545pub async fn send_mail(
546    http: &reqwest::Client,
547    base_url: &str,
548    access_token: &str,
549    message: &Outgoing,
550) -> Result<(), ClientError> {
551    let url = format!("{base_url}/me/sendMail");
552    let body = serde_json::json!({
553        "message": message.to_graph_json(),
554        "saveToSentItems": true,
555    });
556    post_no_body(http, &url, access_token, &body).await
557}
558
559/// POST /me/messages/{id}/reply — reply to a message with an optional comment
560/// prepended to Graph's auto-generated quoted text.
561pub async fn reply_message(
562    http: &reqwest::Client,
563    base_url: &str,
564    access_token: &str,
565    message_id: &str,
566    comment: &str,
567) -> Result<(), ClientError> {
568    let url = format!("{base_url}/me/messages/{message_id}/reply");
569    let body = serde_json::json!({ "comment": comment });
570    post_no_body(http, &url, access_token, &body).await
571}
572
573/// POST /me/messages/{id}/replyAll — reply to every recipient on the thread.
574pub async fn reply_all_message(
575    http: &reqwest::Client,
576    base_url: &str,
577    access_token: &str,
578    message_id: &str,
579    comment: &str,
580) -> Result<(), ClientError> {
581    let url = format!("{base_url}/me/messages/{message_id}/replyAll");
582    let body = serde_json::json!({ "comment": comment });
583    post_no_body(http, &url, access_token, &body).await
584}
585
586/// POST /me/messages/{id}/forward — forward to new recipients with optional comment.
587pub async fn forward_message(
588    http: &reqwest::Client,
589    base_url: &str,
590    access_token: &str,
591    message_id: &str,
592    to: &[String],
593    comment: &str,
594) -> Result<(), ClientError> {
595    let url = format!("{base_url}/me/messages/{message_id}/forward");
596    let body = serde_json::json!({
597        "comment": comment,
598        "toRecipients": to.iter().map(|addr| serde_json::json!({
599            "emailAddress": { "address": addr }
600        })).collect::<Vec<_>>(),
601    });
602    post_no_body(http, &url, access_token, &body).await
603}
604
605async fn post_no_body(
606    http: &reqwest::Client,
607    url: &str,
608    access_token: &str,
609    body: &serde_json::Value,
610) -> Result<(), ClientError> {
611    let resp = http
612        .post(url)
613        .bearer_auth(access_token)
614        .json(body)
615        .send()
616        .await?;
617    let status = resp.status();
618    if !status.is_success() {
619        let text = resp.text().await.unwrap_or_default();
620        return Err(ClientError::Graph {
621            status: status.as_u16(),
622            message: text,
623        });
624    }
625    Ok(())
626}
627
628/// POST /me/messages — create a draft message in the Drafts folder.
629/// Returns the new draft's Graph message ID.
630pub async fn create_draft(
631    http: &reqwest::Client,
632    base_url: &str,
633    access_token: &str,
634    message: &Outgoing,
635) -> Result<String, ClientError> {
636    let url = format!("{base_url}/me/messages");
637    let body = message.to_graph_json();
638    let resp = http
639        .post(&url)
640        .bearer_auth(access_token)
641        .json(&body)
642        .send()
643        .await?;
644    parse_id_from_response(resp).await
645}
646
647/// POST /me/messages/{id}/createReply — create a reply draft. Returns the
648/// new draft's Graph message ID.
649pub async fn create_reply_draft(
650    http: &reqwest::Client,
651    base_url: &str,
652    access_token: &str,
653    message_id: &str,
654    comment: &str,
655) -> Result<String, ClientError> {
656    let url = format!("{base_url}/me/messages/{message_id}/createReply");
657    let resp = http
658        .post(&url)
659        .bearer_auth(access_token)
660        .json(&serde_json::json!({ "comment": comment }))
661        .send()
662        .await?;
663    parse_id_from_response(resp).await
664}
665
666/// POST /me/messages/{id}/createReplyAll — create a reply-all draft.
667pub async fn create_reply_all_draft(
668    http: &reqwest::Client,
669    base_url: &str,
670    access_token: &str,
671    message_id: &str,
672    comment: &str,
673) -> Result<String, ClientError> {
674    let url = format!("{base_url}/me/messages/{message_id}/createReplyAll");
675    let resp = http
676        .post(&url)
677        .bearer_auth(access_token)
678        .json(&serde_json::json!({ "comment": comment }))
679        .send()
680        .await?;
681    parse_id_from_response(resp).await
682}
683
684/// POST /me/messages/{id}/createForward — create a forward draft with the
685/// given recipients already populated.
686pub async fn create_forward_draft(
687    http: &reqwest::Client,
688    base_url: &str,
689    access_token: &str,
690    message_id: &str,
691    to: &[String],
692    comment: &str,
693) -> Result<String, ClientError> {
694    let url = format!("{base_url}/me/messages/{message_id}/createForward");
695    let resp = http
696        .post(&url)
697        .bearer_auth(access_token)
698        .json(&serde_json::json!({
699            "comment": comment,
700            "toRecipients": to.iter().map(|addr| serde_json::json!({
701                "emailAddress": { "address": addr }
702            })).collect::<Vec<_>>(),
703        }))
704        .send()
705        .await?;
706    parse_id_from_response(resp).await
707}
708
709/// POST /me/messages/{id}/send — send an existing draft.
710pub async fn send_draft(
711    http: &reqwest::Client,
712    base_url: &str,
713    access_token: &str,
714    message_id: &str,
715) -> Result<(), ClientError> {
716    let url = format!("{base_url}/me/messages/{message_id}/send");
717    // Graph's /send takes no body but requires `Content-Length: 0`; reqwest
718    // omits that header for body-less requests, so the Graph edge layer
719    // responds with HTTP 411. Sending an explicit empty body forces the
720    // header to land.
721    let resp = http
722        .post(&url)
723        .bearer_auth(access_token)
724        .header(reqwest::header::CONTENT_LENGTH, 0)
725        .body(reqwest::Body::from(""))
726        .send()
727        .await?;
728    let status = resp.status();
729    if !status.is_success() {
730        let text = resp.text().await.unwrap_or_default();
731        return Err(ClientError::Graph {
732            status: status.as_u16(),
733            message: text,
734        });
735    }
736    Ok(())
737}
738
739/// PATCH /me/messages/{id} — overwrite a draft's editable fields. Only the
740/// fields in `Outgoing` are patched, since that's what our wizard owns.
741pub async fn update_draft(
742    http: &reqwest::Client,
743    base_url: &str,
744    access_token: &str,
745    message_id: &str,
746    message: &Outgoing,
747) -> Result<(), ClientError> {
748    let url = format!("{base_url}/me/messages/{message_id}");
749    let body = message.to_graph_json();
750    let resp = http
751        .patch(&url)
752        .bearer_auth(access_token)
753        .json(&body)
754        .send()
755        .await?;
756    let status = resp.status();
757    if !status.is_success() {
758        let text = resp.text().await.unwrap_or_default();
759        return Err(ClientError::Graph {
760            status: status.as_u16(),
761            message: text,
762        });
763    }
764    Ok(())
765}
766
767/// DELETE /me/messages/{id} — moves the message to Deleted Items. Same call
768/// works for drafts and for inbox messages; the destination folder differs
769/// only by what the user is currently in.
770pub async fn delete_message(
771    http: &reqwest::Client,
772    base_url: &str,
773    access_token: &str,
774    message_id: &str,
775) -> Result<(), ClientError> {
776    let url = format!("{base_url}/me/messages/{message_id}");
777    let resp = http.delete(&url).bearer_auth(access_token).send().await?;
778    let status = resp.status();
779    if !status.is_success() {
780        let text = resp.text().await.unwrap_or_default();
781        return Err(ClientError::Graph {
782            status: status.as_u16(),
783            message: text,
784        });
785    }
786    Ok(())
787}
788
789/// POST /me/messages/{id}/attachments — attach a file to a draft.
790///
791/// Uses Graph's simple (non-resumable) upload, which is limited to ~3 MB per
792/// attachment. Larger files require `createUploadSession`, which isn't wired
793/// yet — the CLI rejects oversized attachments before calling this.
794pub async fn add_attachment(
795    http: &reqwest::Client,
796    base_url: &str,
797    access_token: &str,
798    message_id: &str,
799    name: &str,
800    content_type: &str,
801    bytes: &[u8],
802) -> Result<String, ClientError> {
803    use base64::Engine;
804    use base64::engine::general_purpose::STANDARD;
805
806    let url = format!("{base_url}/me/messages/{message_id}/attachments");
807    let body = serde_json::json!({
808        "@odata.type": "#microsoft.graph.fileAttachment",
809        "name": name,
810        "contentType": content_type,
811        "contentBytes": STANDARD.encode(bytes),
812    });
813    let resp = http
814        .post(&url)
815        .bearer_auth(access_token)
816        .json(&body)
817        .send()
818        .await?;
819    parse_id_from_response(resp).await
820}
821
822/// DELETE /me/messages/{id}/attachments/{att_id}.
823pub async fn delete_attachment(
824    http: &reqwest::Client,
825    base_url: &str,
826    access_token: &str,
827    message_id: &str,
828    attachment_id: &str,
829) -> Result<(), ClientError> {
830    let url = format!("{base_url}/me/messages/{message_id}/attachments/{attachment_id}");
831    let resp = http.delete(&url).bearer_auth(access_token).send().await?;
832    let status = resp.status();
833    if !status.is_success() {
834        let text = resp.text().await.unwrap_or_default();
835        return Err(ClientError::Graph {
836            status: status.as_u16(),
837            message: text,
838        });
839    }
840    Ok(())
841}
842
843async fn parse_id_from_response(resp: reqwest::Response) -> Result<String, ClientError> {
844    let status = resp.status();
845    if !status.is_success() {
846        let text = resp.text().await.unwrap_or_default();
847        return Err(ClientError::Graph {
848            status: status.as_u16(),
849            message: text,
850        });
851    }
852    let v: serde_json::Value = resp.json().await?;
853    v["id"]
854        .as_str()
855        .map(|s| s.to_string())
856        .ok_or_else(|| ClientError::Graph {
857            status: 200,
858            message: "draft response missing 'id'".to_string(),
859        })
860}
861
862/// What the user is sending — pre-Graph-serialization shape.
863#[derive(Debug, Clone)]
864pub struct Outgoing {
865    pub subject: String,
866    pub body_text: String,
867    pub to: Vec<String>,
868    pub cc: Vec<String>,
869    pub bcc: Vec<String>,
870}
871
872impl Outgoing {
873    fn to_graph_json(&self) -> serde_json::Value {
874        fn addresses(list: &[String]) -> Vec<serde_json::Value> {
875            list.iter()
876                .map(|addr| serde_json::json!({ "emailAddress": { "address": addr } }))
877                .collect()
878        }
879        serde_json::json!({
880            "subject": self.subject,
881            "body": {
882                "contentType": "Text",
883                "content": self.body_text,
884            },
885            "toRecipients": addresses(&self.to),
886            "ccRecipients": addresses(&self.cc),
887            "bccRecipients": addresses(&self.bcc),
888        })
889    }
890}
891
892/// POST /me/messages/{id}/move — move the message to another folder.
893///
894/// `destination` is either a Graph folder ID or a well-known folder name
895/// (`"archive"`, `"deleteditems"`, `"junkemail"`, …). Graph returns the new
896/// message (it gets a new ID in the target folder); we discard that since
897/// the caller's cache will be refreshed on the next list/search.
898pub async fn move_message(
899    http: &reqwest::Client,
900    base_url: &str,
901    access_token: &str,
902    message_id: &str,
903    destination: &str,
904) -> Result<(), ClientError> {
905    let url = format!("{base_url}/me/messages/{message_id}/move");
906    let resp = http
907        .post(&url)
908        .bearer_auth(access_token)
909        .json(&serde_json::json!({ "destinationId": destination }))
910        .send()
911        .await?;
912    let status = resp.status();
913    if !status.is_success() {
914        let text = resp.text().await.unwrap_or_default();
915        return Err(ClientError::Graph {
916            status: status.as_u16(),
917            message: text,
918        });
919    }
920    Ok(())
921}
922
923#[cfg(test)]
924mod tests {
925    use super::*;
926    use wiremock::matchers::{header, method, path, path_regex, query_param};
927    use wiremock::{Mock, MockServer, ResponseTemplate};
928
929    #[tokio::test]
930    async fn list_inbox_parses_graph_response() {
931        let server = MockServer::start().await;
932        Mock::given(method("GET"))
933            .and(path("/me/mailFolders/inbox/messages"))
934            .and(header("authorization", "Bearer AT"))
935            .and(query_param("$top", "5"))
936            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
937                "value": [
938                    {
939                        "id": "AAAA",
940                        "subject": "Hello",
941                        "from": {
942                            "emailAddress": {
943                                "name": "Maria",
944                                "address": "maria@mklab.se"
945                            }
946                        },
947                        "receivedDateTime": "2026-05-13T22:00:00Z",
948                        "isRead": false,
949                        "bodyPreview": "Hi there"
950                    }
951                ]
952            })))
953            .mount(&server)
954            .await;
955
956        let http = reqwest::Client::new();
957        let page = list_inbox(&http, &server.uri(), "AT", "u@e.com", 5, 0, false)
958            .await
959            .unwrap();
960        assert_eq!(page.messages.len(), 1);
961        assert_eq!(page.messages[0].subject, "Hello");
962        assert_eq!(page.messages[0].from.address, "maria@mklab.se");
963        assert!(!page.messages[0].is_read);
964        assert_eq!(page.messages[0].account, "u@e.com");
965        assert!(!page.has_more);
966    }
967
968    #[tokio::test]
969    async fn list_inbox_adds_filter_when_unread_only() {
970        let server = MockServer::start().await;
971        Mock::given(method("GET"))
972            .and(path("/me/mailFolders/inbox/messages"))
973            .and(query_param("$filter", "isRead eq false"))
974            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
975                "value": []
976            })))
977            .mount(&server)
978            .await;
979
980        let http = reqwest::Client::new();
981        let page = list_inbox(&http, &server.uri(), "AT", "u@e.com", 5, 0, true)
982            .await
983            .unwrap();
984        assert!(page.messages.is_empty());
985    }
986
987    #[tokio::test]
988    async fn list_inbox_passes_skip_when_nonzero() {
989        let server = MockServer::start().await;
990        Mock::given(method("GET"))
991            .and(path("/me/mailFolders/inbox/messages"))
992            .and(query_param("$skip", "25"))
993            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
994                "value": [],
995                "@odata.nextLink": "https://graph.example/next"
996            })))
997            .mount(&server)
998            .await;
999
1000        let http = reqwest::Client::new();
1001        let page = list_inbox(&http, &server.uri(), "AT", "u@e.com", 25, 25, false)
1002            .await
1003            .unwrap();
1004        assert!(page.has_more);
1005    }
1006
1007    #[tokio::test]
1008    async fn search_messages_passes_search_query() {
1009        let server = MockServer::start().await;
1010        Mock::given(method("GET"))
1011            .and(path("/me/messages"))
1012            .and(query_param("$search", "\"alice budget\""))
1013            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1014                "value": [
1015                    {
1016                        "id": "S1",
1017                        "subject": "Q4 budget review",
1018                        "from": { "emailAddress": { "name": "Alice", "address": "alice@example.com" } },
1019                        "receivedDateTime": "2026-05-13T22:00:00Z",
1020                        "isRead": true,
1021                        "bodyPreview": "Numbers attached"
1022                    }
1023                ]
1024            })))
1025            .mount(&server)
1026            .await;
1027
1028        let http = reqwest::Client::new();
1029        let msgs = search_messages(&http, &server.uri(), "AT", "u@e.com", "alice budget", 25)
1030            .await
1031            .unwrap();
1032        assert_eq!(msgs.len(), 1);
1033        assert_eq!(msgs[0].subject, "Q4 budget review");
1034    }
1035
1036    #[tokio::test]
1037    async fn mark_unread_patches_isread_false() {
1038        let server = MockServer::start().await;
1039        Mock::given(method("PATCH"))
1040            .and(path_regex("/me/messages/[A-Za-z0-9]+"))
1041            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
1042            .mount(&server)
1043            .await;
1044        let http = reqwest::Client::new();
1045        mark_unread(&http, &server.uri(), "AT", "MSG")
1046            .await
1047            .unwrap();
1048    }
1049
1050    #[tokio::test]
1051    async fn set_flag_patches_flag_status() {
1052        let server = MockServer::start().await;
1053        Mock::given(method("PATCH"))
1054            .and(path_regex("/me/messages/[A-Za-z0-9]+"))
1055            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
1056            .mount(&server)
1057            .await;
1058        let http = reqwest::Client::new();
1059        set_flag(&http, &server.uri(), "AT", "MSG", true)
1060            .await
1061            .unwrap();
1062        set_flag(&http, &server.uri(), "AT", "MSG", false)
1063            .await
1064            .unwrap();
1065    }
1066
1067    #[tokio::test]
1068    async fn send_mail_wraps_outgoing_in_message_envelope() {
1069        use wiremock::matchers::body_partial_json;
1070        let server = MockServer::start().await;
1071        Mock::given(method("POST"))
1072            .and(path("/me/sendMail"))
1073            .and(body_partial_json(serde_json::json!({
1074                "saveToSentItems": true,
1075                "message": {
1076                    "subject": "Hello",
1077                    "body": { "contentType": "Text", "content": "Hi there" },
1078                    "toRecipients": [{ "emailAddress": { "address": "alice@example.com" } }]
1079                }
1080            })))
1081            .respond_with(ResponseTemplate::new(202))
1082            .mount(&server)
1083            .await;
1084        let http = reqwest::Client::new();
1085        let msg = Outgoing {
1086            subject: "Hello".into(),
1087            body_text: "Hi there".into(),
1088            to: vec!["alice@example.com".into()],
1089            cc: vec![],
1090            bcc: vec![],
1091        };
1092        send_mail(&http, &server.uri(), "AT", &msg).await.unwrap();
1093    }
1094
1095    #[tokio::test]
1096    async fn reply_message_posts_comment() {
1097        use wiremock::matchers::body_partial_json;
1098        let server = MockServer::start().await;
1099        Mock::given(method("POST"))
1100            .and(path_regex("/me/messages/[A-Za-z0-9]+/reply"))
1101            .and(body_partial_json(
1102                serde_json::json!({ "comment": "Thanks!" }),
1103            ))
1104            .respond_with(ResponseTemplate::new(202))
1105            .mount(&server)
1106            .await;
1107        let http = reqwest::Client::new();
1108        reply_message(&http, &server.uri(), "AT", "MSG", "Thanks!")
1109            .await
1110            .unwrap();
1111    }
1112
1113    #[tokio::test]
1114    async fn forward_message_posts_recipients_and_comment() {
1115        use wiremock::matchers::body_partial_json;
1116        let server = MockServer::start().await;
1117        Mock::given(method("POST"))
1118            .and(path_regex("/me/messages/[A-Za-z0-9]+/forward"))
1119            .and(body_partial_json(serde_json::json!({
1120                "comment": "FYI",
1121                "toRecipients": [{ "emailAddress": { "address": "bob@example.com" } }]
1122            })))
1123            .respond_with(ResponseTemplate::new(202))
1124            .mount(&server)
1125            .await;
1126        let http = reqwest::Client::new();
1127        forward_message(
1128            &http,
1129            &server.uri(),
1130            "AT",
1131            "MSG",
1132            &["bob@example.com".into()],
1133            "FYI",
1134        )
1135        .await
1136        .unwrap();
1137    }
1138
1139    #[tokio::test]
1140    async fn move_message_posts_destination() {
1141        let server = MockServer::start().await;
1142        Mock::given(method("POST"))
1143            .and(path_regex("/me/messages/[A-Za-z0-9]+/move"))
1144            .respond_with(
1145                ResponseTemplate::new(201).set_body_json(serde_json::json!({"id": "NEW"})),
1146            )
1147            .mount(&server)
1148            .await;
1149        let http = reqwest::Client::new();
1150        move_message(&http, &server.uri(), "AT", "MSG", "archive")
1151            .await
1152            .unwrap();
1153    }
1154
1155    #[tokio::test]
1156    async fn get_message_parses_graph_response() {
1157        let server = MockServer::start().await;
1158        Mock::given(method("GET"))
1159            .and(path_regex("/me/messages/[A-Za-z0-9]+"))
1160            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1161                "id": "AAA",
1162                "subject": "Hello",
1163                "from": { "emailAddress": { "name": "Maria", "address": "maria@mklab.se" } },
1164                "toRecipients": [
1165                    { "emailAddress": { "name": "Kristofer", "address": "kristofer@mklab.se" } }
1166                ],
1167                "ccRecipients": [],
1168                "bccRecipients": [],
1169                "receivedDateTime": "2026-05-14T22:00:00Z",
1170                "sentDateTime": "2026-05-14T21:59:30Z",
1171                "isRead": false,
1172                "body": { "contentType": "html", "content": "<p>Hi</p>" },
1173                "hasAttachments": true
1174            })))
1175            .mount(&server)
1176            .await;
1177
1178        let http = reqwest::Client::new();
1179        let m = get_message(&http, &server.uri(), "AT", "u@e.com", "AAA")
1180            .await
1181            .unwrap();
1182        assert_eq!(m.id, "AAA");
1183        assert_eq!(m.subject, "Hello");
1184        assert_eq!(m.from.name, "Maria");
1185        assert_eq!(m.to.len(), 1);
1186        assert_eq!(m.to[0].address, "kristofer@mklab.se");
1187        assert!(matches!(
1188            m.body_content_type,
1189            pidge_core::BodyContentType::Html
1190        ));
1191        assert_eq!(m.body_content, "<p>Hi</p>");
1192        assert!(m.has_attachments);
1193    }
1194
1195    #[tokio::test]
1196    async fn list_attachments_filters_file_attachments() {
1197        let server = MockServer::start().await;
1198        Mock::given(method("GET"))
1199            .and(path_regex("/me/messages/[A-Za-z0-9]+/attachments"))
1200            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1201                "value": [
1202                    {
1203                        "@odata.type": "#microsoft.graph.fileAttachment",
1204                        "id": "att-1",
1205                        "name": "report.pdf",
1206                        "contentType": "application/pdf",
1207                        "size": 12345,
1208                        "isInline": false
1209                    },
1210                    {
1211                        "@odata.type": "#microsoft.graph.itemAttachment",
1212                        "id": "att-2",
1213                        "name": "an-email.eml",
1214                        "contentType": "message/rfc822",
1215                        "size": 7777,
1216                        "isInline": false
1217                    }
1218                ]
1219            })))
1220            .mount(&server)
1221            .await;
1222
1223        let http = reqwest::Client::new();
1224        let atts = list_attachments(&http, &server.uri(), "AT", "MSG")
1225            .await
1226            .unwrap();
1227        assert_eq!(atts.len(), 1);
1228        assert_eq!(atts[0].name, "report.pdf");
1229        assert_eq!(atts[0].size_bytes, 12345);
1230    }
1231
1232    #[tokio::test]
1233    async fn get_attachment_bytes_decodes_base64() {
1234        let server = MockServer::start().await;
1235        Mock::given(method("GET"))
1236            .and(path_regex(
1237                "/me/messages/[A-Za-z0-9]+/attachments/[A-Za-z0-9-]+",
1238            ))
1239            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1240                "id": "att-1",
1241                "name": "report.pdf",
1242                "contentType": "application/pdf",
1243                "size": 5,
1244                "isInline": false,
1245                "contentBytes": "aGVsbG8="
1246            })))
1247            .mount(&server)
1248            .await;
1249
1250        let http = reqwest::Client::new();
1251        let bytes = get_attachment_bytes(&http, &server.uri(), "AT", "MSG", "att-1")
1252            .await
1253            .unwrap();
1254        assert_eq!(bytes, b"hello");
1255    }
1256
1257    #[tokio::test]
1258    async fn mark_read_patches_isread_true() {
1259        let server = MockServer::start().await;
1260        Mock::given(method("PATCH"))
1261            .and(path_regex("/me/messages/[A-Za-z0-9]+"))
1262            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
1263            .mount(&server)
1264            .await;
1265
1266        let http = reqwest::Client::new();
1267        mark_read(&http, &server.uri(), "AT", "MSG").await.unwrap();
1268    }
1269}