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(rename = "conversationId", default)]
26    conversation_id: Option<String>,
27    #[serde(default)]
28    flag: Option<GraphFlag>,
29    #[serde(rename = "toRecipients", default)]
30    to_recipients: Vec<GraphFromWrapper>,
31    #[serde(rename = "ccRecipients", default)]
32    cc_recipients: Vec<GraphFromWrapper>,
33    /// Present on derived types only, e.g. `#microsoft.graph.eventMessage`.
34    #[serde(rename = "@odata.type", default)]
35    odata_type: Option<String>,
36}
37
38/// Whether a Graph `@odata.type` names a meeting request, an invite the
39/// user can respond to. Responses and cancellations are not invites.
40fn is_event_message(odata_type: Option<&str>) -> bool {
41    odata_type == Some("#microsoft.graph.eventMessageRequest")
42}
43
44#[derive(Debug, Deserialize)]
45struct GraphFlag {
46    #[serde(rename = "flagStatus", default)]
47    flag_status: Option<String>,
48}
49
50fn recipient_from(addr: GraphFromAddress) -> MessageFrom {
51    MessageFrom {
52        name: clean(addr.name),
53        address: clean(addr.address),
54    }
55}
56
57/// Third-party text as pidge-core types carry it: control characters
58/// removed (see [`pidge_core::render::strip_controls`]), `None` as empty.
59pub(crate) fn clean(text: Option<String>) -> String {
60    text.as_deref()
61        .map(pidge_core::render::strip_controls)
62        .unwrap_or_default()
63}
64
65/// `serde(deserialize_with)` form of [`clean`] for fields Graph always sends.
66pub(crate) fn clean_string<'de, D: serde::Deserializer<'de>>(d: D) -> Result<String, D::Error> {
67    let s = <String as serde::Deserialize>::deserialize(d)?;
68    Ok(pidge_core::render::strip_controls(&s))
69}
70
71fn unwrap_recipients(rs: Vec<GraphFromWrapper>) -> Vec<MessageFrom> {
72    rs.into_iter()
73        .map(|w| recipient_from(w.email_address))
74        .collect()
75}
76
77fn flag_status_from(g: Option<GraphFlag>) -> FlagStatus {
78    match g.and_then(|f| f.flag_status).as_deref() {
79        Some("flagged") => FlagStatus::Flagged,
80        Some("complete") => FlagStatus::Complete,
81        _ => FlagStatus::NotFlagged,
82    }
83}
84
85#[derive(Debug, Deserialize)]
86struct GraphFromWrapper {
87    #[serde(rename = "emailAddress")]
88    email_address: GraphFromAddress,
89}
90
91#[derive(Debug, Deserialize)]
92struct GraphFromAddress {
93    name: Option<String>,
94    address: Option<String>,
95}
96
97#[derive(Debug, Deserialize)]
98struct GraphList {
99    value: Vec<GraphMessage>,
100    /// Graph returns this when there are more pages. We expose it so the CLI
101    /// can decide whether to keep paging.
102    #[serde(rename = "@odata.nextLink", default)]
103    next_link: Option<String>,
104}
105
106/// One page of inbox messages plus a flag indicating whether more pages exist.
107pub struct InboxPage {
108    pub messages: Vec<Message>,
109    pub has_more: bool,
110    /// Graph continuation URL for the next page (`@odata.nextLink`).
111    pub next_link: Option<String>,
112}
113
114#[derive(Debug, Deserialize)]
115struct GraphFullMessage {
116    id: String,
117    #[serde(rename = "conversationId", default)]
118    conversation_id: Option<String>,
119    subject: Option<String>,
120    from: Option<GraphFromWrapper>,
121    #[serde(rename = "toRecipients", default)]
122    to_recipients: Vec<GraphFromWrapper>,
123    #[serde(rename = "ccRecipients", default)]
124    cc_recipients: Vec<GraphFromWrapper>,
125    #[serde(rename = "bccRecipients", default)]
126    bcc_recipients: Vec<GraphFromWrapper>,
127    #[serde(rename = "receivedDateTime")]
128    received_date_time: chrono::DateTime<chrono::Utc>,
129    #[serde(rename = "sentDateTime")]
130    sent_date_time: chrono::DateTime<chrono::Utc>,
131    #[serde(rename = "isRead")]
132    is_read: Option<bool>,
133    body: GraphBody,
134    #[serde(rename = "hasAttachments")]
135    has_attachments: Option<bool>,
136    #[serde(default)]
137    flag: Option<GraphFlag>,
138    #[serde(rename = "@odata.type", default)]
139    odata_type: Option<String>,
140    #[serde(rename = "isDraft", default)]
141    is_draft: Option<bool>,
142}
143
144#[derive(Debug, Deserialize)]
145struct GraphBody {
146    #[serde(rename = "contentType")]
147    content_type: String,
148    content: String,
149}
150
151#[derive(Debug, Deserialize)]
152struct GraphAttachmentList {
153    value: Vec<GraphAttachment>,
154}
155
156#[derive(Debug, Deserialize)]
157struct GraphAttachment {
158    id: String,
159    name: Option<String>,
160    #[serde(rename = "contentType")]
161    content_type: Option<String>,
162    size: Option<u64>,
163    #[serde(rename = "isInline")]
164    is_inline: Option<bool>,
165    #[serde(rename = "contentId")]
166    content_id: Option<String>,
167    #[serde(rename = "@odata.type", default)]
168    odata_type: Option<String>,
169    /// Only populated when fetching a single attachment (not in list endpoint).
170    #[serde(rename = "contentBytes", default)]
171    content_bytes: Option<String>,
172}
173
174/// List a page of messages from the Inbox folder, sorted by `receivedDateTime desc`.
175///
176/// `skip` is the offset into the result set (page * page_size for 0-based paging).
177/// Returns an `InboxPage` whose `has_more` is true when Graph included an
178/// `@odata.nextLink`, i.e., there are more messages beyond this page.
179pub async fn list_inbox(
180    http: &reqwest::Client,
181    base_url: &str,
182    access_token: &str,
183    account: &str,
184    limit: usize,
185    skip: usize,
186    unread_only: bool,
187) -> Result<InboxPage, ClientError> {
188    list_folder(
189        http,
190        base_url,
191        access_token,
192        account,
193        "inbox",
194        limit,
195        skip,
196        unread_only,
197        None,
198    )
199    .await
200}
201
202/// List a page of messages from an arbitrary folder, identified by its Graph
203/// folder ID (or a well-known name). Same shape as `list_inbox`; used by
204/// `mail list --folder` to page through custom folders.
205#[allow(clippy::too_many_arguments)]
206pub async fn list_folder_messages(
207    http: &reqwest::Client,
208    base_url: &str,
209    access_token: &str,
210    account: &str,
211    folder_id: &str,
212    limit: usize,
213    skip: usize,
214    unread_only: bool,
215) -> Result<InboxPage, ClientError> {
216    list_folder(
217        http,
218        base_url,
219        access_token,
220        account,
221        folder_id,
222        limit,
223        skip,
224        unread_only,
225        None,
226    )
227    .await
228}
229
230/// Like [`list_folder_messages`] with an OData `$filter` of the caller's
231/// (values already escaped, `receivedDateTime` first so Graph accepts it
232/// with the `receivedDateTime desc` order).
233pub async fn list_folder_messages_where(
234    http: &reqwest::Client,
235    base_url: &str,
236    access_token: &str,
237    account: &str,
238    folder_id: &str,
239    limit: usize,
240    filter: &str,
241) -> Result<InboxPage, ClientError> {
242    list_folder(
243        http,
244        base_url,
245        access_token,
246        account,
247        folder_id,
248        limit,
249        0,
250        false,
251        Some(filter),
252    )
253    .await
254}
255
256/// List a page of drafts from the Drafts folder. Drafts don't have a real
257/// "received" time, so Graph sorts by `lastModifiedDateTime desc` here.
258pub async fn list_drafts(
259    http: &reqwest::Client,
260    base_url: &str,
261    access_token: &str,
262    account: &str,
263    limit: usize,
264    skip: usize,
265) -> Result<InboxPage, ClientError> {
266    list_folder(
267        http,
268        base_url,
269        access_token,
270        account,
271        "drafts",
272        limit,
273        skip,
274        false,
275        None,
276    )
277    .await
278}
279
280#[allow(clippy::too_many_arguments)]
281async fn list_folder(
282    http: &reqwest::Client,
283    base_url: &str,
284    access_token: &str,
285    account: &str,
286    folder: &str,
287    limit: usize,
288    skip: usize,
289    unread_only: bool,
290    filter: Option<&str>,
291) -> Result<InboxPage, ClientError> {
292    let url = format!("{base_url}/me/mailFolders/{folder}/messages");
293    // Body is fetched in its native content type (HTML or text) so the
294    // renderer can use html2text to extract anchor text + click targets,
295    // making LINK TEXT (not URLs) the clickable surface in previews.
296    let mut req = http.get(&url).bearer_auth(access_token).query(&[
297        (
298            "$select",
299            "id,subject,from,receivedDateTime,isRead,bodyPreview,body,hasAttachments,flag,conversationId,toRecipients,ccRecipients",
300        ),
301        ("$orderby", "receivedDateTime desc"),
302        ("$top", &limit.to_string()),
303    ]);
304    if skip > 0 {
305        req = req.query(&[("$skip", &skip.to_string())]);
306    }
307    if let Some(filter) = filter {
308        req = req.query(&[("$filter", filter)]);
309    } else if unread_only {
310        req = req.query(&[("$filter", "isRead eq false")]);
311    }
312
313    let resp = super::send_with_retry(req).await?;
314    let status = resp.status();
315    if !status.is_success() {
316        let text = resp.text().await.unwrap_or_default();
317        return Err(ClientError::Graph {
318            status: status.as_u16(),
319            message: text,
320        });
321    }
322
323    let list: GraphList = resp.json().await?;
324    Ok(InboxPage {
325        has_more: list.next_link.is_some(),
326        next_link: list.next_link,
327        messages: list
328            .value
329            .into_iter()
330            .map(|g| to_message(g, account))
331            .collect(),
332    })
333}
334
335/// Search messages across all folders using Graph's `$search` KQL query.
336///
337/// `$search` doesn't combine with `$filter` or `$orderby`; results come back
338/// in Graph's relevance ranking, not date order. Common query forms users can
339/// pass:
340///
341/// - `alice budget`          → matches anywhere in subject/body/sender
342/// - `from:alice@example.com`
343/// - `subject:"q4 review"`
344/// - `from:alice AND subject:budget`
345///
346/// Parse one raw delta item into a [`Message`] (None if the shape is not a
347/// message, e.g. a tombstone or a partial patch without required fields).
348pub(crate) fn message_from_delta_value(
349    value: serde_json::Value,
350    account: &str,
351) -> Option<pidge_core::Message> {
352    let g: GraphMessage = serde_json::from_value(value).ok()?;
353    Some(to_message(g, account))
354}
355
356/// Fetch every message in a conversation (thread), oldest first.
357pub async fn list_conversation(
358    http: &reqwest::Client,
359    base_url: &str,
360    access_token: &str,
361    account: &str,
362    conversation_id: &str,
363) -> Result<Vec<pidge_core::Message>, ClientError> {
364    let url = format!("{base_url}/me/messages");
365    let filter = format!(
366        "conversationId eq '{}'",
367        conversation_id.replace('\'', "''")
368    );
369    let req = http
370        .get(&url)
371        .bearer_auth(access_token)
372        .header("Prefer", "outlook.body-content-type=\"text\"")
373        .query(&[
374            (
375                "$select",
376                "id,subject,from,receivedDateTime,isRead,bodyPreview,body,hasAttachments,flag,conversationId,toRecipients,ccRecipients",
377            ),
378            // No $orderby: Graph rejects conversationId filters combined
379            // with a sort ("InefficientFilter"), so we sort client-side.
380            ("$filter", filter.as_str()),
381            ("$top", "100"),
382        ]);
383    let resp = super::send_with_retry(req).await?;
384    let status = resp.status();
385    if !status.is_success() {
386        let text = resp.text().await.unwrap_or_default();
387        return Err(ClientError::Graph {
388            status: status.as_u16(),
389            message: text,
390        });
391    }
392    let list: GraphList = resp.json().await?;
393    let mut messages: Vec<pidge_core::Message> = list
394        .value
395        .into_iter()
396        .map(|g| to_message(g, account))
397        .collect();
398    messages.sort_by_key(|m| m.received_at);
399    Ok(messages)
400}
401
402/// Fetch a page of messages at an absolute Graph URL (an `@odata.nextLink`
403/// carried in a pidge cursor). Continues any listing or search stream. A
404/// link off Graph (or off `base_url` in tests) is refused before any request.
405pub async fn list_messages_at(
406    http: &reqwest::Client,
407    base_url: &str,
408    access_token: &str,
409    account: &str,
410    url: &str,
411) -> Result<InboxPage, ClientError> {
412    super::check_continuation(url, base_url)?;
413    let req = http.get(url).bearer_auth(access_token);
414    let resp = super::send_with_retry(req).await?;
415    let status = resp.status();
416    if !status.is_success() {
417        let text = resp.text().await.unwrap_or_default();
418        return Err(ClientError::Graph {
419            status: status.as_u16(),
420            message: text,
421        });
422    }
423    let list: GraphList = resp.json().await?;
424    Ok(InboxPage {
425        has_more: list.next_link.is_some(),
426        next_link: list.next_link,
427        messages: list
428            .value
429            .into_iter()
430            .map(|g| to_message(g, account))
431            .collect(),
432    })
433}
434
435pub async fn search_messages(
436    http: &reqwest::Client,
437    base_url: &str,
438    access_token: &str,
439    account: &str,
440    query: &str,
441    limit: usize,
442) -> Result<InboxPage, ClientError> {
443    search_in(http, base_url, access_token, account, None, query, limit).await
444}
445
446/// Like [`search_messages`], restricted to one folder (a well-known name
447/// such as `inbox`, or a folder id).
448pub async fn search_folder_messages(
449    http: &reqwest::Client,
450    base_url: &str,
451    access_token: &str,
452    account: &str,
453    folder: &str,
454    query: &str,
455    limit: usize,
456) -> Result<InboxPage, ClientError> {
457    search_in(
458        http,
459        base_url,
460        access_token,
461        account,
462        Some(folder),
463        query,
464        limit,
465    )
466    .await
467}
468
469async fn search_in(
470    http: &reqwest::Client,
471    base_url: &str,
472    access_token: &str,
473    account: &str,
474    folder: Option<&str>,
475    query: &str,
476    limit: usize,
477) -> Result<InboxPage, ClientError> {
478    // $search expects a quoted KQL string; the user passes the raw query.
479    let escaped = query.replace('\\', "\\\\").replace('"', "\\\"");
480    let quoted = format!("\"{escaped}\"");
481    let url = match folder {
482        Some(f) => format!("{base_url}/me/mailFolders/{f}/messages"),
483        None => format!("{base_url}/me/messages"),
484    };
485    let resp = super::send_with_retry(
486        http.get(&url)
487            .bearer_auth(access_token)
488            .header("Prefer", "outlook.body-content-type=\"text\"")
489            .query(&[
490                (
491                    "$select",
492                    "id,subject,from,receivedDateTime,isRead,bodyPreview,body,hasAttachments,flag,conversationId,toRecipients,ccRecipients",
493                ),
494                ("$top", &limit.to_string()),
495                ("$search", &quoted),
496            ]),
497    )
498    .await?;
499    let status = resp.status();
500    if !status.is_success() {
501        let text = resp.text().await.unwrap_or_default();
502        return Err(ClientError::Graph {
503            status: status.as_u16(),
504            message: text,
505        });
506    }
507    let list: GraphList = resp.json().await?;
508    Ok(InboxPage {
509        has_more: list.next_link.is_some(),
510        next_link: list.next_link,
511        messages: list
512            .value
513            .into_iter()
514            .map(|g| to_message(g, account))
515            .collect(),
516    })
517}
518
519fn to_message(g: GraphMessage, account: &str) -> Message {
520    let (body, body_content_type) = match g.body {
521        Some(b) => {
522            let kind = if b.content_type.eq_ignore_ascii_case("html") {
523                pidge_core::BodyContentType::Html
524            } else {
525                pidge_core::BodyContentType::Text
526            };
527            (clean(Some(b.content)), kind)
528        }
529        None => (String::new(), pidge_core::BodyContentType::Text),
530    };
531    Message {
532        account: account.to_string(),
533        id: g.id,
534        conversation_id: g.conversation_id.unwrap_or_default(),
535        from: MessageFrom {
536            name: clean(g.from.as_ref().and_then(|f| f.email_address.name.clone())),
537            address: clean(
538                g.from
539                    .as_ref()
540                    .and_then(|f| f.email_address.address.clone()),
541            ),
542        },
543        subject: clean(g.subject),
544        received_at: g.received_date_time,
545        is_read: g.is_read.unwrap_or(true),
546        // `preview` keeps the 255-char plain-text snippet Graph computes
547        // (bodyPreview). It's used as a cheap fallback when body is absent
548        // and as the plain-text source for `--json` output (AI agents and
549        // scripts that don't want to deal with HTML).
550        preview: clean(g.body_preview),
551        flag_status: flag_status_from(g.flag),
552        has_attachments: g.has_attachments.unwrap_or(false),
553        body,
554        body_content_type,
555        to: unwrap_recipients(g.to_recipients),
556        cc: unwrap_recipients(g.cc_recipients),
557        is_invite: is_event_message(g.odata_type.as_deref()),
558    }
559}
560
561/// GET /me/messages/{id}: fetch a single message with full body.
562pub async fn get_message(
563    http: &reqwest::Client,
564    base_url: &str,
565    access_token: &str,
566    account: &str,
567    message_id: &str,
568) -> Result<pidge_core::FullMessage, ClientError> {
569    let url = format!(
570        "{base_url}/me/messages/{message_id}\
571         ?$select=id,subject,from,toRecipients,ccRecipients,bccRecipients,\
572receivedDateTime,sentDateTime,isRead,body,hasAttachments,flag,conversationId,isDraft"
573    );
574    let resp = super::send_with_retry(http.get(&url).bearer_auth(access_token)).await?;
575    let status = resp.status();
576    if !status.is_success() {
577        let text = resp.text().await.unwrap_or_default();
578        return Err(ClientError::Graph {
579            status: status.as_u16(),
580            message: text,
581        });
582    }
583    let g: GraphFullMessage = resp.json().await?;
584    let is_invite = is_event_message(g.odata_type.as_deref());
585    let event_id = if is_invite {
586        fetch_event_id(http, base_url, access_token, message_id).await
587    } else {
588        None
589    };
590
591    let content_type = match g.body.content_type.to_lowercase().as_str() {
592        "html" => pidge_core::BodyContentType::Html,
593        _ => pidge_core::BodyContentType::Text,
594    };
595
596    Ok(pidge_core::FullMessage {
597        account: account.to_string(),
598        id: g.id,
599        conversation_id: g.conversation_id.unwrap_or_default(),
600        from: g
601            .from
602            .map(|w| recipient_from(w.email_address))
603            .unwrap_or_else(|| pidge_core::MessageFrom {
604                name: String::new(),
605                address: String::new(),
606            }),
607        to: unwrap_recipients(g.to_recipients),
608        cc: unwrap_recipients(g.cc_recipients),
609        bcc: unwrap_recipients(g.bcc_recipients),
610        subject: clean(g.subject),
611        received_at: g.received_date_time,
612        sent_at: g.sent_date_time,
613        is_read: g.is_read.unwrap_or(true),
614        body_content_type: content_type,
615        body_content: clean(Some(g.body.content)),
616        has_attachments: g.has_attachments.unwrap_or(false),
617        flag_status: flag_status_from(g.flag),
618        is_invite,
619        event_id,
620        is_draft: g.is_draft.unwrap_or(false),
621    })
622}
623
624/// The calendar event behind a meeting request, via Graph's documented
625/// `$expand=microsoft.graph.eventMessage/event`. Only called for messages
626/// already known to be `eventMessageRequest`, so the type-cast expand is
627/// always valid. Best effort: any failure means "no event id".
628async fn fetch_event_id(
629    http: &reqwest::Client,
630    base_url: &str,
631    access_token: &str,
632    message_id: &str,
633) -> Option<String> {
634    #[derive(Deserialize)]
635    struct WithEvent {
636        event: Option<EventRef>,
637    }
638    #[derive(Deserialize)]
639    struct EventRef {
640        id: String,
641    }
642    let url = format!("{base_url}/me/messages/{message_id}");
643    let req = http.get(&url).bearer_auth(access_token).query(&[
644        ("$select", "id"),
645        ("$expand", "microsoft.graph.eventMessage/event($select=id)"),
646    ]);
647    let resp = super::send_with_retry(req).await.ok()?;
648    if !resp.status().is_success() {
649        return None;
650    }
651    let body: WithEvent = resp.json().await.ok()?;
652    body.event.map(|e| e.id)
653}
654
655/// GET /me/messages/{id}?$select=internetMessageHeaders: fetch just the
656/// raw RFC 5322 headers for a message. Used by `pidge mail unsubscribe`
657/// to locate `List-Unsubscribe` / `List-Unsubscribe-Post`.
658pub async fn fetch_message_headers(
659    http: &reqwest::Client,
660    base_url: &str,
661    access_token: &str,
662    message_id: &str,
663) -> Result<Vec<(String, String)>, ClientError> {
664    let url = format!("{base_url}/me/messages/{message_id}?$select=internetMessageHeaders");
665    let resp = super::send_with_retry(http.get(&url).bearer_auth(access_token)).await?;
666    let status = resp.status();
667    if !status.is_success() {
668        let text = resp.text().await.unwrap_or_default();
669        return Err(ClientError::Graph {
670            status: status.as_u16(),
671            message: text,
672        });
673    }
674    let body: GraphHeadersResponse = resp.json().await?;
675    Ok(body
676        .internet_message_headers
677        .unwrap_or_default()
678        .into_iter()
679        .map(|h| (h.name, h.value))
680        .collect())
681}
682
683#[derive(serde::Deserialize)]
684struct GraphHeadersResponse {
685    #[serde(rename = "internetMessageHeaders", default)]
686    internet_message_headers: Option<Vec<GraphHeader>>,
687}
688
689#[derive(serde::Deserialize)]
690struct GraphHeader {
691    name: String,
692    value: String,
693}
694
695/// Which one-click targets [`post_one_click`] accepts.
696#[derive(Debug, Clone, Copy, PartialEq, Eq)]
697pub(crate) enum OneClickPolicy {
698    /// https to a host name that resolves only to public addresses.
699    PublicOnly,
700    /// Also plain http and loopback (an IP literal or a name), for tests
701    /// against a local mock server. Every other non-public address is still
702    /// refused.
703    AllowLoopback,
704}
705
706/// POST `List-Unsubscribe=One-Click` to the given URL per RFC 8058. The
707/// body is form-urlencoded (the RFC says so explicitly).
708///
709/// The URL comes from a third party's e-mail header, so the request is
710/// fenced in: https only, never to an IP literal, never to a host that
711/// resolves to a loopback, private, link-local, unique-local or unspecified
712/// address (the checked addresses are the ones connected to, so a second
713/// DNS answer can't swap them), and redirects are not followed. Every
714/// failure, including a non-2xx answer, is the same
715/// [`ClientError::UnsubscribeRejected`]: the endpoint's status and body are
716/// never passed on.
717///
718/// It uses its own short-lived client: no bearer token, no shared retry
719/// policy (a broken sender's unsubscribe endpoint shouldn't get the same
720/// exponential backoff as a throttled Graph call).
721pub async fn unsubscribe_one_click(url: &str) -> Result<(), ClientError> {
722    post_one_click(url, OneClickPolicy::PublicOnly).await
723}
724
725pub(crate) async fn post_one_click(url: &str, policy: OneClickPolicy) -> Result<(), ClientError> {
726    let rejected = || ClientError::UnsubscribeRejected;
727    let loopback_ok = policy == OneClickPolicy::AllowLoopback;
728    let parsed = url::Url::parse(url).map_err(|_| rejected())?;
729    match parsed.scheme() {
730        "https" => {}
731        "http" if loopback_ok => {}
732        _ => return Err(rejected()),
733    }
734    let (host, is_name) = match parsed.host() {
735        Some(url::Host::Domain(name)) => (name.to_string(), true),
736        Some(url::Host::Ipv4(ip)) if loopback_ok && ip.is_loopback() => (ip.to_string(), false),
737        _ => return Err(rejected()),
738    };
739    let port = parsed.port_or_known_default().ok_or_else(rejected)?;
740    let addrs: Vec<std::net::SocketAddr> = tokio::net::lookup_host((host.as_str(), port))
741        .await
742        .map_err(|_| rejected())?
743        .collect();
744    if addrs.is_empty()
745        || addrs
746            .iter()
747            .any(|a| !one_click_address_allowed(a.ip(), loopback_ok))
748    {
749        return Err(rejected());
750    }
751
752    let mut builder = reqwest::Client::builder()
753        .user_agent(format!("pidge/{}", env!("CARGO_PKG_VERSION")))
754        .timeout(std::time::Duration::from_secs(10))
755        .redirect(reqwest::redirect::Policy::none());
756    if is_name {
757        // Connect to exactly the addresses just checked.
758        builder = builder.resolve_to_addrs(&host, &addrs);
759    }
760    let client = builder.build().map_err(|_| rejected())?;
761    let resp = client
762        .post(parsed)
763        .header("Content-Type", "application/x-www-form-urlencoded")
764        .body("List-Unsubscribe=One-Click")
765        .send()
766        .await
767        .map_err(|_| rejected())?;
768    if !resp.status().is_success() {
769        return Err(rejected());
770    }
771    Ok(())
772}
773
774/// Whether a one-click POST may connect to `ip`: public unicast only
775/// (loopback too when `loopback_ok`). IPv4-mapped IPv6 is judged as IPv4.
776fn one_click_address_allowed(ip: std::net::IpAddr, loopback_ok: bool) -> bool {
777    use std::net::IpAddr;
778    let ip = match ip {
779        IpAddr::V6(v6) => v6.to_ipv4_mapped().map_or(IpAddr::V6(v6), IpAddr::V4),
780        v4 => v4,
781    };
782    if ip.is_loopback() {
783        return loopback_ok;
784    }
785    match ip {
786        IpAddr::V4(v4) => {
787            let [a, b, ..] = v4.octets();
788            !(v4.is_private()
789                || v4.is_link_local()
790                || v4.is_unspecified()
791                || v4.is_broadcast()
792                || v4.is_multicast()
793                || a == 0
794                // 100.64.0.0/10, carrier-grade NAT.
795                || (a == 100 && (b & 0xc0) == 64))
796        }
797        IpAddr::V6(v6) => {
798            let first = v6.segments()[0];
799            !(v6.is_unspecified()
800                || v6.is_multicast()
801                // fc00::/7, unique local.
802                || (first & 0xfe00) == 0xfc00
803                // fe80::/10, link local.
804                || (first & 0xffc0) == 0xfe80)
805        }
806    }
807}
808
809/// GET /me/messages/{id}/attachments: list attachments without fetching bytes.
810/// Filters to file attachments only.
811pub async fn list_attachments(
812    http: &reqwest::Client,
813    base_url: &str,
814    access_token: &str,
815    message_id: &str,
816) -> Result<Vec<pidge_core::Attachment>, ClientError> {
817    // contentId is intentionally NOT in $select: it lives on the
818    // `microsoft.graph.fileAttachment` subtype, not the base attachment
819    // type, so Graph rejects the query with a 400 when it's in a flat
820    // select clause. We don't currently use content_id in the CLI; if we
821    // ever need it (e.g. to match inline `cid:` references), per-attachment
822    // GETs return it without a cast.
823    let url = format!(
824        "{base_url}/me/messages/{message_id}/attachments\
825         ?$select=id,name,contentType,size,isInline"
826    );
827    let resp = super::send_with_retry(http.get(&url).bearer_auth(access_token)).await?;
828    let status = resp.status();
829    if !status.is_success() {
830        let text = resp.text().await.unwrap_or_default();
831        return Err(ClientError::Graph {
832            status: status.as_u16(),
833            message: text,
834        });
835    }
836    let list: GraphAttachmentList = resp.json().await?;
837    Ok(list
838        .value
839        .into_iter()
840        .filter(|a| {
841            a.odata_type
842                .as_deref()
843                .map(|t| t == "#microsoft.graph.fileAttachment")
844                .unwrap_or(true)
845        })
846        .map(|a| pidge_core::Attachment {
847            id: a.id,
848            name: clean(a.name),
849            content_type: clean(a.content_type),
850            size_bytes: a.size.unwrap_or(0),
851            is_inline: a.is_inline.unwrap_or(false),
852            content_id: a.content_id,
853        })
854        .collect())
855}
856
857/// GET /me/messages/{id}/attachments/{attachment_id}: fetch a single attachment
858/// with its base64 contentBytes. Returns the decoded bytes.
859pub async fn get_attachment_bytes(
860    http: &reqwest::Client,
861    base_url: &str,
862    access_token: &str,
863    message_id: &str,
864    attachment_id: &str,
865) -> Result<Vec<u8>, ClientError> {
866    use base64::Engine;
867    use base64::engine::general_purpose::STANDARD;
868
869    let url = format!("{base_url}/me/messages/{message_id}/attachments/{attachment_id}");
870    let resp = super::send_with_retry(http.get(&url).bearer_auth(access_token)).await?;
871    let status = resp.status();
872    if !status.is_success() {
873        let text = resp.text().await.unwrap_or_default();
874        return Err(ClientError::Graph {
875            status: status.as_u16(),
876            message: text,
877        });
878    }
879    let g: GraphAttachment = resp.json().await?;
880    let b64 = g.content_bytes.ok_or_else(|| ClientError::Graph {
881        status: 200,
882        message: "attachment response missing contentBytes".to_string(),
883    })?;
884    STANDARD.decode(&b64).map_err(|e| ClientError::Graph {
885        status: 200,
886        message: format!("attachment base64 decode: {e}"),
887    })
888}
889
890/// PATCH /me/messages/{id}: mark the message as read.
891pub async fn mark_read(
892    http: &reqwest::Client,
893    base_url: &str,
894    access_token: &str,
895    message_id: &str,
896) -> Result<(), ClientError> {
897    patch_message(
898        http,
899        base_url,
900        access_token,
901        message_id,
902        &serde_json::json!({ "isRead": true }),
903    )
904    .await
905}
906
907/// PATCH /me/messages/{id}: mark the message as unread.
908pub async fn mark_unread(
909    http: &reqwest::Client,
910    base_url: &str,
911    access_token: &str,
912    message_id: &str,
913) -> Result<(), ClientError> {
914    patch_message(
915        http,
916        base_url,
917        access_token,
918        message_id,
919        &serde_json::json!({ "isRead": false }),
920    )
921    .await
922}
923
924/// PATCH /me/messages/{id}: set or clear the follow-up flag.
925pub async fn set_flag(
926    http: &reqwest::Client,
927    base_url: &str,
928    access_token: &str,
929    message_id: &str,
930    flagged: bool,
931) -> Result<(), ClientError> {
932    let status = if flagged { "flagged" } else { "notFlagged" };
933    patch_message(
934        http,
935        base_url,
936        access_token,
937        message_id,
938        &serde_json::json!({ "flag": { "flagStatus": status } }),
939    )
940    .await
941}
942
943#[derive(serde::Deserialize)]
944struct GraphCategories {
945    #[serde(default)]
946    categories: Vec<String>,
947}
948
949/// GET /me/messages/{id}?$select=categories: read a message's categories.
950pub async fn get_categories(
951    http: &reqwest::Client,
952    base_url: &str,
953    access_token: &str,
954    message_id: &str,
955) -> Result<Vec<String>, ClientError> {
956    let url = format!("{base_url}/me/messages/{message_id}?$select=categories");
957    let resp = super::send_with_retry(http.get(&url).bearer_auth(access_token)).await?;
958    let status = resp.status();
959    if !status.is_success() {
960        let text = resp.text().await.unwrap_or_default();
961        return Err(ClientError::Graph {
962            status: status.as_u16(),
963            message: text,
964        });
965    }
966    let body: GraphCategories = resp.json().await?;
967    Ok(body.categories)
968}
969
970/// PATCH /me/messages/{id} with `{ "categories": [...] }`: replace categories.
971pub async fn set_categories(
972    http: &reqwest::Client,
973    base_url: &str,
974    access_token: &str,
975    message_id: &str,
976    categories: &[String],
977) -> Result<(), ClientError> {
978    patch_message(
979        http,
980        base_url,
981        access_token,
982        message_id,
983        &serde_json::json!({ "categories": categories }),
984    )
985    .await
986}
987
988async fn patch_message(
989    http: &reqwest::Client,
990    base_url: &str,
991    access_token: &str,
992    message_id: &str,
993    body: &serde_json::Value,
994) -> Result<(), ClientError> {
995    let url = format!("{base_url}/me/messages/{message_id}");
996    let resp =
997        super::send_with_retry(http.patch(&url).bearer_auth(access_token).json(body)).await?;
998    let status = resp.status();
999    if !status.is_success() {
1000        let text = resp.text().await.unwrap_or_default();
1001        return Err(ClientError::Graph {
1002            status: status.as_u16(),
1003            message: text,
1004        });
1005    }
1006    Ok(())
1007}
1008
1009/// POST /me/sendMail: compose-and-send a new message in one call.
1010///
1011/// The Graph endpoint takes ownership of the body; we wrap the `Outgoing`
1012/// in `{ "message": ..., "saveToSentItems": true }` so a copy lands in the
1013/// sender's Sent Items folder. Returns 202 Accepted on success.
1014pub async fn send_mail(
1015    http: &reqwest::Client,
1016    base_url: &str,
1017    access_token: &str,
1018    message: &Outgoing,
1019) -> Result<(), ClientError> {
1020    let url = format!("{base_url}/me/sendMail");
1021    let body = serde_json::json!({
1022        "message": message.to_graph_json(),
1023        "saveToSentItems": true,
1024    });
1025    post_no_body(http, &url, access_token, &body).await
1026}
1027
1028/// Convert plain-text body to HTML, escaping special chars and preserving
1029/// the line- and paragraph-breaks the user typed.
1030///
1031/// Graph's `/reply` and `/forward` endpoints accept a `comment` string and
1032/// insert it into the reply body, but when the source message body is HTML
1033/// (which Outlook always serves), newlines in `comment` collapse to spaces.
1034/// To keep the user's formatting, we send the body as HTML via a
1035/// `createReply` + PATCH dance (see `prepend_html_to_draft`), and this helper
1036/// is the text→HTML conversion that feeds it.
1037fn text_to_html(text: &str) -> String {
1038    fn escape(s: &str) -> String {
1039        s.replace('&', "&amp;")
1040            .replace('<', "&lt;")
1041            .replace('>', "&gt;")
1042            .replace('"', "&quot;")
1043    }
1044    let normalized = text.replace("\r\n", "\n").replace('\r', "\n");
1045    normalized
1046        .split("\n\n")
1047        .filter(|p| !p.trim().is_empty())
1048        .map(|paragraph| {
1049            let lines: Vec<String> = paragraph
1050                .trim_matches('\n')
1051                .split('\n')
1052                .map(escape)
1053                .collect();
1054            format!("<p>{}</p>", lines.join("<br>"))
1055        })
1056        .collect::<Vec<_>>()
1057        .join("")
1058}
1059
1060#[derive(Debug, Deserialize)]
1061struct GraphBodyOnly {
1062    body: GraphBody,
1063}
1064
1065/// GET a draft's body, splice `html_to_prepend` in above Graph's auto-quoted
1066/// text, and PATCH the draft. Used by reply/reply-all/forward to deliver
1067/// HTML-formatted comments that survive Outlook's HTML rendering.
1068async fn prepend_html_to_draft(
1069    http: &reqwest::Client,
1070    base_url: &str,
1071    access_token: &str,
1072    message_id: &str,
1073    html_to_prepend: &str,
1074) -> Result<(), ClientError> {
1075    let get_url = format!("{base_url}/me/messages/{message_id}?$select=body");
1076    let resp = super::send_with_retry(http.get(&get_url).bearer_auth(access_token)).await?;
1077    let status = resp.status();
1078    if !status.is_success() {
1079        let text = resp.text().await.unwrap_or_default();
1080        return Err(ClientError::Graph {
1081            status: status.as_u16(),
1082            message: text,
1083        });
1084    }
1085    let existing: GraphBodyOnly = resp.json().await?;
1086    let existing_content = existing.body.content;
1087
1088    // Insert right after the opening `<body...>` tag if present, otherwise
1089    // prepend to the whole string. Case-insensitive match because Outlook
1090    // sometimes serves uppercase `<BODY>`.
1091    let new_content = match find_body_tag_end(&existing_content) {
1092        Some(pos) => {
1093            let mut s = String::with_capacity(existing_content.len() + html_to_prepend.len());
1094            s.push_str(&existing_content[..pos]);
1095            s.push_str(html_to_prepend);
1096            s.push_str(&existing_content[pos..]);
1097            s
1098        }
1099        None => format!("{html_to_prepend}{existing_content}"),
1100    };
1101
1102    let patch_url = format!("{base_url}/me/messages/{message_id}");
1103    let patch_body = serde_json::json!({
1104        "body": {
1105            "contentType": "HTML",
1106            "content": new_content,
1107        }
1108    });
1109    let resp = super::send_with_retry(
1110        http.patch(&patch_url)
1111            .bearer_auth(access_token)
1112            .json(&patch_body),
1113    )
1114    .await?;
1115    let status = resp.status();
1116    if !status.is_success() {
1117        let text = resp.text().await.unwrap_or_default();
1118        return Err(ClientError::Graph {
1119            status: status.as_u16(),
1120            message: text,
1121        });
1122    }
1123    Ok(())
1124}
1125
1126fn find_body_tag_end(html: &str) -> Option<usize> {
1127    let lc = html.to_ascii_lowercase();
1128    let start = lc.find("<body")?;
1129    let after = &html[start..];
1130    let close_rel = after.find('>')?;
1131    Some(start + close_rel + 1)
1132}
1133
1134/// Reply to a message; sends immediately. Uses createReply + body PATCH +
1135/// send so the comment is delivered as HTML and the user's paragraph and
1136/// line breaks survive Outlook's HTML rendering.
1137pub async fn reply_message(
1138    http: &reqwest::Client,
1139    base_url: &str,
1140    access_token: &str,
1141    message_id: &str,
1142    comment: &str,
1143) -> Result<(), ClientError> {
1144    let draft_id = create_reply_draft(http, base_url, access_token, message_id, comment).await?;
1145    send_draft(http, base_url, access_token, &draft_id).await
1146}
1147
1148/// Reply-all variant of `reply_message`.
1149pub async fn reply_all_message(
1150    http: &reqwest::Client,
1151    base_url: &str,
1152    access_token: &str,
1153    message_id: &str,
1154    comment: &str,
1155) -> Result<(), ClientError> {
1156    let draft_id =
1157        create_reply_all_draft(http, base_url, access_token, message_id, comment).await?;
1158    send_draft(http, base_url, access_token, &draft_id).await
1159}
1160
1161/// Forward; sends immediately, with HTML-formatted comment.
1162pub async fn forward_message(
1163    http: &reqwest::Client,
1164    base_url: &str,
1165    access_token: &str,
1166    message_id: &str,
1167    to: &[String],
1168    comment: &str,
1169) -> Result<(), ClientError> {
1170    let draft_id =
1171        create_forward_draft(http, base_url, access_token, message_id, to, comment).await?;
1172    send_draft(http, base_url, access_token, &draft_id).await
1173}
1174
1175async fn post_no_body(
1176    http: &reqwest::Client,
1177    url: &str,
1178    access_token: &str,
1179    body: &serde_json::Value,
1180) -> Result<(), ClientError> {
1181    let resp = super::send_with_retry(http.post(url).bearer_auth(access_token).json(body)).await?;
1182    let status = resp.status();
1183    if !status.is_success() {
1184        let text = resp.text().await.unwrap_or_default();
1185        return Err(ClientError::Graph {
1186            status: status.as_u16(),
1187            message: text,
1188        });
1189    }
1190    Ok(())
1191}
1192
1193/// POST /me/messages: create a draft message in the Drafts folder.
1194/// Returns the new draft's Graph message ID.
1195pub async fn create_draft(
1196    http: &reqwest::Client,
1197    base_url: &str,
1198    access_token: &str,
1199    message: &Outgoing,
1200) -> Result<String, ClientError> {
1201    let url = format!("{base_url}/me/messages");
1202    let body = message.to_graph_json();
1203    let resp =
1204        super::send_with_retry(http.post(&url).bearer_auth(access_token).json(&body)).await?;
1205    parse_id_from_response(resp).await
1206}
1207
1208/// POST /me/messages/{id}/createReply: create a reply draft. Returns the
1209/// new draft's Graph message ID.
1210///
1211/// The comment is converted from plain text to HTML and spliced into the
1212/// draft body via PATCH, so paragraph- and line-breaks survive Outlook's
1213/// HTML rendering. (Graph's own `comment` parameter would collapse them.)
1214pub async fn create_reply_draft(
1215    http: &reqwest::Client,
1216    base_url: &str,
1217    access_token: &str,
1218    message_id: &str,
1219    comment: &str,
1220) -> Result<String, ClientError> {
1221    let url = format!("{base_url}/me/messages/{message_id}/createReply");
1222    let resp = super::send_with_retry(
1223        http.post(&url)
1224            .bearer_auth(access_token)
1225            .json(&serde_json::json!({})),
1226    )
1227    .await?;
1228    let draft_id = parse_id_from_response(resp).await?;
1229    if !comment.is_empty() {
1230        prepend_html_to_draft(
1231            http,
1232            base_url,
1233            access_token,
1234            &draft_id,
1235            &text_to_html(comment),
1236        )
1237        .await?;
1238    }
1239    Ok(draft_id)
1240}
1241
1242/// POST /me/messages/{id}/createReplyAll: create a reply-all draft.
1243pub async fn create_reply_all_draft(
1244    http: &reqwest::Client,
1245    base_url: &str,
1246    access_token: &str,
1247    message_id: &str,
1248    comment: &str,
1249) -> Result<String, ClientError> {
1250    let url = format!("{base_url}/me/messages/{message_id}/createReplyAll");
1251    let resp = super::send_with_retry(
1252        http.post(&url)
1253            .bearer_auth(access_token)
1254            .json(&serde_json::json!({})),
1255    )
1256    .await?;
1257    let draft_id = parse_id_from_response(resp).await?;
1258    if !comment.is_empty() {
1259        prepend_html_to_draft(
1260            http,
1261            base_url,
1262            access_token,
1263            &draft_id,
1264            &text_to_html(comment),
1265        )
1266        .await?;
1267    }
1268    Ok(draft_id)
1269}
1270
1271/// POST /me/messages/{id}/createForward: create a forward draft with the
1272/// given recipients already populated.
1273pub async fn create_forward_draft(
1274    http: &reqwest::Client,
1275    base_url: &str,
1276    access_token: &str,
1277    message_id: &str,
1278    to: &[String],
1279    comment: &str,
1280) -> Result<String, ClientError> {
1281    let url = format!("{base_url}/me/messages/{message_id}/createForward");
1282    let resp = super::send_with_retry(http.post(&url).bearer_auth(access_token).json(
1283        &serde_json::json!({
1284            "toRecipients": to.iter().map(|addr| serde_json::json!({
1285                "emailAddress": { "address": addr }
1286            })).collect::<Vec<_>>(),
1287        }),
1288    ))
1289    .await?;
1290    let draft_id = parse_id_from_response(resp).await?;
1291    if !comment.is_empty() {
1292        prepend_html_to_draft(
1293            http,
1294            base_url,
1295            access_token,
1296            &draft_id,
1297            &text_to_html(comment),
1298        )
1299        .await?;
1300    }
1301    Ok(draft_id)
1302}
1303
1304/// POST /me/messages/{id}/send: send an existing draft.
1305pub async fn send_draft(
1306    http: &reqwest::Client,
1307    base_url: &str,
1308    access_token: &str,
1309    message_id: &str,
1310) -> Result<(), ClientError> {
1311    let url = format!("{base_url}/me/messages/{message_id}/send");
1312    // Graph's /send takes no body but requires `Content-Length: 0`; reqwest
1313    // omits that header for body-less requests, so the Graph edge layer
1314    // responds with HTTP 411. Sending an explicit empty body forces the
1315    // header to land.
1316    let resp = super::send_with_retry(
1317        http.post(&url)
1318            .bearer_auth(access_token)
1319            .header(reqwest::header::CONTENT_LENGTH, 0)
1320            .body(reqwest::Body::from("")),
1321    )
1322    .await?;
1323    let status = resp.status();
1324    if !status.is_success() {
1325        let text = resp.text().await.unwrap_or_default();
1326        return Err(ClientError::Graph {
1327            status: status.as_u16(),
1328            message: text,
1329        });
1330    }
1331    Ok(())
1332}
1333
1334/// PATCH /me/messages/{id}: overwrite a draft's editable fields. Only the
1335/// fields in `Outgoing` are patched, since that's what our wizard owns.
1336pub async fn update_draft(
1337    http: &reqwest::Client,
1338    base_url: &str,
1339    access_token: &str,
1340    message_id: &str,
1341    message: &Outgoing,
1342) -> Result<(), ClientError> {
1343    let url = format!("{base_url}/me/messages/{message_id}");
1344    let body = message.to_graph_json();
1345    let resp =
1346        super::send_with_retry(http.patch(&url).bearer_auth(access_token).json(&body)).await?;
1347    let status = resp.status();
1348    if !status.is_success() {
1349        let text = resp.text().await.unwrap_or_default();
1350        return Err(ClientError::Graph {
1351            status: status.as_u16(),
1352            message: text,
1353        });
1354    }
1355    Ok(())
1356}
1357
1358/// PATCH /me/messages/{id}: replace only the recipient lists that are
1359/// `Some`, leaving subject and body (e.g. a reply's quoted history) intact.
1360pub async fn update_draft_recipients(
1361    http: &reqwest::Client,
1362    base_url: &str,
1363    access_token: &str,
1364    message_id: &str,
1365    to: Option<&[String]>,
1366    cc: Option<&[String]>,
1367    bcc: Option<&[String]>,
1368) -> Result<(), ClientError> {
1369    let mut body = serde_json::Map::new();
1370    for (field, list) in [
1371        ("toRecipients", to),
1372        ("ccRecipients", cc),
1373        ("bccRecipients", bcc),
1374    ] {
1375        if let Some(list) = list {
1376            let addresses: Vec<_> = list
1377                .iter()
1378                .map(|addr| serde_json::json!({ "emailAddress": { "address": addr } }))
1379                .collect();
1380            body.insert(field.to_string(), addresses.into());
1381        }
1382    }
1383    let url = format!("{base_url}/me/messages/{message_id}");
1384    let resp =
1385        super::send_with_retry(http.patch(&url).bearer_auth(access_token).json(&body)).await?;
1386    let status = resp.status();
1387    if !status.is_success() {
1388        let text = resp.text().await.unwrap_or_default();
1389        return Err(ClientError::Graph {
1390            status: status.as_u16(),
1391            message: text,
1392        });
1393    }
1394    Ok(())
1395}
1396
1397/// DELETE /me/messages/{id}: moves the message to Deleted Items. Same call
1398/// works for drafts and for inbox messages; the destination folder differs
1399/// only by what the user is currently in.
1400pub async fn delete_message(
1401    http: &reqwest::Client,
1402    base_url: &str,
1403    access_token: &str,
1404    message_id: &str,
1405) -> Result<(), ClientError> {
1406    let url = format!("{base_url}/me/messages/{message_id}");
1407    let resp = super::send_with_retry(http.delete(&url).bearer_auth(access_token)).await?;
1408    let status = resp.status();
1409    if !status.is_success() {
1410        let text = resp.text().await.unwrap_or_default();
1411        return Err(ClientError::Graph {
1412            status: status.as_u16(),
1413            message: text,
1414        });
1415    }
1416    Ok(())
1417}
1418
1419/// POST /me/messages/{id}/attachments: attach a file to a draft.
1420///
1421/// Uses Graph's simple (non-resumable) upload, which is limited to ~3 MB per
1422/// attachment. Larger files require `createUploadSession`, which isn't wired
1423/// yet; the CLI rejects oversized attachments before calling this.
1424pub async fn add_attachment(
1425    http: &reqwest::Client,
1426    base_url: &str,
1427    access_token: &str,
1428    message_id: &str,
1429    name: &str,
1430    content_type: &str,
1431    bytes: &[u8],
1432) -> Result<String, ClientError> {
1433    use base64::Engine;
1434    use base64::engine::general_purpose::STANDARD;
1435
1436    let url = format!("{base_url}/me/messages/{message_id}/attachments");
1437    let body = serde_json::json!({
1438        "@odata.type": "#microsoft.graph.fileAttachment",
1439        "name": name,
1440        "contentType": content_type,
1441        "contentBytes": STANDARD.encode(bytes),
1442    });
1443    let resp =
1444        super::send_with_retry(http.post(&url).bearer_auth(access_token).json(&body)).await?;
1445    parse_id_from_response(resp).await
1446}
1447
1448/// DELETE /me/messages/{id}/attachments/{att_id}.
1449pub async fn delete_attachment(
1450    http: &reqwest::Client,
1451    base_url: &str,
1452    access_token: &str,
1453    message_id: &str,
1454    attachment_id: &str,
1455) -> Result<(), ClientError> {
1456    let url = format!("{base_url}/me/messages/{message_id}/attachments/{attachment_id}");
1457    let resp = super::send_with_retry(http.delete(&url).bearer_auth(access_token)).await?;
1458    let status = resp.status();
1459    if !status.is_success() {
1460        let text = resp.text().await.unwrap_or_default();
1461        return Err(ClientError::Graph {
1462            status: status.as_u16(),
1463            message: text,
1464        });
1465    }
1466    Ok(())
1467}
1468
1469async fn parse_id_from_response(resp: reqwest::Response) -> Result<String, ClientError> {
1470    let status = resp.status();
1471    if !status.is_success() {
1472        let text = resp.text().await.unwrap_or_default();
1473        return Err(ClientError::Graph {
1474            status: status.as_u16(),
1475            message: text,
1476        });
1477    }
1478    let v: serde_json::Value = resp.json().await?;
1479    v["id"]
1480        .as_str()
1481        .map(|s| s.to_string())
1482        .ok_or_else(|| ClientError::Graph {
1483            status: 200,
1484            message: "draft response missing 'id'".to_string(),
1485        })
1486}
1487
1488/// What the user is sending: pre-Graph-serialization shape.
1489#[derive(Debug, Clone)]
1490pub struct Outgoing {
1491    pub subject: String,
1492    pub body_text: String,
1493    pub to: Vec<String>,
1494    pub cc: Vec<String>,
1495    pub bcc: Vec<String>,
1496}
1497
1498impl Outgoing {
1499    fn to_graph_json(&self) -> serde_json::Value {
1500        fn addresses(list: &[String]) -> Vec<serde_json::Value> {
1501            list.iter()
1502                .map(|addr| serde_json::json!({ "emailAddress": { "address": addr } }))
1503                .collect()
1504        }
1505        serde_json::json!({
1506            "subject": self.subject,
1507            "body": {
1508                "contentType": "Text",
1509                "content": self.body_text,
1510            },
1511            "toRecipients": addresses(&self.to),
1512            "ccRecipients": addresses(&self.cc),
1513            "bccRecipients": addresses(&self.bcc),
1514        })
1515    }
1516}
1517
1518/// POST /me/messages/{id}/move: move the message to another folder.
1519///
1520/// `destination` is either a Graph folder ID or a well-known folder name
1521/// (`"archive"`, `"deleteditems"`, `"junkemail"`, …). Graph returns the new
1522/// message (it gets a new ID in the target folder); we discard that since
1523/// the caller's cache will be refreshed on the next list/search.
1524pub async fn move_message(
1525    http: &reqwest::Client,
1526    base_url: &str,
1527    access_token: &str,
1528    message_id: &str,
1529    destination: &str,
1530) -> Result<(), ClientError> {
1531    let url = format!("{base_url}/me/messages/{message_id}/move");
1532    let resp = super::send_with_retry(
1533        http.post(&url)
1534            .bearer_auth(access_token)
1535            .json(&serde_json::json!({ "destinationId": destination })),
1536    )
1537    .await?;
1538    let status = resp.status();
1539    if !status.is_success() {
1540        let text = resp.text().await.unwrap_or_default();
1541        return Err(ClientError::Graph {
1542            status: status.as_u16(),
1543            message: text,
1544        });
1545    }
1546    Ok(())
1547}
1548
1549/// A mail folder as returned by Graph's `/me/mailFolders` endpoint.
1550#[derive(Debug, Clone, Deserialize)]
1551pub struct MailFolder {
1552    pub id: String,
1553    #[serde(rename = "displayName", deserialize_with = "clean_string")]
1554    pub display_name: String,
1555    /// Total message count, present when selected. `None` if Graph omitted it.
1556    #[serde(rename = "totalItemCount", default)]
1557    pub total_item_count: Option<u64>,
1558    /// Unread message count, present when selected.
1559    #[serde(rename = "unreadItemCount", default)]
1560    pub unread_item_count: Option<u64>,
1561    /// Number of immediate child folders, present when selected. Lets callers
1562    /// skip a `childFolders` round-trip for folders that have none.
1563    #[serde(rename = "childFolderCount", default)]
1564    pub child_folder_count: Option<u64>,
1565}
1566
1567#[derive(Debug, Deserialize)]
1568struct GraphFolderList {
1569    value: Vec<MailFolder>,
1570    #[serde(rename = "@odata.nextLink", default)]
1571    next_link: Option<String>,
1572}
1573
1574/// Fields every folder listing selects, shared so top-level and child
1575/// listings return identically-shaped `MailFolder`s.
1576const FOLDER_SELECT: &str = "id,displayName,totalItemCount,unreadItemCount,childFolderCount";
1577
1578/// Page through a `mailFolders`/`childFolders` collection starting at `url`,
1579/// following `@odata.nextLink` until exhausted.
1580async fn fetch_folder_pages(
1581    http: &reqwest::Client,
1582    access_token: &str,
1583    mut url: String,
1584) -> Result<Vec<MailFolder>, ClientError> {
1585    let mut folders: Vec<MailFolder> = Vec::new();
1586    loop {
1587        let resp = super::send_with_retry(http.get(&url).bearer_auth(access_token)).await?;
1588        let status = resp.status();
1589        if !status.is_success() {
1590            let text = resp.text().await.unwrap_or_default();
1591            return Err(ClientError::Graph {
1592                status: status.as_u16(),
1593                message: text,
1594            });
1595        }
1596        let list: GraphFolderList = resp.json().await?;
1597        folders.extend(list.value);
1598        // `@odata.nextLink` is an absolute URL that already carries the
1599        // `$select`/`$top` query; follow it verbatim.
1600        match list.next_link {
1601            Some(next) => url = next,
1602            None => break,
1603        }
1604    }
1605    Ok(folders)
1606}
1607
1608/// GET /me/mailFolders: list the top-level mail folders (id, displayName,
1609/// counts). Pages through `@odata.nextLink` so mailboxes with many folders
1610/// are fully enumerated rather than silently truncated at one page.
1611pub async fn list_mail_folders(
1612    http: &reqwest::Client,
1613    base_url: &str,
1614    access_token: &str,
1615) -> Result<Vec<MailFolder>, ClientError> {
1616    let url = format!("{base_url}/me/mailFolders?$select={FOLDER_SELECT}&$top=100");
1617    fetch_folder_pages(http, access_token, url).await
1618}
1619
1620/// GET /me/mailFolders/{parent_id}/childFolders: list a folder's immediate
1621/// children. Same shape and paging as `list_mail_folders`.
1622pub async fn list_child_folders(
1623    http: &reqwest::Client,
1624    base_url: &str,
1625    access_token: &str,
1626    parent_id: &str,
1627) -> Result<Vec<MailFolder>, ClientError> {
1628    let url = format!(
1629        "{base_url}/me/mailFolders/{parent_id}/childFolders?$select={FOLDER_SELECT}&$top=100"
1630    );
1631    fetch_folder_pages(http, access_token, url).await
1632}
1633
1634async fn post_folder(
1635    http: &reqwest::Client,
1636    url: &str,
1637    access_token: &str,
1638    display_name: &str,
1639) -> Result<MailFolder, ClientError> {
1640    let resp = super::send_with_retry(
1641        http.post(url)
1642            .bearer_auth(access_token)
1643            .json(&serde_json::json!({ "displayName": display_name })),
1644    )
1645    .await?;
1646    let status = resp.status();
1647    if !status.is_success() {
1648        let text = resp.text().await.unwrap_or_default();
1649        return Err(ClientError::Graph {
1650            status: status.as_u16(),
1651            message: text,
1652        });
1653    }
1654    let folder: MailFolder = resp.json().await?;
1655    Ok(folder)
1656}
1657
1658/// POST /me/mailFolders: create a new top-level folder, returning it.
1659///
1660/// Graph rejects a duplicate `displayName` with 409; callers that want
1661/// "create if missing" semantics should list first and only call this when
1662/// no case-insensitive match exists.
1663pub async fn create_mail_folder(
1664    http: &reqwest::Client,
1665    base_url: &str,
1666    access_token: &str,
1667    display_name: &str,
1668) -> Result<MailFolder, ClientError> {
1669    let url = format!("{base_url}/me/mailFolders");
1670    post_folder(http, &url, access_token, display_name).await
1671}
1672
1673/// POST /me/mailFolders/{parent_id}/childFolders: create a child folder
1674/// under `parent_id`, returning it.
1675pub async fn create_child_folder(
1676    http: &reqwest::Client,
1677    base_url: &str,
1678    access_token: &str,
1679    parent_id: &str,
1680    display_name: &str,
1681) -> Result<MailFolder, ClientError> {
1682    let url = format!("{base_url}/me/mailFolders/{parent_id}/childFolders");
1683    post_folder(http, &url, access_token, display_name).await
1684}
1685
1686/// DELETE /me/mailFolders/{id}: delete a folder. Outlook moves the folder
1687/// (and any contents) to Deleted Items, so this is recoverable. Works for
1688/// top-level and child folders alike.
1689pub async fn delete_mail_folder(
1690    http: &reqwest::Client,
1691    base_url: &str,
1692    access_token: &str,
1693    folder_id: &str,
1694) -> Result<(), ClientError> {
1695    let url = format!("{base_url}/me/mailFolders/{folder_id}");
1696    let resp = super::send_with_retry(http.delete(&url).bearer_auth(access_token)).await?;
1697    let status = resp.status();
1698    if !status.is_success() {
1699        let text = resp.text().await.unwrap_or_default();
1700        return Err(ClientError::Graph {
1701            status: status.as_u16(),
1702            message: text,
1703        });
1704    }
1705    Ok(())
1706}
1707
1708#[cfg(test)]
1709mod tests {
1710    use super::*;
1711
1712    #[test]
1713    fn third_party_text_loses_its_control_characters_at_the_graph_boundary() {
1714        assert_eq!(
1715            clean(Some("Re: \x1b[2K\x1b[1Ahidden\u{9b}x".into())),
1716            "Re: [2K[1Ahiddenx"
1717        );
1718        assert_eq!(
1719            clean(Some("keeps\nlines\tand tabs".into())),
1720            "keeps\nlines\tand tabs"
1721        );
1722        assert_eq!(clean(None), "");
1723        let folder: MailFolder = serde_json::from_value(serde_json::json!({
1724            "id": "F1", "displayName": "Inbox\u{1b}]0;pwned\u{7}"
1725        }))
1726        .unwrap();
1727        assert_eq!(folder.display_name, "Inbox]0;pwned");
1728        let from = recipient_from(GraphFromAddress {
1729            name: Some("Eve\u{1b}[31m".into()),
1730            address: Some("eve@example.com".into()),
1731        });
1732        assert_eq!(from.name, "Eve[31m");
1733    }
1734    use wiremock::matchers::{body_string, header, method, path, path_regex, query_param};
1735    use wiremock::{Mock, MockServer, ResponseTemplate};
1736
1737    #[test]
1738    fn event_message_rows_are_invites_and_plain_messages_are_not() {
1739        let row = |odata_type: Option<&str>| {
1740            let mut v = serde_json::json!({
1741                "id": "m1",
1742                "receivedDateTime": "2026-09-23T08:00:00Z",
1743            });
1744            if let Some(t) = odata_type {
1745                v["@odata.type"] = t.into();
1746            }
1747            message_from_delta_value(v, "a@example.com").unwrap()
1748        };
1749        assert!(row(Some("#microsoft.graph.eventMessageRequest")).is_invite);
1750        assert!(!row(Some("#microsoft.graph.eventMessage")).is_invite);
1751        assert!(!row(Some("#microsoft.graph.eventMessageResponse")).is_invite);
1752        assert!(!row(Some("#microsoft.graph.message")).is_invite);
1753        assert!(!row(None).is_invite);
1754    }
1755
1756    #[tokio::test]
1757    async fn list_inbox_parses_graph_response() {
1758        let server = MockServer::start().await;
1759        Mock::given(method("GET"))
1760            .and(path("/me/mailFolders/inbox/messages"))
1761            .and(header("authorization", "Bearer AT"))
1762            .and(query_param("$top", "5"))
1763            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1764                "value": [
1765                    {
1766                        "id": "AAAA",
1767                        "subject": "Hello",
1768                        "from": {
1769                            "emailAddress": {
1770                                "name": "Maria",
1771                                "address": "maria@mklab.se"
1772                            }
1773                        },
1774                        "receivedDateTime": "2026-05-13T22:00:00Z",
1775                        "isRead": false,
1776                        "bodyPreview": "Hi there"
1777                    }
1778                ]
1779            })))
1780            .mount(&server)
1781            .await;
1782
1783        let http = reqwest::Client::new();
1784        let page = list_inbox(&http, &server.uri(), "AT", "u@e.com", 5, 0, false)
1785            .await
1786            .unwrap();
1787        assert_eq!(page.messages.len(), 1);
1788        assert_eq!(page.messages[0].subject, "Hello");
1789        assert_eq!(page.messages[0].from.address, "maria@mklab.se");
1790        assert!(!page.messages[0].is_read);
1791        assert_eq!(page.messages[0].account, "u@e.com");
1792        assert!(!page.has_more);
1793    }
1794
1795    #[tokio::test]
1796    async fn list_inbox_adds_filter_when_unread_only() {
1797        let server = MockServer::start().await;
1798        Mock::given(method("GET"))
1799            .and(path("/me/mailFolders/inbox/messages"))
1800            .and(query_param("$filter", "isRead eq false"))
1801            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1802                "value": []
1803            })))
1804            .mount(&server)
1805            .await;
1806
1807        let http = reqwest::Client::new();
1808        let page = list_inbox(&http, &server.uri(), "AT", "u@e.com", 5, 0, true)
1809            .await
1810            .unwrap();
1811        assert!(page.messages.is_empty());
1812    }
1813
1814    #[tokio::test]
1815    async fn list_inbox_passes_skip_when_nonzero() {
1816        let server = MockServer::start().await;
1817        Mock::given(method("GET"))
1818            .and(path("/me/mailFolders/inbox/messages"))
1819            .and(query_param("$skip", "25"))
1820            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1821                "value": [],
1822                "@odata.nextLink": "https://graph.example/next"
1823            })))
1824            .mount(&server)
1825            .await;
1826
1827        let http = reqwest::Client::new();
1828        let page = list_inbox(&http, &server.uri(), "AT", "u@e.com", 25, 25, false)
1829            .await
1830            .unwrap();
1831        assert!(page.has_more);
1832    }
1833
1834    #[tokio::test]
1835    async fn search_messages_passes_search_query() {
1836        let server = MockServer::start().await;
1837        Mock::given(method("GET"))
1838            .and(path("/me/messages"))
1839            .and(query_param("$search", "\"alice budget\""))
1840            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1841                "value": [
1842                    {
1843                        "id": "S1",
1844                        "subject": "Q4 budget review",
1845                        "from": { "emailAddress": { "name": "Alice", "address": "alice@example.com" } },
1846                        "receivedDateTime": "2026-05-13T22:00:00Z",
1847                        "isRead": true,
1848                        "bodyPreview": "Numbers attached"
1849                    }
1850                ]
1851            })))
1852            .mount(&server)
1853            .await;
1854
1855        let http = reqwest::Client::new();
1856        let msgs = search_messages(&http, &server.uri(), "AT", "u@e.com", "alice budget", 25)
1857            .await
1858            .unwrap()
1859            .messages;
1860        assert_eq!(msgs.len(), 1);
1861        assert_eq!(msgs[0].subject, "Q4 budget review");
1862    }
1863
1864    #[tokio::test]
1865    async fn search_folder_messages_searches_within_the_folder() {
1866        let server = MockServer::start().await;
1867        Mock::given(method("GET"))
1868            .and(path("/me/mailFolders/sentitems/messages"))
1869            .and(query_param("$search", "\"budget\""))
1870            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1871                "value": [{ "id": "S1", "receivedDateTime": "2026-05-13T22:00:00Z" }]
1872            })))
1873            .expect(1)
1874            .mount(&server)
1875            .await;
1876
1877        let http = reqwest::Client::new();
1878        let msgs = search_folder_messages(
1879            &http,
1880            &server.uri(),
1881            "AT",
1882            "u@e.com",
1883            "sentitems",
1884            "budget",
1885            25,
1886        )
1887        .await
1888        .unwrap()
1889        .messages;
1890        assert_eq!(msgs[0].id, "S1");
1891    }
1892
1893    #[tokio::test]
1894    async fn search_escapes_backslashes_before_quotes() {
1895        let server = MockServer::start().await;
1896        Mock::given(method("GET"))
1897            .and(path("/me/messages"))
1898            .and(query_param("$search", r#""a\\b \"c\"""#))
1899            .respond_with(
1900                ResponseTemplate::new(200).set_body_json(serde_json::json!({ "value": [] })),
1901            )
1902            .expect(1)
1903            .mount(&server)
1904            .await;
1905        let http = reqwest::Client::new();
1906        search_messages(&http, &server.uri(), "AT", "u@e.com", r#"a\b "c""#, 5)
1907            .await
1908            .unwrap();
1909    }
1910
1911    #[tokio::test]
1912    async fn get_message_fetches_the_event_id_for_a_meeting_request_only() {
1913        let server = MockServer::start().await;
1914        let message = |id: &str, odata_type: &str| {
1915            serde_json::json!({
1916                "@odata.type": odata_type,
1917                "id": id,
1918                "receivedDateTime": "2026-09-23T08:00:00Z",
1919                "sentDateTime": "2026-09-23T08:00:00Z",
1920                "body": { "contentType": "text", "content": "" },
1921            })
1922        };
1923        Mock::given(method("GET"))
1924            .and(path("/me/messages/INV"))
1925            .and(query_param(
1926                "$expand",
1927                "microsoft.graph.eventMessage/event($select=id)",
1928            ))
1929            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1930                "id": "INV", "event": { "id": "EV1" }
1931            })))
1932            .expect(1)
1933            .mount(&server)
1934            .await;
1935        Mock::given(method("GET"))
1936            .and(path("/me/messages/INV"))
1937            .respond_with(
1938                ResponseTemplate::new(200)
1939                    .set_body_json(message("INV", "#microsoft.graph.eventMessageRequest")),
1940            )
1941            .mount(&server)
1942            .await;
1943        Mock::given(method("GET"))
1944            .and(path("/me/messages/PLAIN"))
1945            .respond_with(
1946                ResponseTemplate::new(200)
1947                    .set_body_json(message("PLAIN", "#microsoft.graph.message")),
1948            )
1949            .expect(1)
1950            .mount(&server)
1951            .await;
1952
1953        let http = reqwest::Client::new();
1954        let inv = get_message(&http, &server.uri(), "AT", "u@e.com", "INV")
1955            .await
1956            .unwrap();
1957        assert!(inv.is_invite);
1958        assert_eq!(inv.event_id.as_deref(), Some("EV1"));
1959        let plain = get_message(&http, &server.uri(), "AT", "u@e.com", "PLAIN")
1960            .await
1961            .unwrap();
1962        assert!(!plain.is_invite);
1963        assert_eq!(plain.event_id, None);
1964    }
1965
1966    #[tokio::test]
1967    async fn mark_unread_patches_isread_false() {
1968        let server = MockServer::start().await;
1969        Mock::given(method("PATCH"))
1970            .and(path_regex("/me/messages/[A-Za-z0-9]+"))
1971            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
1972            .mount(&server)
1973            .await;
1974        let http = reqwest::Client::new();
1975        mark_unread(&http, &server.uri(), "AT", "MSG")
1976            .await
1977            .unwrap();
1978    }
1979
1980    #[tokio::test]
1981    async fn set_flag_patches_flag_status() {
1982        let server = MockServer::start().await;
1983        Mock::given(method("PATCH"))
1984            .and(path_regex("/me/messages/[A-Za-z0-9]+"))
1985            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
1986            .mount(&server)
1987            .await;
1988        let http = reqwest::Client::new();
1989        set_flag(&http, &server.uri(), "AT", "MSG", true)
1990            .await
1991            .unwrap();
1992        set_flag(&http, &server.uri(), "AT", "MSG", false)
1993            .await
1994            .unwrap();
1995    }
1996
1997    #[tokio::test]
1998    async fn send_mail_wraps_outgoing_in_message_envelope() {
1999        use wiremock::matchers::body_partial_json;
2000        let server = MockServer::start().await;
2001        Mock::given(method("POST"))
2002            .and(path("/me/sendMail"))
2003            .and(body_partial_json(serde_json::json!({
2004                "saveToSentItems": true,
2005                "message": {
2006                    "subject": "Hello",
2007                    "body": { "contentType": "Text", "content": "Hi there" },
2008                    "toRecipients": [{ "emailAddress": { "address": "alice@example.com" } }]
2009                }
2010            })))
2011            .respond_with(ResponseTemplate::new(202))
2012            .mount(&server)
2013            .await;
2014        let http = reqwest::Client::new();
2015        let msg = Outgoing {
2016            subject: "Hello".into(),
2017            body_text: "Hi there".into(),
2018            to: vec!["alice@example.com".into()],
2019            cc: vec![],
2020            bcc: vec![],
2021        };
2022        send_mail(&http, &server.uri(), "AT", &msg).await.unwrap();
2023    }
2024
2025    #[tokio::test]
2026    async fn reply_message_creates_draft_patches_body_and_sends() {
2027        use wiremock::matchers::body_partial_json;
2028        let server = MockServer::start().await;
2029
2030        // 1. createReply → returns draft ID
2031        Mock::given(method("POST"))
2032            .and(path_regex("/me/messages/MSG/createReply"))
2033            .respond_with(
2034                ResponseTemplate::new(201).set_body_json(serde_json::json!({ "id": "DRAFT" })),
2035            )
2036            .mount(&server)
2037            .await;
2038        // 2. GET draft body
2039        Mock::given(method("GET"))
2040            .and(path("/me/messages/DRAFT"))
2041            .and(query_param("$select", "body"))
2042            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
2043                "body": { "contentType": "HTML", "content": "<html><body><div></div></body></html>" }
2044            })))
2045            .mount(&server)
2046            .await;
2047        // 3. PATCH draft body, verify our HTML lands in `body.content`
2048        Mock::given(method("PATCH"))
2049            .and(path("/me/messages/DRAFT"))
2050            .and(body_partial_json(serde_json::json!({
2051                "body": { "contentType": "HTML" }
2052            })))
2053            .respond_with(ResponseTemplate::new(200))
2054            .mount(&server)
2055            .await;
2056        // 4. send
2057        Mock::given(method("POST"))
2058            .and(path("/me/messages/DRAFT/send"))
2059            .respond_with(ResponseTemplate::new(202))
2060            .mount(&server)
2061            .await;
2062
2063        let http = reqwest::Client::new();
2064        reply_message(&http, &server.uri(), "AT", "MSG", "Thanks!")
2065            .await
2066            .unwrap();
2067    }
2068
2069    #[tokio::test]
2070    async fn forward_message_creates_draft_with_recipients_and_sends() {
2071        use wiremock::matchers::body_partial_json;
2072        let server = MockServer::start().await;
2073
2074        Mock::given(method("POST"))
2075            .and(path_regex("/me/messages/MSG/createForward"))
2076            .and(body_partial_json(serde_json::json!({
2077                "toRecipients": [{ "emailAddress": { "address": "bob@example.com" } }]
2078            })))
2079            .respond_with(
2080                ResponseTemplate::new(201).set_body_json(serde_json::json!({ "id": "DRAFT" })),
2081            )
2082            .mount(&server)
2083            .await;
2084        Mock::given(method("GET"))
2085            .and(path("/me/messages/DRAFT"))
2086            .and(query_param("$select", "body"))
2087            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
2088                "body": { "contentType": "HTML", "content": "<html><body></body></html>" }
2089            })))
2090            .mount(&server)
2091            .await;
2092        Mock::given(method("PATCH"))
2093            .and(path("/me/messages/DRAFT"))
2094            .respond_with(ResponseTemplate::new(200))
2095            .mount(&server)
2096            .await;
2097        Mock::given(method("POST"))
2098            .and(path("/me/messages/DRAFT/send"))
2099            .respond_with(ResponseTemplate::new(202))
2100            .mount(&server)
2101            .await;
2102
2103        let http = reqwest::Client::new();
2104        forward_message(
2105            &http,
2106            &server.uri(),
2107            "AT",
2108            "MSG",
2109            &["bob@example.com".into()],
2110            "FYI",
2111        )
2112        .await
2113        .unwrap();
2114    }
2115
2116    #[test]
2117    fn text_to_html_escapes_and_breaks_paragraphs() {
2118        let out = text_to_html("Hej Edward,\n\nLine 1\nLine 2\n\n<script>x</script>");
2119        assert!(out.contains("<p>Hej Edward,</p>"));
2120        assert!(out.contains("<p>Line 1<br>Line 2</p>"));
2121        assert!(out.contains("&lt;script&gt;x&lt;/script&gt;"));
2122        assert!(!out.contains("<script>"));
2123    }
2124
2125    #[test]
2126    fn text_to_html_normalizes_crlf() {
2127        let out = text_to_html("A\r\nB\r\n\r\nC");
2128        assert!(out.contains("<p>A<br>B</p>"));
2129        assert!(out.contains("<p>C</p>"));
2130    }
2131
2132    #[test]
2133    fn find_body_tag_end_handles_attributes_and_case() {
2134        let html = "<html><BODY class=\"x\">content</BODY></html>";
2135        let pos = find_body_tag_end(html).unwrap();
2136        assert_eq!(&html[pos..pos + 7], "content");
2137    }
2138
2139    #[tokio::test]
2140    async fn list_mail_folders_parses_and_pages() {
2141        let server = MockServer::start().await;
2142        // First page advertises a next link; second page closes it out.
2143        Mock::given(method("GET"))
2144            .and(path("/me/mailFolders"))
2145            .and(query_param("$top", "100"))
2146            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
2147                "value": [
2148                    { "id": "F1", "displayName": "Biljetter", "totalItemCount": 3, "unreadItemCount": 0 }
2149                ],
2150                "@odata.nextLink": format!("{}/me/mailFolders?page=2", server.uri())
2151            })))
2152            .mount(&server)
2153            .await;
2154        Mock::given(method("GET"))
2155            .and(path("/me/mailFolders"))
2156            .and(query_param("page", "2"))
2157            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
2158                "value": [
2159                    { "id": "F2", "displayName": "Kvitton", "totalItemCount": 7, "unreadItemCount": 2 }
2160                ]
2161            })))
2162            .mount(&server)
2163            .await;
2164
2165        let http = reqwest::Client::new();
2166        let folders = list_mail_folders(&http, &server.uri(), "AT").await.unwrap();
2167        assert_eq!(folders.len(), 2);
2168        assert_eq!(folders[0].display_name, "Biljetter");
2169        assert_eq!(folders[0].total_item_count, Some(3));
2170        assert_eq!(folders[1].display_name, "Kvitton");
2171        assert_eq!(folders[1].unread_item_count, Some(2));
2172    }
2173
2174    #[tokio::test]
2175    async fn create_mail_folder_posts_display_name() {
2176        use wiremock::matchers::body_partial_json;
2177        let server = MockServer::start().await;
2178        Mock::given(method("POST"))
2179            .and(path("/me/mailFolders"))
2180            .and(body_partial_json(
2181                serde_json::json!({ "displayName": "Biljetter" }),
2182            ))
2183            .respond_with(
2184                ResponseTemplate::new(201)
2185                    .set_body_json(serde_json::json!({ "id": "NEWF", "displayName": "Biljetter" })),
2186            )
2187            .mount(&server)
2188            .await;
2189
2190        let http = reqwest::Client::new();
2191        let folder = create_mail_folder(&http, &server.uri(), "AT", "Biljetter")
2192            .await
2193            .unwrap();
2194        assert_eq!(folder.id, "NEWF");
2195        assert_eq!(folder.display_name, "Biljetter");
2196    }
2197
2198    #[tokio::test]
2199    async fn list_child_folders_hits_child_endpoint() {
2200        let server = MockServer::start().await;
2201        Mock::given(method("GET"))
2202            .and(path("/me/mailFolders/PARENT/childFolders"))
2203            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
2204                "value": [
2205                    { "id": "C1", "displayName": "MKLab", "totalItemCount": 5, "unreadItemCount": 0, "childFolderCount": 0 }
2206                ]
2207            })))
2208            .mount(&server)
2209            .await;
2210        let http = reqwest::Client::new();
2211        let children = list_child_folders(&http, &server.uri(), "AT", "PARENT")
2212            .await
2213            .unwrap();
2214        assert_eq!(children.len(), 1);
2215        assert_eq!(children[0].display_name, "MKLab");
2216    }
2217
2218    #[tokio::test]
2219    async fn create_child_folder_posts_to_parent() {
2220        use wiremock::matchers::body_partial_json;
2221        let server = MockServer::start().await;
2222        Mock::given(method("POST"))
2223            .and(path("/me/mailFolders/PARENT/childFolders"))
2224            .and(body_partial_json(
2225                serde_json::json!({ "displayName": "MKLab" }),
2226            ))
2227            .respond_with(
2228                ResponseTemplate::new(201)
2229                    .set_body_json(serde_json::json!({ "id": "C9", "displayName": "MKLab" })),
2230            )
2231            .mount(&server)
2232            .await;
2233        let http = reqwest::Client::new();
2234        let folder = create_child_folder(&http, &server.uri(), "AT", "PARENT", "MKLab")
2235            .await
2236            .unwrap();
2237        assert_eq!(folder.id, "C9");
2238    }
2239
2240    #[tokio::test]
2241    async fn delete_mail_folder_hits_delete_endpoint() {
2242        let server = MockServer::start().await;
2243        Mock::given(method("DELETE"))
2244            .and(path("/me/mailFolders/F1"))
2245            .respond_with(ResponseTemplate::new(204))
2246            .mount(&server)
2247            .await;
2248        let http = reqwest::Client::new();
2249        delete_mail_folder(&http, &server.uri(), "AT", "F1")
2250            .await
2251            .unwrap();
2252    }
2253
2254    #[tokio::test]
2255    async fn get_categories_parses_field() {
2256        let server = MockServer::start().await;
2257        Mock::given(method("GET"))
2258            .and(path_regex(r"^/me/messages/.+$"))
2259            .and(query_param("$select", "categories"))
2260            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
2261                "categories": ["Receipts", "Urgent"]
2262            })))
2263            .mount(&server)
2264            .await;
2265        let http = reqwest::Client::new();
2266        let cats = get_categories(&http, &server.uri(), "AT", "MSG")
2267            .await
2268            .unwrap();
2269        assert_eq!(cats, vec!["Receipts", "Urgent"]);
2270    }
2271
2272    #[tokio::test]
2273    async fn set_categories_patches_array() {
2274        use wiremock::matchers::body_partial_json;
2275        let server = MockServer::start().await;
2276        Mock::given(method("PATCH"))
2277            .and(path_regex(r"^/me/messages/.+$"))
2278            .and(body_partial_json(
2279                serde_json::json!({ "categories": ["receipt", "ticket"] }),
2280            ))
2281            .respond_with(ResponseTemplate::new(200))
2282            .mount(&server)
2283            .await;
2284        let http = reqwest::Client::new();
2285        set_categories(
2286            &http,
2287            &server.uri(),
2288            "AT",
2289            "MSG",
2290            &["receipt".to_string(), "ticket".to_string()],
2291        )
2292        .await
2293        .unwrap();
2294    }
2295
2296    #[tokio::test]
2297    async fn move_message_posts_destination() {
2298        let server = MockServer::start().await;
2299        Mock::given(method("POST"))
2300            .and(path_regex("/me/messages/[A-Za-z0-9]+/move"))
2301            .respond_with(
2302                ResponseTemplate::new(201).set_body_json(serde_json::json!({"id": "NEW"})),
2303            )
2304            .mount(&server)
2305            .await;
2306        let http = reqwest::Client::new();
2307        move_message(&http, &server.uri(), "AT", "MSG", "archive")
2308            .await
2309            .unwrap();
2310    }
2311
2312    #[tokio::test]
2313    async fn get_message_parses_graph_response() {
2314        let server = MockServer::start().await;
2315        Mock::given(method("GET"))
2316            .and(path_regex("/me/messages/[A-Za-z0-9]+"))
2317            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
2318                "id": "AAA",
2319                "subject": "Hello",
2320                "from": { "emailAddress": { "name": "Maria", "address": "maria@mklab.se" } },
2321                "toRecipients": [
2322                    { "emailAddress": { "name": "Kristofer", "address": "kristofer@mklab.se" } }
2323                ],
2324                "ccRecipients": [],
2325                "bccRecipients": [],
2326                "receivedDateTime": "2026-05-14T22:00:00Z",
2327                "sentDateTime": "2026-05-14T21:59:30Z",
2328                "isRead": false,
2329                "body": { "contentType": "html", "content": "<p>Hi</p>" },
2330                "hasAttachments": true
2331            })))
2332            .mount(&server)
2333            .await;
2334
2335        let http = reqwest::Client::new();
2336        let m = get_message(&http, &server.uri(), "AT", "u@e.com", "AAA")
2337            .await
2338            .unwrap();
2339        assert_eq!(m.id, "AAA");
2340        assert_eq!(m.subject, "Hello");
2341        assert_eq!(m.from.name, "Maria");
2342        assert_eq!(m.to.len(), 1);
2343        assert_eq!(m.to[0].address, "kristofer@mklab.se");
2344        assert!(matches!(
2345            m.body_content_type,
2346            pidge_core::BodyContentType::Html
2347        ));
2348        assert_eq!(m.body_content, "<p>Hi</p>");
2349        assert!(m.has_attachments);
2350    }
2351
2352    #[tokio::test]
2353    async fn get_message_selects_and_maps_the_draft_flag() {
2354        let server = MockServer::start().await;
2355        Mock::given(method("GET"))
2356            .and(path("/me/messages/D1"))
2357            .respond_with(move |req: &wiremock::Request| {
2358                let select = req
2359                    .url
2360                    .query_pairs()
2361                    .find(|(k, _)| k == "$select")
2362                    .map(|(_, v)| v.into_owned())
2363                    .unwrap_or_default();
2364                assert!(select.split(',').any(|f| f == "isDraft"), "{select}");
2365                ResponseTemplate::new(200).set_body_json(serde_json::json!({
2366                    "id": "D1",
2367                    "receivedDateTime": "2026-05-14T22:00:00Z",
2368                    "sentDateTime": "2026-05-14T21:59:30Z",
2369                    "body": { "contentType": "text", "content": "" },
2370                    "isDraft": true
2371                }))
2372            })
2373            .mount(&server)
2374            .await;
2375        let http = reqwest::Client::new();
2376        let m = get_message(&http, &server.uri(), "AT", "u@e.com", "D1")
2377            .await
2378            .unwrap();
2379        assert!(m.is_draft);
2380    }
2381
2382    #[tokio::test]
2383    async fn update_draft_recipients_patches_only_the_given_lists() {
2384        let server = MockServer::start().await;
2385        Mock::given(method("PATCH"))
2386            .and(path("/me/messages/D1"))
2387            .and(body_string(
2388                serde_json::json!({
2389                    "ccRecipients": [{ "emailAddress": { "address": "cc@example.com" } }]
2390                })
2391                .to_string(),
2392            ))
2393            .respond_with(ResponseTemplate::new(200))
2394            .expect(1)
2395            .mount(&server)
2396            .await;
2397        let http = reqwest::Client::new();
2398        update_draft_recipients(
2399            &http,
2400            &server.uri(),
2401            "AT",
2402            "D1",
2403            None,
2404            Some(&["cc@example.com".to_string()]),
2405            None,
2406        )
2407        .await
2408        .unwrap();
2409    }
2410
2411    #[tokio::test]
2412    async fn list_attachments_filters_file_attachments() {
2413        let server = MockServer::start().await;
2414        Mock::given(method("GET"))
2415            .and(path_regex("/me/messages/[A-Za-z0-9]+/attachments"))
2416            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
2417                "value": [
2418                    {
2419                        "@odata.type": "#microsoft.graph.fileAttachment",
2420                        "id": "att-1",
2421                        "name": "report.pdf",
2422                        "contentType": "application/pdf",
2423                        "size": 12345,
2424                        "isInline": false
2425                    },
2426                    {
2427                        "@odata.type": "#microsoft.graph.itemAttachment",
2428                        "id": "att-2",
2429                        "name": "an-email.eml",
2430                        "contentType": "message/rfc822",
2431                        "size": 7777,
2432                        "isInline": false
2433                    }
2434                ]
2435            })))
2436            .mount(&server)
2437            .await;
2438
2439        let http = reqwest::Client::new();
2440        let atts = list_attachments(&http, &server.uri(), "AT", "MSG")
2441            .await
2442            .unwrap();
2443        assert_eq!(atts.len(), 1);
2444        assert_eq!(atts[0].name, "report.pdf");
2445        assert_eq!(atts[0].size_bytes, 12345);
2446    }
2447
2448    #[tokio::test]
2449    async fn get_attachment_bytes_decodes_base64() {
2450        let server = MockServer::start().await;
2451        Mock::given(method("GET"))
2452            .and(path_regex(
2453                "/me/messages/[A-Za-z0-9]+/attachments/[A-Za-z0-9-]+",
2454            ))
2455            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
2456                "id": "att-1",
2457                "name": "report.pdf",
2458                "contentType": "application/pdf",
2459                "size": 5,
2460                "isInline": false,
2461                "contentBytes": "aGVsbG8="
2462            })))
2463            .mount(&server)
2464            .await;
2465
2466        let http = reqwest::Client::new();
2467        let bytes = get_attachment_bytes(&http, &server.uri(), "AT", "MSG", "att-1")
2468            .await
2469            .unwrap();
2470        assert_eq!(bytes, b"hello");
2471    }
2472
2473    #[tokio::test]
2474    async fn mark_read_patches_isread_true() {
2475        let server = MockServer::start().await;
2476        Mock::given(method("PATCH"))
2477            .and(path_regex("/me/messages/[A-Za-z0-9]+"))
2478            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
2479            .mount(&server)
2480            .await;
2481
2482        let http = reqwest::Client::new();
2483        mark_read(&http, &server.uri(), "AT", "MSG").await.unwrap();
2484    }
2485
2486    #[tokio::test]
2487    async fn fetch_message_headers_parses_array() {
2488        let server = MockServer::start().await;
2489        Mock::given(method("GET"))
2490            .and(path_regex(r"^/me/messages/.+$"))
2491            .and(query_param("$select", "internetMessageHeaders"))
2492            .and(header("authorization", "Bearer AT"))
2493            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
2494                "internetMessageHeaders": [
2495                    { "name": "List-Unsubscribe", "value": "<mailto:u@x>, <https://x/u>" },
2496                    { "name": "List-Unsubscribe-Post", "value": "List-Unsubscribe=One-Click" }
2497                ]
2498            })))
2499            .mount(&server)
2500            .await;
2501
2502        let http = reqwest::Client::new();
2503        let headers = fetch_message_headers(&http, &server.uri(), "AT", "MSGID")
2504            .await
2505            .unwrap();
2506        assert_eq!(headers.len(), 2);
2507        assert_eq!(headers[0].0, "List-Unsubscribe");
2508        assert_eq!(headers[0].1, "<mailto:u@x>, <https://x/u>");
2509        assert_eq!(headers[1].0, "List-Unsubscribe-Post");
2510    }
2511
2512    #[tokio::test]
2513    async fn unsubscribe_one_click_posts_exact_form_body() {
2514        let server = MockServer::start().await;
2515        Mock::given(method("POST"))
2516            .and(path("/u"))
2517            .and(body_string("List-Unsubscribe=One-Click"))
2518            .respond_with(ResponseTemplate::new(200))
2519            .expect(1)
2520            .mount(&server)
2521            .await;
2522        post_one_click(
2523            &format!("{}/u", server.uri()),
2524            OneClickPolicy::AllowLoopback,
2525        )
2526        .await
2527        .unwrap();
2528    }
2529
2530    #[tokio::test]
2531    async fn unsubscribe_one_click_reports_a_500_without_its_status_or_body() {
2532        let server = MockServer::start().await;
2533        Mock::given(method("POST"))
2534            .and(path("/u"))
2535            .respond_with(ResponseTemplate::new(500).set_body_string("nope"))
2536            .mount(&server)
2537            .await;
2538        let err = post_one_click(
2539            &format!("{}/u", server.uri()),
2540            OneClickPolicy::AllowLoopback,
2541        )
2542        .await
2543        .unwrap_err();
2544        assert!(matches!(err, ClientError::UnsubscribeRejected), "{err:?}");
2545        assert!(!err.to_string().contains("500"));
2546        assert!(!err.to_string().contains("nope"));
2547    }
2548
2549    #[tokio::test]
2550    async fn unsubscribe_one_click_refuses_http_without_a_request() {
2551        let server = MockServer::start().await;
2552        Mock::given(method("POST"))
2553            .respond_with(ResponseTemplate::new(200))
2554            .expect(0)
2555            .mount(&server)
2556            .await;
2557        let port = server.address().port();
2558        for url in [
2559            format!("http://localhost:{port}/u"),
2560            format!("{}/u", server.uri()),
2561        ] {
2562            let err = unsubscribe_one_click(&url).await.unwrap_err();
2563            assert!(matches!(err, ClientError::UnsubscribeRejected), "{url}");
2564        }
2565    }
2566
2567    #[tokio::test]
2568    async fn unsubscribe_one_click_refuses_ip_literals_and_internal_hosts() {
2569        let server = MockServer::start().await;
2570        Mock::given(method("POST"))
2571            .respond_with(ResponseTemplate::new(200))
2572            .expect(0)
2573            .mount(&server)
2574            .await;
2575        let port = server.address().port();
2576        for url in [
2577            format!("https://127.0.0.1:{port}/u"),
2578            "https://10.0.0.1/u".to_string(),
2579            "https://[::1]/u".to_string(),
2580            "https://93.184.215.14/u".to_string(),
2581            // A name resolving to loopback is refused like the literal.
2582            format!("https://localhost:{port}/u"),
2583            "ftp://example.com/u".to_string(),
2584            "not a url".to_string(),
2585        ] {
2586            let err = unsubscribe_one_click(&url).await.unwrap_err();
2587            assert!(matches!(err, ClientError::UnsubscribeRejected), "{url}");
2588        }
2589        // Even the test relaxation only admits loopback.
2590        let err = post_one_click("http://10.0.0.1/u", OneClickPolicy::AllowLoopback)
2591            .await
2592            .unwrap_err();
2593        assert!(matches!(err, ClientError::UnsubscribeRejected));
2594    }
2595
2596    #[tokio::test]
2597    async fn unsubscribe_one_click_does_not_follow_a_redirect() {
2598        let server = MockServer::start().await;
2599        Mock::given(method("POST"))
2600            .and(path("/u"))
2601            .respond_with(ResponseTemplate::new(302).insert_header("Location", "/internal"))
2602            .expect(1)
2603            .mount(&server)
2604            .await;
2605        Mock::given(path("/internal"))
2606            .respond_with(ResponseTemplate::new(200))
2607            .expect(0)
2608            .mount(&server)
2609            .await;
2610        let err = post_one_click(
2611            &format!("{}/u", server.uri()),
2612            OneClickPolicy::AllowLoopback,
2613        )
2614        .await
2615        .unwrap_err();
2616        assert!(matches!(err, ClientError::UnsubscribeRejected), "{err:?}");
2617    }
2618
2619    #[test]
2620    fn one_click_address_policy() {
2621        use std::net::IpAddr;
2622        let allowed = |s: &str, lo| one_click_address_allowed(s.parse::<IpAddr>().unwrap(), lo);
2623        for bad in [
2624            "10.1.2.3",
2625            "172.16.0.1",
2626            "172.31.255.255",
2627            "192.168.1.1",
2628            "169.254.169.254",
2629            "0.0.0.0",
2630            "100.64.0.1",
2631            "::",
2632            "fe80::1",
2633            "fc00::1",
2634            "fd12:3456::1",
2635            "::ffff:10.0.0.1",
2636            "::ffff:127.0.0.1",
2637        ] {
2638            assert!(!allowed(bad, false), "{bad}");
2639        }
2640        assert!(!allowed("127.0.0.1", false));
2641        assert!(!allowed("::1", false));
2642        assert!(allowed("127.0.0.1", true));
2643        assert!(!allowed("10.0.0.1", true));
2644        for good in ["93.184.215.14", "172.32.0.1", "2606:2800:220:1::1"] {
2645            assert!(allowed(good, false), "{good}");
2646        }
2647    }
2648}
2649
2650#[cfg(test)]
2651mod cursor_paging_tests {
2652    use super::*;
2653    use wiremock::matchers::{method, path, query_param};
2654    use wiremock::{Mock, MockServer, ResponseTemplate};
2655
2656    fn msg(id: &str, received: &str) -> serde_json::Value {
2657        serde_json::json!({
2658            "id": id,
2659            "subject": format!("s-{id}"),
2660            "from": {"emailAddress": {"name": "N", "address": "n@x.se"}},
2661            "receivedDateTime": received,
2662            "isRead": true,
2663            "bodyPreview": "p",
2664            "hasAttachments": false
2665        })
2666    }
2667
2668    #[tokio::test]
2669    async fn next_link_is_surfaced_and_followable() {
2670        let server = MockServer::start().await;
2671        let page2_url = format!("{}/me/mailFolders/inbox/messages?page=2", server.uri());
2672        Mock::given(method("GET"))
2673            .and(path("/me/mailFolders/inbox/messages"))
2674            .and(query_param("page", "2"))
2675            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
2676                "value": [msg("m3", "2026-07-01T10:00:00Z")]
2677            })))
2678            .mount(&server)
2679            .await;
2680        Mock::given(method("GET"))
2681            .and(path("/me/mailFolders/inbox/messages"))
2682            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
2683                "value": [msg("m1", "2026-07-03T10:00:00Z"), msg("m2", "2026-07-02T10:00:00Z")],
2684                "@odata.nextLink": page2_url
2685            })))
2686            .mount(&server)
2687            .await;
2688
2689        let http = reqwest::Client::new();
2690        let page1 = list_inbox(&http, &server.uri(), "tok", "a@b.se", 2, 0, false)
2691            .await
2692            .unwrap();
2693        assert_eq!(page1.messages.len(), 2);
2694        let next = page1.next_link.expect("first page links onward");
2695
2696        let page2 = list_messages_at(&http, &server.uri(), "tok", "a@b.se", &next)
2697            .await
2698            .unwrap();
2699        assert_eq!(page2.messages.len(), 1);
2700        assert_eq!(page2.messages[0].id, "m3");
2701        assert!(page2.next_link.is_none(), "final page has no continuation");
2702    }
2703}