1use 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 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 #[serde(rename = "@odata.type", default)]
35 odata_type: Option<String>,
36}
37
38fn 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
57pub(crate) fn clean(text: Option<String>) -> String {
60 text.as_deref()
61 .map(pidge_core::render::strip_controls)
62 .unwrap_or_default()
63}
64
65pub(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 #[serde(rename = "@odata.nextLink", default)]
103 next_link: Option<String>,
104}
105
106pub struct InboxPage {
108 pub messages: Vec<Message>,
109 pub has_more: bool,
110 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 #[serde(rename = "contentBytes", default)]
171 content_bytes: Option<String>,
172}
173
174pub 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#[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
228pub 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 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
303pub(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
324pub 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 ("$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
370pub 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
414pub 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 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", "ed),
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: 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
529pub 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
592async 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
623pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
665pub(crate) enum OneClickPolicy {
666 PublicOnly,
668 AllowLoopback,
672}
673
674pub 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 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
742fn 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 || (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 || (first & 0xfe00) == 0xfc00
771 || (first & 0xffc0) == 0xfe80)
773 }
774 }
775}
776
777pub 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 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
825pub 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
858pub 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
875pub 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
892pub 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
917pub 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
938pub 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
977pub 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
996fn text_to_html(text: &str) -> String {
1006 fn escape(s: &str) -> String {
1007 s.replace('&', "&")
1008 .replace('<', "<")
1009 .replace('>', ">")
1010 .replace('"', """)
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
1033async 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 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
1102pub 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
1116pub 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
1129pub 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
1161pub 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
1176pub 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
1210pub 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
1239pub 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
1272pub 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 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
1302pub 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
1326pub 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
1365pub 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
1387pub 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
1416pub 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#[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
1486pub 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#[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 #[serde(rename = "totalItemCount", default)]
1525 pub total_item_count: Option<u64>,
1526 #[serde(rename = "unreadItemCount", default)]
1528 pub unread_item_count: Option<u64>,
1529 #[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
1542const FOLDER_SELECT: &str = "id,displayName,totalItemCount,unreadItemCount,childFolderCount";
1545
1546async 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 match list.next_link {
1569 Some(next) => url = next,
1570 None => break,
1571 }
1572 }
1573 Ok(folders)
1574}
1575
1576pub 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
1588pub 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
1626pub 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
1641pub 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
1654pub 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 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 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 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 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("<script>x</script>"));
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 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 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 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}