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(default)]
26 flag: Option<GraphFlag>,
27}
28
29#[derive(Debug, Deserialize)]
30struct GraphFlag {
31 #[serde(rename = "flagStatus", default)]
32 flag_status: Option<String>,
33}
34
35fn flag_status_from(g: Option<GraphFlag>) -> FlagStatus {
36 match g.and_then(|f| f.flag_status).as_deref() {
37 Some("flagged") => FlagStatus::Flagged,
38 Some("complete") => FlagStatus::Complete,
39 _ => FlagStatus::NotFlagged,
40 }
41}
42
43#[derive(Debug, Deserialize)]
44struct GraphFromWrapper {
45 #[serde(rename = "emailAddress")]
46 email_address: GraphFromAddress,
47}
48
49#[derive(Debug, Deserialize)]
50struct GraphFromAddress {
51 name: Option<String>,
52 address: Option<String>,
53}
54
55#[derive(Debug, Deserialize)]
56struct GraphList {
57 value: Vec<GraphMessage>,
58 #[serde(rename = "@odata.nextLink", default)]
61 next_link: Option<String>,
62}
63
64pub struct InboxPage {
66 pub messages: Vec<Message>,
67 pub has_more: bool,
68}
69
70#[derive(Debug, Deserialize)]
71struct GraphFullMessage {
72 id: String,
73 subject: Option<String>,
74 from: Option<GraphFromWrapper>,
75 #[serde(rename = "toRecipients", default)]
76 to_recipients: Vec<GraphFromWrapper>,
77 #[serde(rename = "ccRecipients", default)]
78 cc_recipients: Vec<GraphFromWrapper>,
79 #[serde(rename = "bccRecipients", default)]
80 bcc_recipients: Vec<GraphFromWrapper>,
81 #[serde(rename = "receivedDateTime")]
82 received_date_time: chrono::DateTime<chrono::Utc>,
83 #[serde(rename = "sentDateTime")]
84 sent_date_time: chrono::DateTime<chrono::Utc>,
85 #[serde(rename = "isRead")]
86 is_read: Option<bool>,
87 body: GraphBody,
88 #[serde(rename = "hasAttachments")]
89 has_attachments: Option<bool>,
90 #[serde(default)]
91 flag: Option<GraphFlag>,
92}
93
94#[derive(Debug, Deserialize)]
95struct GraphBody {
96 #[serde(rename = "contentType")]
97 content_type: String,
98 content: String,
99}
100
101#[derive(Debug, Deserialize)]
102struct GraphAttachmentList {
103 value: Vec<GraphAttachment>,
104}
105
106#[derive(Debug, Deserialize)]
107struct GraphAttachment {
108 id: String,
109 name: Option<String>,
110 #[serde(rename = "contentType")]
111 content_type: Option<String>,
112 size: Option<u64>,
113 #[serde(rename = "isInline")]
114 is_inline: Option<bool>,
115 #[serde(rename = "contentId")]
116 content_id: Option<String>,
117 #[serde(rename = "@odata.type", default)]
118 odata_type: Option<String>,
119 #[serde(rename = "contentBytes", default)]
121 content_bytes: Option<String>,
122}
123
124pub async fn list_inbox(
130 http: &reqwest::Client,
131 base_url: &str,
132 access_token: &str,
133 account: &str,
134 limit: usize,
135 skip: usize,
136 unread_only: bool,
137) -> Result<InboxPage, ClientError> {
138 list_folder(
139 http,
140 base_url,
141 access_token,
142 account,
143 "inbox",
144 limit,
145 skip,
146 unread_only,
147 )
148 .await
149}
150
151pub async fn list_drafts(
154 http: &reqwest::Client,
155 base_url: &str,
156 access_token: &str,
157 account: &str,
158 limit: usize,
159 skip: usize,
160) -> Result<InboxPage, ClientError> {
161 list_folder(
162 http,
163 base_url,
164 access_token,
165 account,
166 "drafts",
167 limit,
168 skip,
169 false,
170 )
171 .await
172}
173
174#[allow(clippy::too_many_arguments)]
175async fn list_folder(
176 http: &reqwest::Client,
177 base_url: &str,
178 access_token: &str,
179 account: &str,
180 folder: &str,
181 limit: usize,
182 skip: usize,
183 unread_only: bool,
184) -> Result<InboxPage, ClientError> {
185 let url = format!("{base_url}/me/mailFolders/{folder}/messages");
186 let mut req = http.get(&url).bearer_auth(access_token).query(&[
190 (
191 "$select",
192 "id,subject,from,receivedDateTime,isRead,bodyPreview,body,hasAttachments,flag",
193 ),
194 ("$orderby", "receivedDateTime desc"),
195 ("$top", &limit.to_string()),
196 ]);
197 if skip > 0 {
198 req = req.query(&[("$skip", &skip.to_string())]);
199 }
200 if unread_only {
201 req = req.query(&[("$filter", "isRead eq false")]);
202 }
203
204 let resp = req.send().await?;
205 let status = resp.status();
206 if !status.is_success() {
207 let text = resp.text().await.unwrap_or_default();
208 return Err(ClientError::Graph {
209 status: status.as_u16(),
210 message: text,
211 });
212 }
213
214 let list: GraphList = resp.json().await?;
215 Ok(InboxPage {
216 has_more: list.next_link.is_some(),
217 messages: list
218 .value
219 .into_iter()
220 .map(|g| to_message(g, account))
221 .collect(),
222 })
223}
224
225pub async fn search_messages(
236 http: &reqwest::Client,
237 base_url: &str,
238 access_token: &str,
239 account: &str,
240 query: &str,
241 limit: usize,
242) -> Result<Vec<Message>, ClientError> {
243 let quoted = format!("\"{}\"", query.replace('"', "\\\""));
245 let url = format!("{base_url}/me/messages");
246 let resp = http
247 .get(&url)
248 .bearer_auth(access_token)
249 .header("Prefer", "outlook.body-content-type=\"text\"")
250 .query(&[
251 (
252 "$select",
253 "id,subject,from,receivedDateTime,isRead,bodyPreview,body,hasAttachments,flag",
254 ),
255 ("$top", &limit.to_string()),
256 ("$search", "ed),
257 ])
258 .send()
259 .await?;
260 let status = resp.status();
261 if !status.is_success() {
262 let text = resp.text().await.unwrap_or_default();
263 return Err(ClientError::Graph {
264 status: status.as_u16(),
265 message: text,
266 });
267 }
268 let list: GraphList = resp.json().await?;
269 Ok(list
270 .value
271 .into_iter()
272 .map(|g| to_message(g, account))
273 .collect())
274}
275
276fn to_message(g: GraphMessage, account: &str) -> Message {
277 let (body, body_content_type) = match g.body {
278 Some(b) => {
279 let kind = if b.content_type.eq_ignore_ascii_case("html") {
280 pidge_core::BodyContentType::Html
281 } else {
282 pidge_core::BodyContentType::Text
283 };
284 (b.content, kind)
285 }
286 None => (String::new(), pidge_core::BodyContentType::Text),
287 };
288 Message {
289 account: account.to_string(),
290 id: g.id,
291 from: MessageFrom {
292 name: g
293 .from
294 .as_ref()
295 .and_then(|f| f.email_address.name.clone())
296 .unwrap_or_default(),
297 address: g
298 .from
299 .as_ref()
300 .and_then(|f| f.email_address.address.clone())
301 .unwrap_or_default(),
302 },
303 subject: g.subject.unwrap_or_default(),
304 received_at: g.received_date_time,
305 is_read: g.is_read.unwrap_or(true),
306 preview: g.body_preview.unwrap_or_default(),
311 flag_status: flag_status_from(g.flag),
312 has_attachments: g.has_attachments.unwrap_or(false),
313 body,
314 body_content_type,
315 }
316}
317
318pub async fn get_message(
320 http: &reqwest::Client,
321 base_url: &str,
322 access_token: &str,
323 account: &str,
324 message_id: &str,
325) -> Result<pidge_core::FullMessage, ClientError> {
326 let url = format!(
327 "{base_url}/me/messages/{message_id}\
328 ?$select=id,subject,from,toRecipients,ccRecipients,bccRecipients,\
329receivedDateTime,sentDateTime,isRead,body,hasAttachments,flag"
330 );
331 let resp = http.get(&url).bearer_auth(access_token).send().await?;
332 let status = resp.status();
333 if !status.is_success() {
334 let text = resp.text().await.unwrap_or_default();
335 return Err(ClientError::Graph {
336 status: status.as_u16(),
337 message: text,
338 });
339 }
340 let g: GraphFullMessage = resp.json().await?;
341
342 fn from(addr: GraphFromAddress) -> pidge_core::MessageFrom {
343 pidge_core::MessageFrom {
344 name: addr.name.unwrap_or_default(),
345 address: addr.address.unwrap_or_default(),
346 }
347 }
348 fn unwrap_recipients(rs: Vec<GraphFromWrapper>) -> Vec<pidge_core::MessageFrom> {
349 rs.into_iter().map(|w| from(w.email_address)).collect()
350 }
351
352 let content_type = match g.body.content_type.to_lowercase().as_str() {
353 "html" => pidge_core::BodyContentType::Html,
354 _ => pidge_core::BodyContentType::Text,
355 };
356
357 Ok(pidge_core::FullMessage {
358 account: account.to_string(),
359 id: g.id,
360 from: g
361 .from
362 .map(|w| from(w.email_address))
363 .unwrap_or_else(|| pidge_core::MessageFrom {
364 name: String::new(),
365 address: String::new(),
366 }),
367 to: unwrap_recipients(g.to_recipients),
368 cc: unwrap_recipients(g.cc_recipients),
369 bcc: unwrap_recipients(g.bcc_recipients),
370 subject: g.subject.unwrap_or_default(),
371 received_at: g.received_date_time,
372 sent_at: g.sent_date_time,
373 is_read: g.is_read.unwrap_or(true),
374 body_content_type: content_type,
375 body_content: g.body.content,
376 has_attachments: g.has_attachments.unwrap_or(false),
377 flag_status: flag_status_from(g.flag),
378 })
379}
380
381pub async fn fetch_message_headers(
385 http: &reqwest::Client,
386 base_url: &str,
387 access_token: &str,
388 message_id: &str,
389) -> Result<Vec<(String, String)>, ClientError> {
390 let url = format!("{base_url}/me/messages/{message_id}?$select=internetMessageHeaders");
391 let resp = http.get(&url).bearer_auth(access_token).send().await?;
392 let status = resp.status();
393 if !status.is_success() {
394 let text = resp.text().await.unwrap_or_default();
395 return Err(ClientError::Graph {
396 status: status.as_u16(),
397 message: text,
398 });
399 }
400 let body: GraphHeadersResponse = resp.json().await?;
401 Ok(body
402 .internet_message_headers
403 .unwrap_or_default()
404 .into_iter()
405 .map(|h| (h.name, h.value))
406 .collect())
407}
408
409#[derive(serde::Deserialize)]
410struct GraphHeadersResponse {
411 #[serde(rename = "internetMessageHeaders", default)]
412 internet_message_headers: Option<Vec<GraphHeader>>,
413}
414
415#[derive(serde::Deserialize)]
416struct GraphHeader {
417 name: String,
418 value: String,
419}
420
421pub async fn list_attachments(
424 http: &reqwest::Client,
425 base_url: &str,
426 access_token: &str,
427 message_id: &str,
428) -> Result<Vec<pidge_core::Attachment>, ClientError> {
429 let url = format!(
436 "{base_url}/me/messages/{message_id}/attachments\
437 ?$select=id,name,contentType,size,isInline"
438 );
439 let resp = http.get(&url).bearer_auth(access_token).send().await?;
440 let status = resp.status();
441 if !status.is_success() {
442 let text = resp.text().await.unwrap_or_default();
443 return Err(ClientError::Graph {
444 status: status.as_u16(),
445 message: text,
446 });
447 }
448 let list: GraphAttachmentList = resp.json().await?;
449 Ok(list
450 .value
451 .into_iter()
452 .filter(|a| {
453 a.odata_type
454 .as_deref()
455 .map(|t| t == "#microsoft.graph.fileAttachment")
456 .unwrap_or(true)
457 })
458 .map(|a| pidge_core::Attachment {
459 id: a.id,
460 name: a.name.unwrap_or_default(),
461 content_type: a.content_type.unwrap_or_default(),
462 size_bytes: a.size.unwrap_or(0),
463 is_inline: a.is_inline.unwrap_or(false),
464 content_id: a.content_id,
465 })
466 .collect())
467}
468
469pub async fn get_attachment_bytes(
472 http: &reqwest::Client,
473 base_url: &str,
474 access_token: &str,
475 message_id: &str,
476 attachment_id: &str,
477) -> Result<Vec<u8>, ClientError> {
478 use base64::Engine;
479 use base64::engine::general_purpose::STANDARD;
480
481 let url = format!("{base_url}/me/messages/{message_id}/attachments/{attachment_id}");
482 let resp = http.get(&url).bearer_auth(access_token).send().await?;
483 let status = resp.status();
484 if !status.is_success() {
485 let text = resp.text().await.unwrap_or_default();
486 return Err(ClientError::Graph {
487 status: status.as_u16(),
488 message: text,
489 });
490 }
491 let g: GraphAttachment = resp.json().await?;
492 let b64 = g.content_bytes.ok_or_else(|| ClientError::Graph {
493 status: 200,
494 message: "attachment response missing contentBytes".to_string(),
495 })?;
496 STANDARD.decode(&b64).map_err(|e| ClientError::Graph {
497 status: 200,
498 message: format!("attachment base64 decode: {e}"),
499 })
500}
501
502pub async fn mark_read(
504 http: &reqwest::Client,
505 base_url: &str,
506 access_token: &str,
507 message_id: &str,
508) -> Result<(), ClientError> {
509 patch_message(
510 http,
511 base_url,
512 access_token,
513 message_id,
514 &serde_json::json!({ "isRead": true }),
515 )
516 .await
517}
518
519pub async fn mark_unread(
521 http: &reqwest::Client,
522 base_url: &str,
523 access_token: &str,
524 message_id: &str,
525) -> Result<(), ClientError> {
526 patch_message(
527 http,
528 base_url,
529 access_token,
530 message_id,
531 &serde_json::json!({ "isRead": false }),
532 )
533 .await
534}
535
536pub async fn set_flag(
538 http: &reqwest::Client,
539 base_url: &str,
540 access_token: &str,
541 message_id: &str,
542 flagged: bool,
543) -> Result<(), ClientError> {
544 let status = if flagged { "flagged" } else { "notFlagged" };
545 patch_message(
546 http,
547 base_url,
548 access_token,
549 message_id,
550 &serde_json::json!({ "flag": { "flagStatus": status } }),
551 )
552 .await
553}
554
555async fn patch_message(
556 http: &reqwest::Client,
557 base_url: &str,
558 access_token: &str,
559 message_id: &str,
560 body: &serde_json::Value,
561) -> Result<(), ClientError> {
562 let url = format!("{base_url}/me/messages/{message_id}");
563 let resp = http
564 .patch(&url)
565 .bearer_auth(access_token)
566 .json(body)
567 .send()
568 .await?;
569 let status = resp.status();
570 if !status.is_success() {
571 let text = resp.text().await.unwrap_or_default();
572 return Err(ClientError::Graph {
573 status: status.as_u16(),
574 message: text,
575 });
576 }
577 Ok(())
578}
579
580pub async fn send_mail(
586 http: &reqwest::Client,
587 base_url: &str,
588 access_token: &str,
589 message: &Outgoing,
590) -> Result<(), ClientError> {
591 let url = format!("{base_url}/me/sendMail");
592 let body = serde_json::json!({
593 "message": message.to_graph_json(),
594 "saveToSentItems": true,
595 });
596 post_no_body(http, &url, access_token, &body).await
597}
598
599fn text_to_html(text: &str) -> String {
609 fn escape(s: &str) -> String {
610 s.replace('&', "&")
611 .replace('<', "<")
612 .replace('>', ">")
613 .replace('"', """)
614 }
615 let normalized = text.replace("\r\n", "\n").replace('\r', "\n");
616 normalized
617 .split("\n\n")
618 .filter(|p| !p.trim().is_empty())
619 .map(|paragraph| {
620 let lines: Vec<String> = paragraph
621 .trim_matches('\n')
622 .split('\n')
623 .map(escape)
624 .collect();
625 format!("<p>{}</p>", lines.join("<br>"))
626 })
627 .collect::<Vec<_>>()
628 .join("")
629}
630
631#[derive(Debug, Deserialize)]
632struct GraphBodyOnly {
633 body: GraphBody,
634}
635
636async fn prepend_html_to_draft(
640 http: &reqwest::Client,
641 base_url: &str,
642 access_token: &str,
643 message_id: &str,
644 html_to_prepend: &str,
645) -> Result<(), ClientError> {
646 let get_url = format!("{base_url}/me/messages/{message_id}?$select=body");
647 let resp = http.get(&get_url).bearer_auth(access_token).send().await?;
648 let status = resp.status();
649 if !status.is_success() {
650 let text = resp.text().await.unwrap_or_default();
651 return Err(ClientError::Graph {
652 status: status.as_u16(),
653 message: text,
654 });
655 }
656 let existing: GraphBodyOnly = resp.json().await?;
657 let existing_content = existing.body.content;
658
659 let new_content = match find_body_tag_end(&existing_content) {
663 Some(pos) => {
664 let mut s = String::with_capacity(existing_content.len() + html_to_prepend.len());
665 s.push_str(&existing_content[..pos]);
666 s.push_str(html_to_prepend);
667 s.push_str(&existing_content[pos..]);
668 s
669 }
670 None => format!("{html_to_prepend}{existing_content}"),
671 };
672
673 let patch_url = format!("{base_url}/me/messages/{message_id}");
674 let patch_body = serde_json::json!({
675 "body": {
676 "contentType": "HTML",
677 "content": new_content,
678 }
679 });
680 let resp = http
681 .patch(&patch_url)
682 .bearer_auth(access_token)
683 .json(&patch_body)
684 .send()
685 .await?;
686 let status = resp.status();
687 if !status.is_success() {
688 let text = resp.text().await.unwrap_or_default();
689 return Err(ClientError::Graph {
690 status: status.as_u16(),
691 message: text,
692 });
693 }
694 Ok(())
695}
696
697fn find_body_tag_end(html: &str) -> Option<usize> {
698 let lc = html.to_ascii_lowercase();
699 let start = lc.find("<body")?;
700 let after = &html[start..];
701 let close_rel = after.find('>')?;
702 Some(start + close_rel + 1)
703}
704
705pub async fn reply_message(
709 http: &reqwest::Client,
710 base_url: &str,
711 access_token: &str,
712 message_id: &str,
713 comment: &str,
714) -> Result<(), ClientError> {
715 let draft_id = create_reply_draft(http, base_url, access_token, message_id, comment).await?;
716 send_draft(http, base_url, access_token, &draft_id).await
717}
718
719pub async fn reply_all_message(
721 http: &reqwest::Client,
722 base_url: &str,
723 access_token: &str,
724 message_id: &str,
725 comment: &str,
726) -> Result<(), ClientError> {
727 let draft_id =
728 create_reply_all_draft(http, base_url, access_token, message_id, comment).await?;
729 send_draft(http, base_url, access_token, &draft_id).await
730}
731
732pub async fn forward_message(
734 http: &reqwest::Client,
735 base_url: &str,
736 access_token: &str,
737 message_id: &str,
738 to: &[String],
739 comment: &str,
740) -> Result<(), ClientError> {
741 let draft_id =
742 create_forward_draft(http, base_url, access_token, message_id, to, comment).await?;
743 send_draft(http, base_url, access_token, &draft_id).await
744}
745
746async fn post_no_body(
747 http: &reqwest::Client,
748 url: &str,
749 access_token: &str,
750 body: &serde_json::Value,
751) -> Result<(), ClientError> {
752 let resp = http
753 .post(url)
754 .bearer_auth(access_token)
755 .json(body)
756 .send()
757 .await?;
758 let status = resp.status();
759 if !status.is_success() {
760 let text = resp.text().await.unwrap_or_default();
761 return Err(ClientError::Graph {
762 status: status.as_u16(),
763 message: text,
764 });
765 }
766 Ok(())
767}
768
769pub async fn create_draft(
772 http: &reqwest::Client,
773 base_url: &str,
774 access_token: &str,
775 message: &Outgoing,
776) -> Result<String, ClientError> {
777 let url = format!("{base_url}/me/messages");
778 let body = message.to_graph_json();
779 let resp = http
780 .post(&url)
781 .bearer_auth(access_token)
782 .json(&body)
783 .send()
784 .await?;
785 parse_id_from_response(resp).await
786}
787
788pub async fn create_reply_draft(
795 http: &reqwest::Client,
796 base_url: &str,
797 access_token: &str,
798 message_id: &str,
799 comment: &str,
800) -> Result<String, ClientError> {
801 let url = format!("{base_url}/me/messages/{message_id}/createReply");
802 let resp = http
803 .post(&url)
804 .bearer_auth(access_token)
805 .json(&serde_json::json!({}))
806 .send()
807 .await?;
808 let draft_id = parse_id_from_response(resp).await?;
809 if !comment.is_empty() {
810 prepend_html_to_draft(
811 http,
812 base_url,
813 access_token,
814 &draft_id,
815 &text_to_html(comment),
816 )
817 .await?;
818 }
819 Ok(draft_id)
820}
821
822pub async fn create_reply_all_draft(
824 http: &reqwest::Client,
825 base_url: &str,
826 access_token: &str,
827 message_id: &str,
828 comment: &str,
829) -> Result<String, ClientError> {
830 let url = format!("{base_url}/me/messages/{message_id}/createReplyAll");
831 let resp = http
832 .post(&url)
833 .bearer_auth(access_token)
834 .json(&serde_json::json!({}))
835 .send()
836 .await?;
837 let draft_id = parse_id_from_response(resp).await?;
838 if !comment.is_empty() {
839 prepend_html_to_draft(
840 http,
841 base_url,
842 access_token,
843 &draft_id,
844 &text_to_html(comment),
845 )
846 .await?;
847 }
848 Ok(draft_id)
849}
850
851pub async fn create_forward_draft(
854 http: &reqwest::Client,
855 base_url: &str,
856 access_token: &str,
857 message_id: &str,
858 to: &[String],
859 comment: &str,
860) -> Result<String, ClientError> {
861 let url = format!("{base_url}/me/messages/{message_id}/createForward");
862 let resp = http
863 .post(&url)
864 .bearer_auth(access_token)
865 .json(&serde_json::json!({
866 "toRecipients": to.iter().map(|addr| serde_json::json!({
867 "emailAddress": { "address": addr }
868 })).collect::<Vec<_>>(),
869 }))
870 .send()
871 .await?;
872 let draft_id = parse_id_from_response(resp).await?;
873 if !comment.is_empty() {
874 prepend_html_to_draft(
875 http,
876 base_url,
877 access_token,
878 &draft_id,
879 &text_to_html(comment),
880 )
881 .await?;
882 }
883 Ok(draft_id)
884}
885
886pub async fn send_draft(
888 http: &reqwest::Client,
889 base_url: &str,
890 access_token: &str,
891 message_id: &str,
892) -> Result<(), ClientError> {
893 let url = format!("{base_url}/me/messages/{message_id}/send");
894 let resp = http
899 .post(&url)
900 .bearer_auth(access_token)
901 .header(reqwest::header::CONTENT_LENGTH, 0)
902 .body(reqwest::Body::from(""))
903 .send()
904 .await?;
905 let status = resp.status();
906 if !status.is_success() {
907 let text = resp.text().await.unwrap_or_default();
908 return Err(ClientError::Graph {
909 status: status.as_u16(),
910 message: text,
911 });
912 }
913 Ok(())
914}
915
916pub async fn update_draft(
919 http: &reqwest::Client,
920 base_url: &str,
921 access_token: &str,
922 message_id: &str,
923 message: &Outgoing,
924) -> Result<(), ClientError> {
925 let url = format!("{base_url}/me/messages/{message_id}");
926 let body = message.to_graph_json();
927 let resp = http
928 .patch(&url)
929 .bearer_auth(access_token)
930 .json(&body)
931 .send()
932 .await?;
933 let status = resp.status();
934 if !status.is_success() {
935 let text = resp.text().await.unwrap_or_default();
936 return Err(ClientError::Graph {
937 status: status.as_u16(),
938 message: text,
939 });
940 }
941 Ok(())
942}
943
944pub async fn delete_message(
948 http: &reqwest::Client,
949 base_url: &str,
950 access_token: &str,
951 message_id: &str,
952) -> Result<(), ClientError> {
953 let url = format!("{base_url}/me/messages/{message_id}");
954 let resp = http.delete(&url).bearer_auth(access_token).send().await?;
955 let status = resp.status();
956 if !status.is_success() {
957 let text = resp.text().await.unwrap_or_default();
958 return Err(ClientError::Graph {
959 status: status.as_u16(),
960 message: text,
961 });
962 }
963 Ok(())
964}
965
966pub async fn add_attachment(
972 http: &reqwest::Client,
973 base_url: &str,
974 access_token: &str,
975 message_id: &str,
976 name: &str,
977 content_type: &str,
978 bytes: &[u8],
979) -> Result<String, ClientError> {
980 use base64::Engine;
981 use base64::engine::general_purpose::STANDARD;
982
983 let url = format!("{base_url}/me/messages/{message_id}/attachments");
984 let body = serde_json::json!({
985 "@odata.type": "#microsoft.graph.fileAttachment",
986 "name": name,
987 "contentType": content_type,
988 "contentBytes": STANDARD.encode(bytes),
989 });
990 let resp = http
991 .post(&url)
992 .bearer_auth(access_token)
993 .json(&body)
994 .send()
995 .await?;
996 parse_id_from_response(resp).await
997}
998
999pub async fn delete_attachment(
1001 http: &reqwest::Client,
1002 base_url: &str,
1003 access_token: &str,
1004 message_id: &str,
1005 attachment_id: &str,
1006) -> Result<(), ClientError> {
1007 let url = format!("{base_url}/me/messages/{message_id}/attachments/{attachment_id}");
1008 let resp = http.delete(&url).bearer_auth(access_token).send().await?;
1009 let status = resp.status();
1010 if !status.is_success() {
1011 let text = resp.text().await.unwrap_or_default();
1012 return Err(ClientError::Graph {
1013 status: status.as_u16(),
1014 message: text,
1015 });
1016 }
1017 Ok(())
1018}
1019
1020async fn parse_id_from_response(resp: reqwest::Response) -> Result<String, ClientError> {
1021 let status = resp.status();
1022 if !status.is_success() {
1023 let text = resp.text().await.unwrap_or_default();
1024 return Err(ClientError::Graph {
1025 status: status.as_u16(),
1026 message: text,
1027 });
1028 }
1029 let v: serde_json::Value = resp.json().await?;
1030 v["id"]
1031 .as_str()
1032 .map(|s| s.to_string())
1033 .ok_or_else(|| ClientError::Graph {
1034 status: 200,
1035 message: "draft response missing 'id'".to_string(),
1036 })
1037}
1038
1039#[derive(Debug, Clone)]
1041pub struct Outgoing {
1042 pub subject: String,
1043 pub body_text: String,
1044 pub to: Vec<String>,
1045 pub cc: Vec<String>,
1046 pub bcc: Vec<String>,
1047}
1048
1049impl Outgoing {
1050 fn to_graph_json(&self) -> serde_json::Value {
1051 fn addresses(list: &[String]) -> Vec<serde_json::Value> {
1052 list.iter()
1053 .map(|addr| serde_json::json!({ "emailAddress": { "address": addr } }))
1054 .collect()
1055 }
1056 serde_json::json!({
1057 "subject": self.subject,
1058 "body": {
1059 "contentType": "Text",
1060 "content": self.body_text,
1061 },
1062 "toRecipients": addresses(&self.to),
1063 "ccRecipients": addresses(&self.cc),
1064 "bccRecipients": addresses(&self.bcc),
1065 })
1066 }
1067}
1068
1069pub async fn move_message(
1076 http: &reqwest::Client,
1077 base_url: &str,
1078 access_token: &str,
1079 message_id: &str,
1080 destination: &str,
1081) -> Result<(), ClientError> {
1082 let url = format!("{base_url}/me/messages/{message_id}/move");
1083 let resp = http
1084 .post(&url)
1085 .bearer_auth(access_token)
1086 .json(&serde_json::json!({ "destinationId": destination }))
1087 .send()
1088 .await?;
1089 let status = resp.status();
1090 if !status.is_success() {
1091 let text = resp.text().await.unwrap_or_default();
1092 return Err(ClientError::Graph {
1093 status: status.as_u16(),
1094 message: text,
1095 });
1096 }
1097 Ok(())
1098}
1099
1100#[derive(Debug, Clone, Deserialize)]
1102pub struct MailFolder {
1103 pub id: String,
1104 #[serde(rename = "displayName")]
1105 pub display_name: String,
1106 #[serde(rename = "totalItemCount", default)]
1108 pub total_item_count: Option<u64>,
1109 #[serde(rename = "unreadItemCount", default)]
1111 pub unread_item_count: Option<u64>,
1112}
1113
1114#[derive(Debug, Deserialize)]
1115struct GraphFolderList {
1116 value: Vec<MailFolder>,
1117 #[serde(rename = "@odata.nextLink", default)]
1118 next_link: Option<String>,
1119}
1120
1121pub async fn list_mail_folders(
1125 http: &reqwest::Client,
1126 base_url: &str,
1127 access_token: &str,
1128) -> Result<Vec<MailFolder>, ClientError> {
1129 let mut url = format!(
1130 "{base_url}/me/mailFolders?$select=id,displayName,totalItemCount,unreadItemCount&$top=100"
1131 );
1132 let mut folders: Vec<MailFolder> = Vec::new();
1133 loop {
1134 let resp = http.get(&url).bearer_auth(access_token).send().await?;
1135 let status = resp.status();
1136 if !status.is_success() {
1137 let text = resp.text().await.unwrap_or_default();
1138 return Err(ClientError::Graph {
1139 status: status.as_u16(),
1140 message: text,
1141 });
1142 }
1143 let list: GraphFolderList = resp.json().await?;
1144 folders.extend(list.value);
1145 match list.next_link {
1148 Some(next) => url = next,
1149 None => break,
1150 }
1151 }
1152 Ok(folders)
1153}
1154
1155pub async fn create_mail_folder(
1161 http: &reqwest::Client,
1162 base_url: &str,
1163 access_token: &str,
1164 display_name: &str,
1165) -> Result<MailFolder, ClientError> {
1166 let url = format!("{base_url}/me/mailFolders");
1167 let resp = http
1168 .post(&url)
1169 .bearer_auth(access_token)
1170 .json(&serde_json::json!({ "displayName": display_name }))
1171 .send()
1172 .await?;
1173 let status = resp.status();
1174 if !status.is_success() {
1175 let text = resp.text().await.unwrap_or_default();
1176 return Err(ClientError::Graph {
1177 status: status.as_u16(),
1178 message: text,
1179 });
1180 }
1181 let folder: MailFolder = resp.json().await?;
1182 Ok(folder)
1183}
1184
1185#[cfg(test)]
1186mod tests {
1187 use super::*;
1188 use wiremock::matchers::{header, method, path, path_regex, query_param};
1189 use wiremock::{Mock, MockServer, ResponseTemplate};
1190
1191 #[tokio::test]
1192 async fn list_inbox_parses_graph_response() {
1193 let server = MockServer::start().await;
1194 Mock::given(method("GET"))
1195 .and(path("/me/mailFolders/inbox/messages"))
1196 .and(header("authorization", "Bearer AT"))
1197 .and(query_param("$top", "5"))
1198 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1199 "value": [
1200 {
1201 "id": "AAAA",
1202 "subject": "Hello",
1203 "from": {
1204 "emailAddress": {
1205 "name": "Maria",
1206 "address": "maria@mklab.se"
1207 }
1208 },
1209 "receivedDateTime": "2026-05-13T22:00:00Z",
1210 "isRead": false,
1211 "bodyPreview": "Hi there"
1212 }
1213 ]
1214 })))
1215 .mount(&server)
1216 .await;
1217
1218 let http = reqwest::Client::new();
1219 let page = list_inbox(&http, &server.uri(), "AT", "u@e.com", 5, 0, false)
1220 .await
1221 .unwrap();
1222 assert_eq!(page.messages.len(), 1);
1223 assert_eq!(page.messages[0].subject, "Hello");
1224 assert_eq!(page.messages[0].from.address, "maria@mklab.se");
1225 assert!(!page.messages[0].is_read);
1226 assert_eq!(page.messages[0].account, "u@e.com");
1227 assert!(!page.has_more);
1228 }
1229
1230 #[tokio::test]
1231 async fn list_inbox_adds_filter_when_unread_only() {
1232 let server = MockServer::start().await;
1233 Mock::given(method("GET"))
1234 .and(path("/me/mailFolders/inbox/messages"))
1235 .and(query_param("$filter", "isRead eq false"))
1236 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1237 "value": []
1238 })))
1239 .mount(&server)
1240 .await;
1241
1242 let http = reqwest::Client::new();
1243 let page = list_inbox(&http, &server.uri(), "AT", "u@e.com", 5, 0, true)
1244 .await
1245 .unwrap();
1246 assert!(page.messages.is_empty());
1247 }
1248
1249 #[tokio::test]
1250 async fn list_inbox_passes_skip_when_nonzero() {
1251 let server = MockServer::start().await;
1252 Mock::given(method("GET"))
1253 .and(path("/me/mailFolders/inbox/messages"))
1254 .and(query_param("$skip", "25"))
1255 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1256 "value": [],
1257 "@odata.nextLink": "https://graph.example/next"
1258 })))
1259 .mount(&server)
1260 .await;
1261
1262 let http = reqwest::Client::new();
1263 let page = list_inbox(&http, &server.uri(), "AT", "u@e.com", 25, 25, false)
1264 .await
1265 .unwrap();
1266 assert!(page.has_more);
1267 }
1268
1269 #[tokio::test]
1270 async fn search_messages_passes_search_query() {
1271 let server = MockServer::start().await;
1272 Mock::given(method("GET"))
1273 .and(path("/me/messages"))
1274 .and(query_param("$search", "\"alice budget\""))
1275 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1276 "value": [
1277 {
1278 "id": "S1",
1279 "subject": "Q4 budget review",
1280 "from": { "emailAddress": { "name": "Alice", "address": "alice@example.com" } },
1281 "receivedDateTime": "2026-05-13T22:00:00Z",
1282 "isRead": true,
1283 "bodyPreview": "Numbers attached"
1284 }
1285 ]
1286 })))
1287 .mount(&server)
1288 .await;
1289
1290 let http = reqwest::Client::new();
1291 let msgs = search_messages(&http, &server.uri(), "AT", "u@e.com", "alice budget", 25)
1292 .await
1293 .unwrap();
1294 assert_eq!(msgs.len(), 1);
1295 assert_eq!(msgs[0].subject, "Q4 budget review");
1296 }
1297
1298 #[tokio::test]
1299 async fn mark_unread_patches_isread_false() {
1300 let server = MockServer::start().await;
1301 Mock::given(method("PATCH"))
1302 .and(path_regex("/me/messages/[A-Za-z0-9]+"))
1303 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
1304 .mount(&server)
1305 .await;
1306 let http = reqwest::Client::new();
1307 mark_unread(&http, &server.uri(), "AT", "MSG")
1308 .await
1309 .unwrap();
1310 }
1311
1312 #[tokio::test]
1313 async fn set_flag_patches_flag_status() {
1314 let server = MockServer::start().await;
1315 Mock::given(method("PATCH"))
1316 .and(path_regex("/me/messages/[A-Za-z0-9]+"))
1317 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
1318 .mount(&server)
1319 .await;
1320 let http = reqwest::Client::new();
1321 set_flag(&http, &server.uri(), "AT", "MSG", true)
1322 .await
1323 .unwrap();
1324 set_flag(&http, &server.uri(), "AT", "MSG", false)
1325 .await
1326 .unwrap();
1327 }
1328
1329 #[tokio::test]
1330 async fn send_mail_wraps_outgoing_in_message_envelope() {
1331 use wiremock::matchers::body_partial_json;
1332 let server = MockServer::start().await;
1333 Mock::given(method("POST"))
1334 .and(path("/me/sendMail"))
1335 .and(body_partial_json(serde_json::json!({
1336 "saveToSentItems": true,
1337 "message": {
1338 "subject": "Hello",
1339 "body": { "contentType": "Text", "content": "Hi there" },
1340 "toRecipients": [{ "emailAddress": { "address": "alice@example.com" } }]
1341 }
1342 })))
1343 .respond_with(ResponseTemplate::new(202))
1344 .mount(&server)
1345 .await;
1346 let http = reqwest::Client::new();
1347 let msg = Outgoing {
1348 subject: "Hello".into(),
1349 body_text: "Hi there".into(),
1350 to: vec!["alice@example.com".into()],
1351 cc: vec![],
1352 bcc: vec![],
1353 };
1354 send_mail(&http, &server.uri(), "AT", &msg).await.unwrap();
1355 }
1356
1357 #[tokio::test]
1358 async fn reply_message_creates_draft_patches_body_and_sends() {
1359 use wiremock::matchers::body_partial_json;
1360 let server = MockServer::start().await;
1361
1362 Mock::given(method("POST"))
1364 .and(path_regex("/me/messages/MSG/createReply"))
1365 .respond_with(
1366 ResponseTemplate::new(201).set_body_json(serde_json::json!({ "id": "DRAFT" })),
1367 )
1368 .mount(&server)
1369 .await;
1370 Mock::given(method("GET"))
1372 .and(path("/me/messages/DRAFT"))
1373 .and(query_param("$select", "body"))
1374 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1375 "body": { "contentType": "HTML", "content": "<html><body><div></div></body></html>" }
1376 })))
1377 .mount(&server)
1378 .await;
1379 Mock::given(method("PATCH"))
1381 .and(path("/me/messages/DRAFT"))
1382 .and(body_partial_json(serde_json::json!({
1383 "body": { "contentType": "HTML" }
1384 })))
1385 .respond_with(ResponseTemplate::new(200))
1386 .mount(&server)
1387 .await;
1388 Mock::given(method("POST"))
1390 .and(path("/me/messages/DRAFT/send"))
1391 .respond_with(ResponseTemplate::new(202))
1392 .mount(&server)
1393 .await;
1394
1395 let http = reqwest::Client::new();
1396 reply_message(&http, &server.uri(), "AT", "MSG", "Thanks!")
1397 .await
1398 .unwrap();
1399 }
1400
1401 #[tokio::test]
1402 async fn forward_message_creates_draft_with_recipients_and_sends() {
1403 use wiremock::matchers::body_partial_json;
1404 let server = MockServer::start().await;
1405
1406 Mock::given(method("POST"))
1407 .and(path_regex("/me/messages/MSG/createForward"))
1408 .and(body_partial_json(serde_json::json!({
1409 "toRecipients": [{ "emailAddress": { "address": "bob@example.com" } }]
1410 })))
1411 .respond_with(
1412 ResponseTemplate::new(201).set_body_json(serde_json::json!({ "id": "DRAFT" })),
1413 )
1414 .mount(&server)
1415 .await;
1416 Mock::given(method("GET"))
1417 .and(path("/me/messages/DRAFT"))
1418 .and(query_param("$select", "body"))
1419 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1420 "body": { "contentType": "HTML", "content": "<html><body></body></html>" }
1421 })))
1422 .mount(&server)
1423 .await;
1424 Mock::given(method("PATCH"))
1425 .and(path("/me/messages/DRAFT"))
1426 .respond_with(ResponseTemplate::new(200))
1427 .mount(&server)
1428 .await;
1429 Mock::given(method("POST"))
1430 .and(path("/me/messages/DRAFT/send"))
1431 .respond_with(ResponseTemplate::new(202))
1432 .mount(&server)
1433 .await;
1434
1435 let http = reqwest::Client::new();
1436 forward_message(
1437 &http,
1438 &server.uri(),
1439 "AT",
1440 "MSG",
1441 &["bob@example.com".into()],
1442 "FYI",
1443 )
1444 .await
1445 .unwrap();
1446 }
1447
1448 #[test]
1449 fn text_to_html_escapes_and_breaks_paragraphs() {
1450 let out = text_to_html("Hej Edward,\n\nLine 1\nLine 2\n\n<script>x</script>");
1451 assert!(out.contains("<p>Hej Edward,</p>"));
1452 assert!(out.contains("<p>Line 1<br>Line 2</p>"));
1453 assert!(out.contains("<script>x</script>"));
1454 assert!(!out.contains("<script>"));
1455 }
1456
1457 #[test]
1458 fn text_to_html_normalizes_crlf() {
1459 let out = text_to_html("A\r\nB\r\n\r\nC");
1460 assert!(out.contains("<p>A<br>B</p>"));
1461 assert!(out.contains("<p>C</p>"));
1462 }
1463
1464 #[test]
1465 fn find_body_tag_end_handles_attributes_and_case() {
1466 let html = "<html><BODY class=\"x\">content</BODY></html>";
1467 let pos = find_body_tag_end(html).unwrap();
1468 assert_eq!(&html[pos..pos + 7], "content");
1469 }
1470
1471 #[tokio::test]
1472 async fn list_mail_folders_parses_and_pages() {
1473 let server = MockServer::start().await;
1474 Mock::given(method("GET"))
1476 .and(path("/me/mailFolders"))
1477 .and(query_param("$top", "100"))
1478 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1479 "value": [
1480 { "id": "F1", "displayName": "Biljetter", "totalItemCount": 3, "unreadItemCount": 0 }
1481 ],
1482 "@odata.nextLink": format!("{}/me/mailFolders?page=2", server.uri())
1483 })))
1484 .mount(&server)
1485 .await;
1486 Mock::given(method("GET"))
1487 .and(path("/me/mailFolders"))
1488 .and(query_param("page", "2"))
1489 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1490 "value": [
1491 { "id": "F2", "displayName": "Kvitton", "totalItemCount": 7, "unreadItemCount": 2 }
1492 ]
1493 })))
1494 .mount(&server)
1495 .await;
1496
1497 let http = reqwest::Client::new();
1498 let folders = list_mail_folders(&http, &server.uri(), "AT").await.unwrap();
1499 assert_eq!(folders.len(), 2);
1500 assert_eq!(folders[0].display_name, "Biljetter");
1501 assert_eq!(folders[0].total_item_count, Some(3));
1502 assert_eq!(folders[1].display_name, "Kvitton");
1503 assert_eq!(folders[1].unread_item_count, Some(2));
1504 }
1505
1506 #[tokio::test]
1507 async fn create_mail_folder_posts_display_name() {
1508 use wiremock::matchers::body_partial_json;
1509 let server = MockServer::start().await;
1510 Mock::given(method("POST"))
1511 .and(path("/me/mailFolders"))
1512 .and(body_partial_json(
1513 serde_json::json!({ "displayName": "Biljetter" }),
1514 ))
1515 .respond_with(
1516 ResponseTemplate::new(201)
1517 .set_body_json(serde_json::json!({ "id": "NEWF", "displayName": "Biljetter" })),
1518 )
1519 .mount(&server)
1520 .await;
1521
1522 let http = reqwest::Client::new();
1523 let folder = create_mail_folder(&http, &server.uri(), "AT", "Biljetter")
1524 .await
1525 .unwrap();
1526 assert_eq!(folder.id, "NEWF");
1527 assert_eq!(folder.display_name, "Biljetter");
1528 }
1529
1530 #[tokio::test]
1531 async fn move_message_posts_destination() {
1532 let server = MockServer::start().await;
1533 Mock::given(method("POST"))
1534 .and(path_regex("/me/messages/[A-Za-z0-9]+/move"))
1535 .respond_with(
1536 ResponseTemplate::new(201).set_body_json(serde_json::json!({"id": "NEW"})),
1537 )
1538 .mount(&server)
1539 .await;
1540 let http = reqwest::Client::new();
1541 move_message(&http, &server.uri(), "AT", "MSG", "archive")
1542 .await
1543 .unwrap();
1544 }
1545
1546 #[tokio::test]
1547 async fn get_message_parses_graph_response() {
1548 let server = MockServer::start().await;
1549 Mock::given(method("GET"))
1550 .and(path_regex("/me/messages/[A-Za-z0-9]+"))
1551 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1552 "id": "AAA",
1553 "subject": "Hello",
1554 "from": { "emailAddress": { "name": "Maria", "address": "maria@mklab.se" } },
1555 "toRecipients": [
1556 { "emailAddress": { "name": "Kristofer", "address": "kristofer@mklab.se" } }
1557 ],
1558 "ccRecipients": [],
1559 "bccRecipients": [],
1560 "receivedDateTime": "2026-05-14T22:00:00Z",
1561 "sentDateTime": "2026-05-14T21:59:30Z",
1562 "isRead": false,
1563 "body": { "contentType": "html", "content": "<p>Hi</p>" },
1564 "hasAttachments": true
1565 })))
1566 .mount(&server)
1567 .await;
1568
1569 let http = reqwest::Client::new();
1570 let m = get_message(&http, &server.uri(), "AT", "u@e.com", "AAA")
1571 .await
1572 .unwrap();
1573 assert_eq!(m.id, "AAA");
1574 assert_eq!(m.subject, "Hello");
1575 assert_eq!(m.from.name, "Maria");
1576 assert_eq!(m.to.len(), 1);
1577 assert_eq!(m.to[0].address, "kristofer@mklab.se");
1578 assert!(matches!(
1579 m.body_content_type,
1580 pidge_core::BodyContentType::Html
1581 ));
1582 assert_eq!(m.body_content, "<p>Hi</p>");
1583 assert!(m.has_attachments);
1584 }
1585
1586 #[tokio::test]
1587 async fn list_attachments_filters_file_attachments() {
1588 let server = MockServer::start().await;
1589 Mock::given(method("GET"))
1590 .and(path_regex("/me/messages/[A-Za-z0-9]+/attachments"))
1591 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1592 "value": [
1593 {
1594 "@odata.type": "#microsoft.graph.fileAttachment",
1595 "id": "att-1",
1596 "name": "report.pdf",
1597 "contentType": "application/pdf",
1598 "size": 12345,
1599 "isInline": false
1600 },
1601 {
1602 "@odata.type": "#microsoft.graph.itemAttachment",
1603 "id": "att-2",
1604 "name": "an-email.eml",
1605 "contentType": "message/rfc822",
1606 "size": 7777,
1607 "isInline": false
1608 }
1609 ]
1610 })))
1611 .mount(&server)
1612 .await;
1613
1614 let http = reqwest::Client::new();
1615 let atts = list_attachments(&http, &server.uri(), "AT", "MSG")
1616 .await
1617 .unwrap();
1618 assert_eq!(atts.len(), 1);
1619 assert_eq!(atts[0].name, "report.pdf");
1620 assert_eq!(atts[0].size_bytes, 12345);
1621 }
1622
1623 #[tokio::test]
1624 async fn get_attachment_bytes_decodes_base64() {
1625 let server = MockServer::start().await;
1626 Mock::given(method("GET"))
1627 .and(path_regex(
1628 "/me/messages/[A-Za-z0-9]+/attachments/[A-Za-z0-9-]+",
1629 ))
1630 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1631 "id": "att-1",
1632 "name": "report.pdf",
1633 "contentType": "application/pdf",
1634 "size": 5,
1635 "isInline": false,
1636 "contentBytes": "aGVsbG8="
1637 })))
1638 .mount(&server)
1639 .await;
1640
1641 let http = reqwest::Client::new();
1642 let bytes = get_attachment_bytes(&http, &server.uri(), "AT", "MSG", "att-1")
1643 .await
1644 .unwrap();
1645 assert_eq!(bytes, b"hello");
1646 }
1647
1648 #[tokio::test]
1649 async fn mark_read_patches_isread_true() {
1650 let server = MockServer::start().await;
1651 Mock::given(method("PATCH"))
1652 .and(path_regex("/me/messages/[A-Za-z0-9]+"))
1653 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
1654 .mount(&server)
1655 .await;
1656
1657 let http = reqwest::Client::new();
1658 mark_read(&http, &server.uri(), "AT", "MSG").await.unwrap();
1659 }
1660
1661 #[tokio::test]
1662 async fn fetch_message_headers_parses_array() {
1663 let server = MockServer::start().await;
1664 Mock::given(method("GET"))
1665 .and(path_regex(r"^/me/messages/.+$"))
1666 .and(query_param("$select", "internetMessageHeaders"))
1667 .and(header("authorization", "Bearer AT"))
1668 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1669 "internetMessageHeaders": [
1670 { "name": "List-Unsubscribe", "value": "<mailto:u@x>, <https://x/u>" },
1671 { "name": "List-Unsubscribe-Post", "value": "List-Unsubscribe=One-Click" }
1672 ]
1673 })))
1674 .mount(&server)
1675 .await;
1676
1677 let http = reqwest::Client::new();
1678 let headers = fetch_message_headers(&http, &server.uri(), "AT", "MSGID")
1679 .await
1680 .unwrap();
1681 assert_eq!(headers.len(), 2);
1682 assert_eq!(headers[0].0, "List-Unsubscribe");
1683 assert_eq!(headers[0].1, "<mailto:u@x>, <https://x/u>");
1684 assert_eq!(headers[1].0, "List-Unsubscribe-Post");
1685 }
1686}