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