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
151#[allow(clippy::too_many_arguments)]
155pub async fn list_folder_messages(
156 http: &reqwest::Client,
157 base_url: &str,
158 access_token: &str,
159 account: &str,
160 folder_id: &str,
161 limit: usize,
162 skip: usize,
163 unread_only: bool,
164) -> Result<InboxPage, ClientError> {
165 list_folder(
166 http,
167 base_url,
168 access_token,
169 account,
170 folder_id,
171 limit,
172 skip,
173 unread_only,
174 )
175 .await
176}
177
178pub async fn list_drafts(
181 http: &reqwest::Client,
182 base_url: &str,
183 access_token: &str,
184 account: &str,
185 limit: usize,
186 skip: usize,
187) -> Result<InboxPage, ClientError> {
188 list_folder(
189 http,
190 base_url,
191 access_token,
192 account,
193 "drafts",
194 limit,
195 skip,
196 false,
197 )
198 .await
199}
200
201#[allow(clippy::too_many_arguments)]
202async fn list_folder(
203 http: &reqwest::Client,
204 base_url: &str,
205 access_token: &str,
206 account: &str,
207 folder: &str,
208 limit: usize,
209 skip: usize,
210 unread_only: bool,
211) -> Result<InboxPage, ClientError> {
212 let url = format!("{base_url}/me/mailFolders/{folder}/messages");
213 let mut req = http.get(&url).bearer_auth(access_token).query(&[
217 (
218 "$select",
219 "id,subject,from,receivedDateTime,isRead,bodyPreview,body,hasAttachments,flag",
220 ),
221 ("$orderby", "receivedDateTime desc"),
222 ("$top", &limit.to_string()),
223 ]);
224 if skip > 0 {
225 req = req.query(&[("$skip", &skip.to_string())]);
226 }
227 if unread_only {
228 req = req.query(&[("$filter", "isRead eq false")]);
229 }
230
231 let resp = req.send().await?;
232 let status = resp.status();
233 if !status.is_success() {
234 let text = resp.text().await.unwrap_or_default();
235 return Err(ClientError::Graph {
236 status: status.as_u16(),
237 message: text,
238 });
239 }
240
241 let list: GraphList = resp.json().await?;
242 Ok(InboxPage {
243 has_more: list.next_link.is_some(),
244 messages: list
245 .value
246 .into_iter()
247 .map(|g| to_message(g, account))
248 .collect(),
249 })
250}
251
252pub async fn search_messages(
263 http: &reqwest::Client,
264 base_url: &str,
265 access_token: &str,
266 account: &str,
267 query: &str,
268 limit: usize,
269) -> Result<Vec<Message>, ClientError> {
270 let quoted = format!("\"{}\"", query.replace('"', "\\\""));
272 let url = format!("{base_url}/me/messages");
273 let resp = http
274 .get(&url)
275 .bearer_auth(access_token)
276 .header("Prefer", "outlook.body-content-type=\"text\"")
277 .query(&[
278 (
279 "$select",
280 "id,subject,from,receivedDateTime,isRead,bodyPreview,body,hasAttachments,flag",
281 ),
282 ("$top", &limit.to_string()),
283 ("$search", "ed),
284 ])
285 .send()
286 .await?;
287 let status = resp.status();
288 if !status.is_success() {
289 let text = resp.text().await.unwrap_or_default();
290 return Err(ClientError::Graph {
291 status: status.as_u16(),
292 message: text,
293 });
294 }
295 let list: GraphList = resp.json().await?;
296 Ok(list
297 .value
298 .into_iter()
299 .map(|g| to_message(g, account))
300 .collect())
301}
302
303fn to_message(g: GraphMessage, account: &str) -> Message {
304 let (body, body_content_type) = match g.body {
305 Some(b) => {
306 let kind = if b.content_type.eq_ignore_ascii_case("html") {
307 pidge_core::BodyContentType::Html
308 } else {
309 pidge_core::BodyContentType::Text
310 };
311 (b.content, kind)
312 }
313 None => (String::new(), pidge_core::BodyContentType::Text),
314 };
315 Message {
316 account: account.to_string(),
317 id: g.id,
318 from: MessageFrom {
319 name: g
320 .from
321 .as_ref()
322 .and_then(|f| f.email_address.name.clone())
323 .unwrap_or_default(),
324 address: g
325 .from
326 .as_ref()
327 .and_then(|f| f.email_address.address.clone())
328 .unwrap_or_default(),
329 },
330 subject: g.subject.unwrap_or_default(),
331 received_at: g.received_date_time,
332 is_read: g.is_read.unwrap_or(true),
333 preview: g.body_preview.unwrap_or_default(),
338 flag_status: flag_status_from(g.flag),
339 has_attachments: g.has_attachments.unwrap_or(false),
340 body,
341 body_content_type,
342 }
343}
344
345pub async fn get_message(
347 http: &reqwest::Client,
348 base_url: &str,
349 access_token: &str,
350 account: &str,
351 message_id: &str,
352) -> Result<pidge_core::FullMessage, ClientError> {
353 let url = format!(
354 "{base_url}/me/messages/{message_id}\
355 ?$select=id,subject,from,toRecipients,ccRecipients,bccRecipients,\
356receivedDateTime,sentDateTime,isRead,body,hasAttachments,flag"
357 );
358 let resp = http.get(&url).bearer_auth(access_token).send().await?;
359 let status = resp.status();
360 if !status.is_success() {
361 let text = resp.text().await.unwrap_or_default();
362 return Err(ClientError::Graph {
363 status: status.as_u16(),
364 message: text,
365 });
366 }
367 let g: GraphFullMessage = resp.json().await?;
368
369 fn from(addr: GraphFromAddress) -> pidge_core::MessageFrom {
370 pidge_core::MessageFrom {
371 name: addr.name.unwrap_or_default(),
372 address: addr.address.unwrap_or_default(),
373 }
374 }
375 fn unwrap_recipients(rs: Vec<GraphFromWrapper>) -> Vec<pidge_core::MessageFrom> {
376 rs.into_iter().map(|w| from(w.email_address)).collect()
377 }
378
379 let content_type = match g.body.content_type.to_lowercase().as_str() {
380 "html" => pidge_core::BodyContentType::Html,
381 _ => pidge_core::BodyContentType::Text,
382 };
383
384 Ok(pidge_core::FullMessage {
385 account: account.to_string(),
386 id: g.id,
387 from: g
388 .from
389 .map(|w| from(w.email_address))
390 .unwrap_or_else(|| pidge_core::MessageFrom {
391 name: String::new(),
392 address: String::new(),
393 }),
394 to: unwrap_recipients(g.to_recipients),
395 cc: unwrap_recipients(g.cc_recipients),
396 bcc: unwrap_recipients(g.bcc_recipients),
397 subject: g.subject.unwrap_or_default(),
398 received_at: g.received_date_time,
399 sent_at: g.sent_date_time,
400 is_read: g.is_read.unwrap_or(true),
401 body_content_type: content_type,
402 body_content: g.body.content,
403 has_attachments: g.has_attachments.unwrap_or(false),
404 flag_status: flag_status_from(g.flag),
405 })
406}
407
408pub async fn fetch_message_headers(
412 http: &reqwest::Client,
413 base_url: &str,
414 access_token: &str,
415 message_id: &str,
416) -> Result<Vec<(String, String)>, ClientError> {
417 let url = format!("{base_url}/me/messages/{message_id}?$select=internetMessageHeaders");
418 let resp = http.get(&url).bearer_auth(access_token).send().await?;
419 let status = resp.status();
420 if !status.is_success() {
421 let text = resp.text().await.unwrap_or_default();
422 return Err(ClientError::Graph {
423 status: status.as_u16(),
424 message: text,
425 });
426 }
427 let body: GraphHeadersResponse = resp.json().await?;
428 Ok(body
429 .internet_message_headers
430 .unwrap_or_default()
431 .into_iter()
432 .map(|h| (h.name, h.value))
433 .collect())
434}
435
436#[derive(serde::Deserialize)]
437struct GraphHeadersResponse {
438 #[serde(rename = "internetMessageHeaders", default)]
439 internet_message_headers: Option<Vec<GraphHeader>>,
440}
441
442#[derive(serde::Deserialize)]
443struct GraphHeader {
444 name: String,
445 value: String,
446}
447
448pub async fn list_attachments(
451 http: &reqwest::Client,
452 base_url: &str,
453 access_token: &str,
454 message_id: &str,
455) -> Result<Vec<pidge_core::Attachment>, ClientError> {
456 let url = format!(
463 "{base_url}/me/messages/{message_id}/attachments\
464 ?$select=id,name,contentType,size,isInline"
465 );
466 let resp = http.get(&url).bearer_auth(access_token).send().await?;
467 let status = resp.status();
468 if !status.is_success() {
469 let text = resp.text().await.unwrap_or_default();
470 return Err(ClientError::Graph {
471 status: status.as_u16(),
472 message: text,
473 });
474 }
475 let list: GraphAttachmentList = resp.json().await?;
476 Ok(list
477 .value
478 .into_iter()
479 .filter(|a| {
480 a.odata_type
481 .as_deref()
482 .map(|t| t == "#microsoft.graph.fileAttachment")
483 .unwrap_or(true)
484 })
485 .map(|a| pidge_core::Attachment {
486 id: a.id,
487 name: a.name.unwrap_or_default(),
488 content_type: a.content_type.unwrap_or_default(),
489 size_bytes: a.size.unwrap_or(0),
490 is_inline: a.is_inline.unwrap_or(false),
491 content_id: a.content_id,
492 })
493 .collect())
494}
495
496pub async fn get_attachment_bytes(
499 http: &reqwest::Client,
500 base_url: &str,
501 access_token: &str,
502 message_id: &str,
503 attachment_id: &str,
504) -> Result<Vec<u8>, ClientError> {
505 use base64::Engine;
506 use base64::engine::general_purpose::STANDARD;
507
508 let url = format!("{base_url}/me/messages/{message_id}/attachments/{attachment_id}");
509 let resp = http.get(&url).bearer_auth(access_token).send().await?;
510 let status = resp.status();
511 if !status.is_success() {
512 let text = resp.text().await.unwrap_or_default();
513 return Err(ClientError::Graph {
514 status: status.as_u16(),
515 message: text,
516 });
517 }
518 let g: GraphAttachment = resp.json().await?;
519 let b64 = g.content_bytes.ok_or_else(|| ClientError::Graph {
520 status: 200,
521 message: "attachment response missing contentBytes".to_string(),
522 })?;
523 STANDARD.decode(&b64).map_err(|e| ClientError::Graph {
524 status: 200,
525 message: format!("attachment base64 decode: {e}"),
526 })
527}
528
529pub async fn mark_read(
531 http: &reqwest::Client,
532 base_url: &str,
533 access_token: &str,
534 message_id: &str,
535) -> Result<(), ClientError> {
536 patch_message(
537 http,
538 base_url,
539 access_token,
540 message_id,
541 &serde_json::json!({ "isRead": true }),
542 )
543 .await
544}
545
546pub async fn mark_unread(
548 http: &reqwest::Client,
549 base_url: &str,
550 access_token: &str,
551 message_id: &str,
552) -> Result<(), ClientError> {
553 patch_message(
554 http,
555 base_url,
556 access_token,
557 message_id,
558 &serde_json::json!({ "isRead": false }),
559 )
560 .await
561}
562
563pub async fn set_flag(
565 http: &reqwest::Client,
566 base_url: &str,
567 access_token: &str,
568 message_id: &str,
569 flagged: bool,
570) -> Result<(), ClientError> {
571 let status = if flagged { "flagged" } else { "notFlagged" };
572 patch_message(
573 http,
574 base_url,
575 access_token,
576 message_id,
577 &serde_json::json!({ "flag": { "flagStatus": status } }),
578 )
579 .await
580}
581
582#[derive(serde::Deserialize)]
583struct GraphCategories {
584 #[serde(default)]
585 categories: Vec<String>,
586}
587
588pub async fn get_categories(
590 http: &reqwest::Client,
591 base_url: &str,
592 access_token: &str,
593 message_id: &str,
594) -> Result<Vec<String>, ClientError> {
595 let url = format!("{base_url}/me/messages/{message_id}?$select=categories");
596 let resp = http.get(&url).bearer_auth(access_token).send().await?;
597 let status = resp.status();
598 if !status.is_success() {
599 let text = resp.text().await.unwrap_or_default();
600 return Err(ClientError::Graph {
601 status: status.as_u16(),
602 message: text,
603 });
604 }
605 let body: GraphCategories = resp.json().await?;
606 Ok(body.categories)
607}
608
609pub async fn set_categories(
611 http: &reqwest::Client,
612 base_url: &str,
613 access_token: &str,
614 message_id: &str,
615 categories: &[String],
616) -> Result<(), ClientError> {
617 patch_message(
618 http,
619 base_url,
620 access_token,
621 message_id,
622 &serde_json::json!({ "categories": categories }),
623 )
624 .await
625}
626
627async fn patch_message(
628 http: &reqwest::Client,
629 base_url: &str,
630 access_token: &str,
631 message_id: &str,
632 body: &serde_json::Value,
633) -> Result<(), ClientError> {
634 let url = format!("{base_url}/me/messages/{message_id}");
635 let resp = http
636 .patch(&url)
637 .bearer_auth(access_token)
638 .json(body)
639 .send()
640 .await?;
641 let status = resp.status();
642 if !status.is_success() {
643 let text = resp.text().await.unwrap_or_default();
644 return Err(ClientError::Graph {
645 status: status.as_u16(),
646 message: text,
647 });
648 }
649 Ok(())
650}
651
652pub async fn send_mail(
658 http: &reqwest::Client,
659 base_url: &str,
660 access_token: &str,
661 message: &Outgoing,
662) -> Result<(), ClientError> {
663 let url = format!("{base_url}/me/sendMail");
664 let body = serde_json::json!({
665 "message": message.to_graph_json(),
666 "saveToSentItems": true,
667 });
668 post_no_body(http, &url, access_token, &body).await
669}
670
671fn text_to_html(text: &str) -> String {
681 fn escape(s: &str) -> String {
682 s.replace('&', "&")
683 .replace('<', "<")
684 .replace('>', ">")
685 .replace('"', """)
686 }
687 let normalized = text.replace("\r\n", "\n").replace('\r', "\n");
688 normalized
689 .split("\n\n")
690 .filter(|p| !p.trim().is_empty())
691 .map(|paragraph| {
692 let lines: Vec<String> = paragraph
693 .trim_matches('\n')
694 .split('\n')
695 .map(escape)
696 .collect();
697 format!("<p>{}</p>", lines.join("<br>"))
698 })
699 .collect::<Vec<_>>()
700 .join("")
701}
702
703#[derive(Debug, Deserialize)]
704struct GraphBodyOnly {
705 body: GraphBody,
706}
707
708async fn prepend_html_to_draft(
712 http: &reqwest::Client,
713 base_url: &str,
714 access_token: &str,
715 message_id: &str,
716 html_to_prepend: &str,
717) -> Result<(), ClientError> {
718 let get_url = format!("{base_url}/me/messages/{message_id}?$select=body");
719 let resp = http.get(&get_url).bearer_auth(access_token).send().await?;
720 let status = resp.status();
721 if !status.is_success() {
722 let text = resp.text().await.unwrap_or_default();
723 return Err(ClientError::Graph {
724 status: status.as_u16(),
725 message: text,
726 });
727 }
728 let existing: GraphBodyOnly = resp.json().await?;
729 let existing_content = existing.body.content;
730
731 let new_content = match find_body_tag_end(&existing_content) {
735 Some(pos) => {
736 let mut s = String::with_capacity(existing_content.len() + html_to_prepend.len());
737 s.push_str(&existing_content[..pos]);
738 s.push_str(html_to_prepend);
739 s.push_str(&existing_content[pos..]);
740 s
741 }
742 None => format!("{html_to_prepend}{existing_content}"),
743 };
744
745 let patch_url = format!("{base_url}/me/messages/{message_id}");
746 let patch_body = serde_json::json!({
747 "body": {
748 "contentType": "HTML",
749 "content": new_content,
750 }
751 });
752 let resp = http
753 .patch(&patch_url)
754 .bearer_auth(access_token)
755 .json(&patch_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
769fn find_body_tag_end(html: &str) -> Option<usize> {
770 let lc = html.to_ascii_lowercase();
771 let start = lc.find("<body")?;
772 let after = &html[start..];
773 let close_rel = after.find('>')?;
774 Some(start + close_rel + 1)
775}
776
777pub async fn reply_message(
781 http: &reqwest::Client,
782 base_url: &str,
783 access_token: &str,
784 message_id: &str,
785 comment: &str,
786) -> Result<(), ClientError> {
787 let draft_id = create_reply_draft(http, base_url, access_token, message_id, comment).await?;
788 send_draft(http, base_url, access_token, &draft_id).await
789}
790
791pub async fn reply_all_message(
793 http: &reqwest::Client,
794 base_url: &str,
795 access_token: &str,
796 message_id: &str,
797 comment: &str,
798) -> Result<(), ClientError> {
799 let draft_id =
800 create_reply_all_draft(http, base_url, access_token, message_id, comment).await?;
801 send_draft(http, base_url, access_token, &draft_id).await
802}
803
804pub async fn forward_message(
806 http: &reqwest::Client,
807 base_url: &str,
808 access_token: &str,
809 message_id: &str,
810 to: &[String],
811 comment: &str,
812) -> Result<(), ClientError> {
813 let draft_id =
814 create_forward_draft(http, base_url, access_token, message_id, to, comment).await?;
815 send_draft(http, base_url, access_token, &draft_id).await
816}
817
818async fn post_no_body(
819 http: &reqwest::Client,
820 url: &str,
821 access_token: &str,
822 body: &serde_json::Value,
823) -> Result<(), ClientError> {
824 let resp = http
825 .post(url)
826 .bearer_auth(access_token)
827 .json(body)
828 .send()
829 .await?;
830 let status = resp.status();
831 if !status.is_success() {
832 let text = resp.text().await.unwrap_or_default();
833 return Err(ClientError::Graph {
834 status: status.as_u16(),
835 message: text,
836 });
837 }
838 Ok(())
839}
840
841pub async fn create_draft(
844 http: &reqwest::Client,
845 base_url: &str,
846 access_token: &str,
847 message: &Outgoing,
848) -> Result<String, ClientError> {
849 let url = format!("{base_url}/me/messages");
850 let body = message.to_graph_json();
851 let resp = http
852 .post(&url)
853 .bearer_auth(access_token)
854 .json(&body)
855 .send()
856 .await?;
857 parse_id_from_response(resp).await
858}
859
860pub async fn create_reply_draft(
867 http: &reqwest::Client,
868 base_url: &str,
869 access_token: &str,
870 message_id: &str,
871 comment: &str,
872) -> Result<String, ClientError> {
873 let url = format!("{base_url}/me/messages/{message_id}/createReply");
874 let resp = http
875 .post(&url)
876 .bearer_auth(access_token)
877 .json(&serde_json::json!({}))
878 .send()
879 .await?;
880 let draft_id = parse_id_from_response(resp).await?;
881 if !comment.is_empty() {
882 prepend_html_to_draft(
883 http,
884 base_url,
885 access_token,
886 &draft_id,
887 &text_to_html(comment),
888 )
889 .await?;
890 }
891 Ok(draft_id)
892}
893
894pub async fn create_reply_all_draft(
896 http: &reqwest::Client,
897 base_url: &str,
898 access_token: &str,
899 message_id: &str,
900 comment: &str,
901) -> Result<String, ClientError> {
902 let url = format!("{base_url}/me/messages/{message_id}/createReplyAll");
903 let resp = http
904 .post(&url)
905 .bearer_auth(access_token)
906 .json(&serde_json::json!({}))
907 .send()
908 .await?;
909 let draft_id = parse_id_from_response(resp).await?;
910 if !comment.is_empty() {
911 prepend_html_to_draft(
912 http,
913 base_url,
914 access_token,
915 &draft_id,
916 &text_to_html(comment),
917 )
918 .await?;
919 }
920 Ok(draft_id)
921}
922
923pub async fn create_forward_draft(
926 http: &reqwest::Client,
927 base_url: &str,
928 access_token: &str,
929 message_id: &str,
930 to: &[String],
931 comment: &str,
932) -> Result<String, ClientError> {
933 let url = format!("{base_url}/me/messages/{message_id}/createForward");
934 let resp = http
935 .post(&url)
936 .bearer_auth(access_token)
937 .json(&serde_json::json!({
938 "toRecipients": to.iter().map(|addr| serde_json::json!({
939 "emailAddress": { "address": addr }
940 })).collect::<Vec<_>>(),
941 }))
942 .send()
943 .await?;
944 let draft_id = parse_id_from_response(resp).await?;
945 if !comment.is_empty() {
946 prepend_html_to_draft(
947 http,
948 base_url,
949 access_token,
950 &draft_id,
951 &text_to_html(comment),
952 )
953 .await?;
954 }
955 Ok(draft_id)
956}
957
958pub async fn send_draft(
960 http: &reqwest::Client,
961 base_url: &str,
962 access_token: &str,
963 message_id: &str,
964) -> Result<(), ClientError> {
965 let url = format!("{base_url}/me/messages/{message_id}/send");
966 let resp = http
971 .post(&url)
972 .bearer_auth(access_token)
973 .header(reqwest::header::CONTENT_LENGTH, 0)
974 .body(reqwest::Body::from(""))
975 .send()
976 .await?;
977 let status = resp.status();
978 if !status.is_success() {
979 let text = resp.text().await.unwrap_or_default();
980 return Err(ClientError::Graph {
981 status: status.as_u16(),
982 message: text,
983 });
984 }
985 Ok(())
986}
987
988pub async fn update_draft(
991 http: &reqwest::Client,
992 base_url: &str,
993 access_token: &str,
994 message_id: &str,
995 message: &Outgoing,
996) -> Result<(), ClientError> {
997 let url = format!("{base_url}/me/messages/{message_id}");
998 let body = message.to_graph_json();
999 let resp = http
1000 .patch(&url)
1001 .bearer_auth(access_token)
1002 .json(&body)
1003 .send()
1004 .await?;
1005 let status = resp.status();
1006 if !status.is_success() {
1007 let text = resp.text().await.unwrap_or_default();
1008 return Err(ClientError::Graph {
1009 status: status.as_u16(),
1010 message: text,
1011 });
1012 }
1013 Ok(())
1014}
1015
1016pub async fn delete_message(
1020 http: &reqwest::Client,
1021 base_url: &str,
1022 access_token: &str,
1023 message_id: &str,
1024) -> Result<(), ClientError> {
1025 let url = format!("{base_url}/me/messages/{message_id}");
1026 let resp = http.delete(&url).bearer_auth(access_token).send().await?;
1027 let status = resp.status();
1028 if !status.is_success() {
1029 let text = resp.text().await.unwrap_or_default();
1030 return Err(ClientError::Graph {
1031 status: status.as_u16(),
1032 message: text,
1033 });
1034 }
1035 Ok(())
1036}
1037
1038pub async fn add_attachment(
1044 http: &reqwest::Client,
1045 base_url: &str,
1046 access_token: &str,
1047 message_id: &str,
1048 name: &str,
1049 content_type: &str,
1050 bytes: &[u8],
1051) -> Result<String, ClientError> {
1052 use base64::Engine;
1053 use base64::engine::general_purpose::STANDARD;
1054
1055 let url = format!("{base_url}/me/messages/{message_id}/attachments");
1056 let body = serde_json::json!({
1057 "@odata.type": "#microsoft.graph.fileAttachment",
1058 "name": name,
1059 "contentType": content_type,
1060 "contentBytes": STANDARD.encode(bytes),
1061 });
1062 let resp = http
1063 .post(&url)
1064 .bearer_auth(access_token)
1065 .json(&body)
1066 .send()
1067 .await?;
1068 parse_id_from_response(resp).await
1069}
1070
1071pub async fn delete_attachment(
1073 http: &reqwest::Client,
1074 base_url: &str,
1075 access_token: &str,
1076 message_id: &str,
1077 attachment_id: &str,
1078) -> Result<(), ClientError> {
1079 let url = format!("{base_url}/me/messages/{message_id}/attachments/{attachment_id}");
1080 let resp = http.delete(&url).bearer_auth(access_token).send().await?;
1081 let status = resp.status();
1082 if !status.is_success() {
1083 let text = resp.text().await.unwrap_or_default();
1084 return Err(ClientError::Graph {
1085 status: status.as_u16(),
1086 message: text,
1087 });
1088 }
1089 Ok(())
1090}
1091
1092async fn parse_id_from_response(resp: reqwest::Response) -> Result<String, ClientError> {
1093 let status = resp.status();
1094 if !status.is_success() {
1095 let text = resp.text().await.unwrap_or_default();
1096 return Err(ClientError::Graph {
1097 status: status.as_u16(),
1098 message: text,
1099 });
1100 }
1101 let v: serde_json::Value = resp.json().await?;
1102 v["id"]
1103 .as_str()
1104 .map(|s| s.to_string())
1105 .ok_or_else(|| ClientError::Graph {
1106 status: 200,
1107 message: "draft response missing 'id'".to_string(),
1108 })
1109}
1110
1111#[derive(Debug, Clone)]
1113pub struct Outgoing {
1114 pub subject: String,
1115 pub body_text: String,
1116 pub to: Vec<String>,
1117 pub cc: Vec<String>,
1118 pub bcc: Vec<String>,
1119}
1120
1121impl Outgoing {
1122 fn to_graph_json(&self) -> serde_json::Value {
1123 fn addresses(list: &[String]) -> Vec<serde_json::Value> {
1124 list.iter()
1125 .map(|addr| serde_json::json!({ "emailAddress": { "address": addr } }))
1126 .collect()
1127 }
1128 serde_json::json!({
1129 "subject": self.subject,
1130 "body": {
1131 "contentType": "Text",
1132 "content": self.body_text,
1133 },
1134 "toRecipients": addresses(&self.to),
1135 "ccRecipients": addresses(&self.cc),
1136 "bccRecipients": addresses(&self.bcc),
1137 })
1138 }
1139}
1140
1141pub async fn move_message(
1148 http: &reqwest::Client,
1149 base_url: &str,
1150 access_token: &str,
1151 message_id: &str,
1152 destination: &str,
1153) -> Result<(), ClientError> {
1154 let url = format!("{base_url}/me/messages/{message_id}/move");
1155 let resp = http
1156 .post(&url)
1157 .bearer_auth(access_token)
1158 .json(&serde_json::json!({ "destinationId": destination }))
1159 .send()
1160 .await?;
1161 let status = resp.status();
1162 if !status.is_success() {
1163 let text = resp.text().await.unwrap_or_default();
1164 return Err(ClientError::Graph {
1165 status: status.as_u16(),
1166 message: text,
1167 });
1168 }
1169 Ok(())
1170}
1171
1172#[derive(Debug, Clone, Deserialize)]
1174pub struct MailFolder {
1175 pub id: String,
1176 #[serde(rename = "displayName")]
1177 pub display_name: String,
1178 #[serde(rename = "totalItemCount", default)]
1180 pub total_item_count: Option<u64>,
1181 #[serde(rename = "unreadItemCount", default)]
1183 pub unread_item_count: Option<u64>,
1184 #[serde(rename = "childFolderCount", default)]
1187 pub child_folder_count: Option<u64>,
1188}
1189
1190#[derive(Debug, Deserialize)]
1191struct GraphFolderList {
1192 value: Vec<MailFolder>,
1193 #[serde(rename = "@odata.nextLink", default)]
1194 next_link: Option<String>,
1195}
1196
1197const FOLDER_SELECT: &str = "id,displayName,totalItemCount,unreadItemCount,childFolderCount";
1200
1201async fn fetch_folder_pages(
1204 http: &reqwest::Client,
1205 access_token: &str,
1206 mut url: String,
1207) -> Result<Vec<MailFolder>, ClientError> {
1208 let mut folders: Vec<MailFolder> = Vec::new();
1209 loop {
1210 let resp = http.get(&url).bearer_auth(access_token).send().await?;
1211 let status = resp.status();
1212 if !status.is_success() {
1213 let text = resp.text().await.unwrap_or_default();
1214 return Err(ClientError::Graph {
1215 status: status.as_u16(),
1216 message: text,
1217 });
1218 }
1219 let list: GraphFolderList = resp.json().await?;
1220 folders.extend(list.value);
1221 match list.next_link {
1224 Some(next) => url = next,
1225 None => break,
1226 }
1227 }
1228 Ok(folders)
1229}
1230
1231pub async fn list_mail_folders(
1235 http: &reqwest::Client,
1236 base_url: &str,
1237 access_token: &str,
1238) -> Result<Vec<MailFolder>, ClientError> {
1239 let url = format!("{base_url}/me/mailFolders?$select={FOLDER_SELECT}&$top=100");
1240 fetch_folder_pages(http, access_token, url).await
1241}
1242
1243pub async fn list_child_folders(
1246 http: &reqwest::Client,
1247 base_url: &str,
1248 access_token: &str,
1249 parent_id: &str,
1250) -> Result<Vec<MailFolder>, ClientError> {
1251 let url = format!(
1252 "{base_url}/me/mailFolders/{parent_id}/childFolders?$select={FOLDER_SELECT}&$top=100"
1253 );
1254 fetch_folder_pages(http, access_token, url).await
1255}
1256
1257async fn post_folder(
1258 http: &reqwest::Client,
1259 url: &str,
1260 access_token: &str,
1261 display_name: &str,
1262) -> Result<MailFolder, ClientError> {
1263 let resp = http
1264 .post(url)
1265 .bearer_auth(access_token)
1266 .json(&serde_json::json!({ "displayName": display_name }))
1267 .send()
1268 .await?;
1269 let status = resp.status();
1270 if !status.is_success() {
1271 let text = resp.text().await.unwrap_or_default();
1272 return Err(ClientError::Graph {
1273 status: status.as_u16(),
1274 message: text,
1275 });
1276 }
1277 let folder: MailFolder = resp.json().await?;
1278 Ok(folder)
1279}
1280
1281pub async fn create_mail_folder(
1287 http: &reqwest::Client,
1288 base_url: &str,
1289 access_token: &str,
1290 display_name: &str,
1291) -> Result<MailFolder, ClientError> {
1292 let url = format!("{base_url}/me/mailFolders");
1293 post_folder(http, &url, access_token, display_name).await
1294}
1295
1296pub async fn create_child_folder(
1299 http: &reqwest::Client,
1300 base_url: &str,
1301 access_token: &str,
1302 parent_id: &str,
1303 display_name: &str,
1304) -> Result<MailFolder, ClientError> {
1305 let url = format!("{base_url}/me/mailFolders/{parent_id}/childFolders");
1306 post_folder(http, &url, access_token, display_name).await
1307}
1308
1309pub async fn delete_mail_folder(
1313 http: &reqwest::Client,
1314 base_url: &str,
1315 access_token: &str,
1316 folder_id: &str,
1317) -> Result<(), ClientError> {
1318 let url = format!("{base_url}/me/mailFolders/{folder_id}");
1319 let resp = http.delete(&url).bearer_auth(access_token).send().await?;
1320 let status = resp.status();
1321 if !status.is_success() {
1322 let text = resp.text().await.unwrap_or_default();
1323 return Err(ClientError::Graph {
1324 status: status.as_u16(),
1325 message: text,
1326 });
1327 }
1328 Ok(())
1329}
1330
1331#[cfg(test)]
1332mod tests {
1333 use super::*;
1334 use wiremock::matchers::{header, method, path, path_regex, query_param};
1335 use wiremock::{Mock, MockServer, ResponseTemplate};
1336
1337 #[tokio::test]
1338 async fn list_inbox_parses_graph_response() {
1339 let server = MockServer::start().await;
1340 Mock::given(method("GET"))
1341 .and(path("/me/mailFolders/inbox/messages"))
1342 .and(header("authorization", "Bearer AT"))
1343 .and(query_param("$top", "5"))
1344 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1345 "value": [
1346 {
1347 "id": "AAAA",
1348 "subject": "Hello",
1349 "from": {
1350 "emailAddress": {
1351 "name": "Maria",
1352 "address": "maria@mklab.se"
1353 }
1354 },
1355 "receivedDateTime": "2026-05-13T22:00:00Z",
1356 "isRead": false,
1357 "bodyPreview": "Hi there"
1358 }
1359 ]
1360 })))
1361 .mount(&server)
1362 .await;
1363
1364 let http = reqwest::Client::new();
1365 let page = list_inbox(&http, &server.uri(), "AT", "u@e.com", 5, 0, false)
1366 .await
1367 .unwrap();
1368 assert_eq!(page.messages.len(), 1);
1369 assert_eq!(page.messages[0].subject, "Hello");
1370 assert_eq!(page.messages[0].from.address, "maria@mklab.se");
1371 assert!(!page.messages[0].is_read);
1372 assert_eq!(page.messages[0].account, "u@e.com");
1373 assert!(!page.has_more);
1374 }
1375
1376 #[tokio::test]
1377 async fn list_inbox_adds_filter_when_unread_only() {
1378 let server = MockServer::start().await;
1379 Mock::given(method("GET"))
1380 .and(path("/me/mailFolders/inbox/messages"))
1381 .and(query_param("$filter", "isRead eq false"))
1382 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1383 "value": []
1384 })))
1385 .mount(&server)
1386 .await;
1387
1388 let http = reqwest::Client::new();
1389 let page = list_inbox(&http, &server.uri(), "AT", "u@e.com", 5, 0, true)
1390 .await
1391 .unwrap();
1392 assert!(page.messages.is_empty());
1393 }
1394
1395 #[tokio::test]
1396 async fn list_inbox_passes_skip_when_nonzero() {
1397 let server = MockServer::start().await;
1398 Mock::given(method("GET"))
1399 .and(path("/me/mailFolders/inbox/messages"))
1400 .and(query_param("$skip", "25"))
1401 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1402 "value": [],
1403 "@odata.nextLink": "https://graph.example/next"
1404 })))
1405 .mount(&server)
1406 .await;
1407
1408 let http = reqwest::Client::new();
1409 let page = list_inbox(&http, &server.uri(), "AT", "u@e.com", 25, 25, false)
1410 .await
1411 .unwrap();
1412 assert!(page.has_more);
1413 }
1414
1415 #[tokio::test]
1416 async fn search_messages_passes_search_query() {
1417 let server = MockServer::start().await;
1418 Mock::given(method("GET"))
1419 .and(path("/me/messages"))
1420 .and(query_param("$search", "\"alice budget\""))
1421 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1422 "value": [
1423 {
1424 "id": "S1",
1425 "subject": "Q4 budget review",
1426 "from": { "emailAddress": { "name": "Alice", "address": "alice@example.com" } },
1427 "receivedDateTime": "2026-05-13T22:00:00Z",
1428 "isRead": true,
1429 "bodyPreview": "Numbers attached"
1430 }
1431 ]
1432 })))
1433 .mount(&server)
1434 .await;
1435
1436 let http = reqwest::Client::new();
1437 let msgs = search_messages(&http, &server.uri(), "AT", "u@e.com", "alice budget", 25)
1438 .await
1439 .unwrap();
1440 assert_eq!(msgs.len(), 1);
1441 assert_eq!(msgs[0].subject, "Q4 budget review");
1442 }
1443
1444 #[tokio::test]
1445 async fn mark_unread_patches_isread_false() {
1446 let server = MockServer::start().await;
1447 Mock::given(method("PATCH"))
1448 .and(path_regex("/me/messages/[A-Za-z0-9]+"))
1449 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
1450 .mount(&server)
1451 .await;
1452 let http = reqwest::Client::new();
1453 mark_unread(&http, &server.uri(), "AT", "MSG")
1454 .await
1455 .unwrap();
1456 }
1457
1458 #[tokio::test]
1459 async fn set_flag_patches_flag_status() {
1460 let server = MockServer::start().await;
1461 Mock::given(method("PATCH"))
1462 .and(path_regex("/me/messages/[A-Za-z0-9]+"))
1463 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
1464 .mount(&server)
1465 .await;
1466 let http = reqwest::Client::new();
1467 set_flag(&http, &server.uri(), "AT", "MSG", true)
1468 .await
1469 .unwrap();
1470 set_flag(&http, &server.uri(), "AT", "MSG", false)
1471 .await
1472 .unwrap();
1473 }
1474
1475 #[tokio::test]
1476 async fn send_mail_wraps_outgoing_in_message_envelope() {
1477 use wiremock::matchers::body_partial_json;
1478 let server = MockServer::start().await;
1479 Mock::given(method("POST"))
1480 .and(path("/me/sendMail"))
1481 .and(body_partial_json(serde_json::json!({
1482 "saveToSentItems": true,
1483 "message": {
1484 "subject": "Hello",
1485 "body": { "contentType": "Text", "content": "Hi there" },
1486 "toRecipients": [{ "emailAddress": { "address": "alice@example.com" } }]
1487 }
1488 })))
1489 .respond_with(ResponseTemplate::new(202))
1490 .mount(&server)
1491 .await;
1492 let http = reqwest::Client::new();
1493 let msg = Outgoing {
1494 subject: "Hello".into(),
1495 body_text: "Hi there".into(),
1496 to: vec!["alice@example.com".into()],
1497 cc: vec![],
1498 bcc: vec![],
1499 };
1500 send_mail(&http, &server.uri(), "AT", &msg).await.unwrap();
1501 }
1502
1503 #[tokio::test]
1504 async fn reply_message_creates_draft_patches_body_and_sends() {
1505 use wiremock::matchers::body_partial_json;
1506 let server = MockServer::start().await;
1507
1508 Mock::given(method("POST"))
1510 .and(path_regex("/me/messages/MSG/createReply"))
1511 .respond_with(
1512 ResponseTemplate::new(201).set_body_json(serde_json::json!({ "id": "DRAFT" })),
1513 )
1514 .mount(&server)
1515 .await;
1516 Mock::given(method("GET"))
1518 .and(path("/me/messages/DRAFT"))
1519 .and(query_param("$select", "body"))
1520 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1521 "body": { "contentType": "HTML", "content": "<html><body><div></div></body></html>" }
1522 })))
1523 .mount(&server)
1524 .await;
1525 Mock::given(method("PATCH"))
1527 .and(path("/me/messages/DRAFT"))
1528 .and(body_partial_json(serde_json::json!({
1529 "body": { "contentType": "HTML" }
1530 })))
1531 .respond_with(ResponseTemplate::new(200))
1532 .mount(&server)
1533 .await;
1534 Mock::given(method("POST"))
1536 .and(path("/me/messages/DRAFT/send"))
1537 .respond_with(ResponseTemplate::new(202))
1538 .mount(&server)
1539 .await;
1540
1541 let http = reqwest::Client::new();
1542 reply_message(&http, &server.uri(), "AT", "MSG", "Thanks!")
1543 .await
1544 .unwrap();
1545 }
1546
1547 #[tokio::test]
1548 async fn forward_message_creates_draft_with_recipients_and_sends() {
1549 use wiremock::matchers::body_partial_json;
1550 let server = MockServer::start().await;
1551
1552 Mock::given(method("POST"))
1553 .and(path_regex("/me/messages/MSG/createForward"))
1554 .and(body_partial_json(serde_json::json!({
1555 "toRecipients": [{ "emailAddress": { "address": "bob@example.com" } }]
1556 })))
1557 .respond_with(
1558 ResponseTemplate::new(201).set_body_json(serde_json::json!({ "id": "DRAFT" })),
1559 )
1560 .mount(&server)
1561 .await;
1562 Mock::given(method("GET"))
1563 .and(path("/me/messages/DRAFT"))
1564 .and(query_param("$select", "body"))
1565 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1566 "body": { "contentType": "HTML", "content": "<html><body></body></html>" }
1567 })))
1568 .mount(&server)
1569 .await;
1570 Mock::given(method("PATCH"))
1571 .and(path("/me/messages/DRAFT"))
1572 .respond_with(ResponseTemplate::new(200))
1573 .mount(&server)
1574 .await;
1575 Mock::given(method("POST"))
1576 .and(path("/me/messages/DRAFT/send"))
1577 .respond_with(ResponseTemplate::new(202))
1578 .mount(&server)
1579 .await;
1580
1581 let http = reqwest::Client::new();
1582 forward_message(
1583 &http,
1584 &server.uri(),
1585 "AT",
1586 "MSG",
1587 &["bob@example.com".into()],
1588 "FYI",
1589 )
1590 .await
1591 .unwrap();
1592 }
1593
1594 #[test]
1595 fn text_to_html_escapes_and_breaks_paragraphs() {
1596 let out = text_to_html("Hej Edward,\n\nLine 1\nLine 2\n\n<script>x</script>");
1597 assert!(out.contains("<p>Hej Edward,</p>"));
1598 assert!(out.contains("<p>Line 1<br>Line 2</p>"));
1599 assert!(out.contains("<script>x</script>"));
1600 assert!(!out.contains("<script>"));
1601 }
1602
1603 #[test]
1604 fn text_to_html_normalizes_crlf() {
1605 let out = text_to_html("A\r\nB\r\n\r\nC");
1606 assert!(out.contains("<p>A<br>B</p>"));
1607 assert!(out.contains("<p>C</p>"));
1608 }
1609
1610 #[test]
1611 fn find_body_tag_end_handles_attributes_and_case() {
1612 let html = "<html><BODY class=\"x\">content</BODY></html>";
1613 let pos = find_body_tag_end(html).unwrap();
1614 assert_eq!(&html[pos..pos + 7], "content");
1615 }
1616
1617 #[tokio::test]
1618 async fn list_mail_folders_parses_and_pages() {
1619 let server = MockServer::start().await;
1620 Mock::given(method("GET"))
1622 .and(path("/me/mailFolders"))
1623 .and(query_param("$top", "100"))
1624 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1625 "value": [
1626 { "id": "F1", "displayName": "Biljetter", "totalItemCount": 3, "unreadItemCount": 0 }
1627 ],
1628 "@odata.nextLink": format!("{}/me/mailFolders?page=2", server.uri())
1629 })))
1630 .mount(&server)
1631 .await;
1632 Mock::given(method("GET"))
1633 .and(path("/me/mailFolders"))
1634 .and(query_param("page", "2"))
1635 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1636 "value": [
1637 { "id": "F2", "displayName": "Kvitton", "totalItemCount": 7, "unreadItemCount": 2 }
1638 ]
1639 })))
1640 .mount(&server)
1641 .await;
1642
1643 let http = reqwest::Client::new();
1644 let folders = list_mail_folders(&http, &server.uri(), "AT").await.unwrap();
1645 assert_eq!(folders.len(), 2);
1646 assert_eq!(folders[0].display_name, "Biljetter");
1647 assert_eq!(folders[0].total_item_count, Some(3));
1648 assert_eq!(folders[1].display_name, "Kvitton");
1649 assert_eq!(folders[1].unread_item_count, Some(2));
1650 }
1651
1652 #[tokio::test]
1653 async fn create_mail_folder_posts_display_name() {
1654 use wiremock::matchers::body_partial_json;
1655 let server = MockServer::start().await;
1656 Mock::given(method("POST"))
1657 .and(path("/me/mailFolders"))
1658 .and(body_partial_json(
1659 serde_json::json!({ "displayName": "Biljetter" }),
1660 ))
1661 .respond_with(
1662 ResponseTemplate::new(201)
1663 .set_body_json(serde_json::json!({ "id": "NEWF", "displayName": "Biljetter" })),
1664 )
1665 .mount(&server)
1666 .await;
1667
1668 let http = reqwest::Client::new();
1669 let folder = create_mail_folder(&http, &server.uri(), "AT", "Biljetter")
1670 .await
1671 .unwrap();
1672 assert_eq!(folder.id, "NEWF");
1673 assert_eq!(folder.display_name, "Biljetter");
1674 }
1675
1676 #[tokio::test]
1677 async fn list_child_folders_hits_child_endpoint() {
1678 let server = MockServer::start().await;
1679 Mock::given(method("GET"))
1680 .and(path("/me/mailFolders/PARENT/childFolders"))
1681 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1682 "value": [
1683 { "id": "C1", "displayName": "MKLab", "totalItemCount": 5, "unreadItemCount": 0, "childFolderCount": 0 }
1684 ]
1685 })))
1686 .mount(&server)
1687 .await;
1688 let http = reqwest::Client::new();
1689 let children = list_child_folders(&http, &server.uri(), "AT", "PARENT")
1690 .await
1691 .unwrap();
1692 assert_eq!(children.len(), 1);
1693 assert_eq!(children[0].display_name, "MKLab");
1694 }
1695
1696 #[tokio::test]
1697 async fn create_child_folder_posts_to_parent() {
1698 use wiremock::matchers::body_partial_json;
1699 let server = MockServer::start().await;
1700 Mock::given(method("POST"))
1701 .and(path("/me/mailFolders/PARENT/childFolders"))
1702 .and(body_partial_json(
1703 serde_json::json!({ "displayName": "MKLab" }),
1704 ))
1705 .respond_with(
1706 ResponseTemplate::new(201)
1707 .set_body_json(serde_json::json!({ "id": "C9", "displayName": "MKLab" })),
1708 )
1709 .mount(&server)
1710 .await;
1711 let http = reqwest::Client::new();
1712 let folder = create_child_folder(&http, &server.uri(), "AT", "PARENT", "MKLab")
1713 .await
1714 .unwrap();
1715 assert_eq!(folder.id, "C9");
1716 }
1717
1718 #[tokio::test]
1719 async fn delete_mail_folder_hits_delete_endpoint() {
1720 let server = MockServer::start().await;
1721 Mock::given(method("DELETE"))
1722 .and(path("/me/mailFolders/F1"))
1723 .respond_with(ResponseTemplate::new(204))
1724 .mount(&server)
1725 .await;
1726 let http = reqwest::Client::new();
1727 delete_mail_folder(&http, &server.uri(), "AT", "F1")
1728 .await
1729 .unwrap();
1730 }
1731
1732 #[tokio::test]
1733 async fn get_categories_parses_field() {
1734 let server = MockServer::start().await;
1735 Mock::given(method("GET"))
1736 .and(path_regex(r"^/me/messages/.+$"))
1737 .and(query_param("$select", "categories"))
1738 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1739 "categories": ["Receipts", "Urgent"]
1740 })))
1741 .mount(&server)
1742 .await;
1743 let http = reqwest::Client::new();
1744 let cats = get_categories(&http, &server.uri(), "AT", "MSG")
1745 .await
1746 .unwrap();
1747 assert_eq!(cats, vec!["Receipts", "Urgent"]);
1748 }
1749
1750 #[tokio::test]
1751 async fn set_categories_patches_array() {
1752 use wiremock::matchers::body_partial_json;
1753 let server = MockServer::start().await;
1754 Mock::given(method("PATCH"))
1755 .and(path_regex(r"^/me/messages/.+$"))
1756 .and(body_partial_json(
1757 serde_json::json!({ "categories": ["receipt", "ticket"] }),
1758 ))
1759 .respond_with(ResponseTemplate::new(200))
1760 .mount(&server)
1761 .await;
1762 let http = reqwest::Client::new();
1763 set_categories(
1764 &http,
1765 &server.uri(),
1766 "AT",
1767 "MSG",
1768 &["receipt".to_string(), "ticket".to_string()],
1769 )
1770 .await
1771 .unwrap();
1772 }
1773
1774 #[tokio::test]
1775 async fn move_message_posts_destination() {
1776 let server = MockServer::start().await;
1777 Mock::given(method("POST"))
1778 .and(path_regex("/me/messages/[A-Za-z0-9]+/move"))
1779 .respond_with(
1780 ResponseTemplate::new(201).set_body_json(serde_json::json!({"id": "NEW"})),
1781 )
1782 .mount(&server)
1783 .await;
1784 let http = reqwest::Client::new();
1785 move_message(&http, &server.uri(), "AT", "MSG", "archive")
1786 .await
1787 .unwrap();
1788 }
1789
1790 #[tokio::test]
1791 async fn get_message_parses_graph_response() {
1792 let server = MockServer::start().await;
1793 Mock::given(method("GET"))
1794 .and(path_regex("/me/messages/[A-Za-z0-9]+"))
1795 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1796 "id": "AAA",
1797 "subject": "Hello",
1798 "from": { "emailAddress": { "name": "Maria", "address": "maria@mklab.se" } },
1799 "toRecipients": [
1800 { "emailAddress": { "name": "Kristofer", "address": "kristofer@mklab.se" } }
1801 ],
1802 "ccRecipients": [],
1803 "bccRecipients": [],
1804 "receivedDateTime": "2026-05-14T22:00:00Z",
1805 "sentDateTime": "2026-05-14T21:59:30Z",
1806 "isRead": false,
1807 "body": { "contentType": "html", "content": "<p>Hi</p>" },
1808 "hasAttachments": true
1809 })))
1810 .mount(&server)
1811 .await;
1812
1813 let http = reqwest::Client::new();
1814 let m = get_message(&http, &server.uri(), "AT", "u@e.com", "AAA")
1815 .await
1816 .unwrap();
1817 assert_eq!(m.id, "AAA");
1818 assert_eq!(m.subject, "Hello");
1819 assert_eq!(m.from.name, "Maria");
1820 assert_eq!(m.to.len(), 1);
1821 assert_eq!(m.to[0].address, "kristofer@mklab.se");
1822 assert!(matches!(
1823 m.body_content_type,
1824 pidge_core::BodyContentType::Html
1825 ));
1826 assert_eq!(m.body_content, "<p>Hi</p>");
1827 assert!(m.has_attachments);
1828 }
1829
1830 #[tokio::test]
1831 async fn list_attachments_filters_file_attachments() {
1832 let server = MockServer::start().await;
1833 Mock::given(method("GET"))
1834 .and(path_regex("/me/messages/[A-Za-z0-9]+/attachments"))
1835 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1836 "value": [
1837 {
1838 "@odata.type": "#microsoft.graph.fileAttachment",
1839 "id": "att-1",
1840 "name": "report.pdf",
1841 "contentType": "application/pdf",
1842 "size": 12345,
1843 "isInline": false
1844 },
1845 {
1846 "@odata.type": "#microsoft.graph.itemAttachment",
1847 "id": "att-2",
1848 "name": "an-email.eml",
1849 "contentType": "message/rfc822",
1850 "size": 7777,
1851 "isInline": false
1852 }
1853 ]
1854 })))
1855 .mount(&server)
1856 .await;
1857
1858 let http = reqwest::Client::new();
1859 let atts = list_attachments(&http, &server.uri(), "AT", "MSG")
1860 .await
1861 .unwrap();
1862 assert_eq!(atts.len(), 1);
1863 assert_eq!(atts[0].name, "report.pdf");
1864 assert_eq!(atts[0].size_bytes, 12345);
1865 }
1866
1867 #[tokio::test]
1868 async fn get_attachment_bytes_decodes_base64() {
1869 let server = MockServer::start().await;
1870 Mock::given(method("GET"))
1871 .and(path_regex(
1872 "/me/messages/[A-Za-z0-9]+/attachments/[A-Za-z0-9-]+",
1873 ))
1874 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1875 "id": "att-1",
1876 "name": "report.pdf",
1877 "contentType": "application/pdf",
1878 "size": 5,
1879 "isInline": false,
1880 "contentBytes": "aGVsbG8="
1881 })))
1882 .mount(&server)
1883 .await;
1884
1885 let http = reqwest::Client::new();
1886 let bytes = get_attachment_bytes(&http, &server.uri(), "AT", "MSG", "att-1")
1887 .await
1888 .unwrap();
1889 assert_eq!(bytes, b"hello");
1890 }
1891
1892 #[tokio::test]
1893 async fn mark_read_patches_isread_true() {
1894 let server = MockServer::start().await;
1895 Mock::given(method("PATCH"))
1896 .and(path_regex("/me/messages/[A-Za-z0-9]+"))
1897 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
1898 .mount(&server)
1899 .await;
1900
1901 let http = reqwest::Client::new();
1902 mark_read(&http, &server.uri(), "AT", "MSG").await.unwrap();
1903 }
1904
1905 #[tokio::test]
1906 async fn fetch_message_headers_parses_array() {
1907 let server = MockServer::start().await;
1908 Mock::given(method("GET"))
1909 .and(path_regex(r"^/me/messages/.+$"))
1910 .and(query_param("$select", "internetMessageHeaders"))
1911 .and(header("authorization", "Bearer AT"))
1912 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1913 "internetMessageHeaders": [
1914 { "name": "List-Unsubscribe", "value": "<mailto:u@x>, <https://x/u>" },
1915 { "name": "List-Unsubscribe-Post", "value": "List-Unsubscribe=One-Click" }
1916 ]
1917 })))
1918 .mount(&server)
1919 .await;
1920
1921 let http = reqwest::Client::new();
1922 let headers = fetch_message_headers(&http, &server.uri(), "AT", "MSGID")
1923 .await
1924 .unwrap();
1925 assert_eq!(headers.len(), 2);
1926 assert_eq!(headers[0].0, "List-Unsubscribe");
1927 assert_eq!(headers[0].1, "<mailto:u@x>, <https://x/u>");
1928 assert_eq!(headers[1].0, "List-Unsubscribe-Post");
1929 }
1930}