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