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
582async fn patch_message(
583 http: &reqwest::Client,
584 base_url: &str,
585 access_token: &str,
586 message_id: &str,
587 body: &serde_json::Value,
588) -> Result<(), ClientError> {
589 let url = format!("{base_url}/me/messages/{message_id}");
590 let resp = http
591 .patch(&url)
592 .bearer_auth(access_token)
593 .json(body)
594 .send()
595 .await?;
596 let status = resp.status();
597 if !status.is_success() {
598 let text = resp.text().await.unwrap_or_default();
599 return Err(ClientError::Graph {
600 status: status.as_u16(),
601 message: text,
602 });
603 }
604 Ok(())
605}
606
607pub async fn send_mail(
613 http: &reqwest::Client,
614 base_url: &str,
615 access_token: &str,
616 message: &Outgoing,
617) -> Result<(), ClientError> {
618 let url = format!("{base_url}/me/sendMail");
619 let body = serde_json::json!({
620 "message": message.to_graph_json(),
621 "saveToSentItems": true,
622 });
623 post_no_body(http, &url, access_token, &body).await
624}
625
626fn text_to_html(text: &str) -> String {
636 fn escape(s: &str) -> String {
637 s.replace('&', "&")
638 .replace('<', "<")
639 .replace('>', ">")
640 .replace('"', """)
641 }
642 let normalized = text.replace("\r\n", "\n").replace('\r', "\n");
643 normalized
644 .split("\n\n")
645 .filter(|p| !p.trim().is_empty())
646 .map(|paragraph| {
647 let lines: Vec<String> = paragraph
648 .trim_matches('\n')
649 .split('\n')
650 .map(escape)
651 .collect();
652 format!("<p>{}</p>", lines.join("<br>"))
653 })
654 .collect::<Vec<_>>()
655 .join("")
656}
657
658#[derive(Debug, Deserialize)]
659struct GraphBodyOnly {
660 body: GraphBody,
661}
662
663async fn prepend_html_to_draft(
667 http: &reqwest::Client,
668 base_url: &str,
669 access_token: &str,
670 message_id: &str,
671 html_to_prepend: &str,
672) -> Result<(), ClientError> {
673 let get_url = format!("{base_url}/me/messages/{message_id}?$select=body");
674 let resp = http.get(&get_url).bearer_auth(access_token).send().await?;
675 let status = resp.status();
676 if !status.is_success() {
677 let text = resp.text().await.unwrap_or_default();
678 return Err(ClientError::Graph {
679 status: status.as_u16(),
680 message: text,
681 });
682 }
683 let existing: GraphBodyOnly = resp.json().await?;
684 let existing_content = existing.body.content;
685
686 let new_content = match find_body_tag_end(&existing_content) {
690 Some(pos) => {
691 let mut s = String::with_capacity(existing_content.len() + html_to_prepend.len());
692 s.push_str(&existing_content[..pos]);
693 s.push_str(html_to_prepend);
694 s.push_str(&existing_content[pos..]);
695 s
696 }
697 None => format!("{html_to_prepend}{existing_content}"),
698 };
699
700 let patch_url = format!("{base_url}/me/messages/{message_id}");
701 let patch_body = serde_json::json!({
702 "body": {
703 "contentType": "HTML",
704 "content": new_content,
705 }
706 });
707 let resp = http
708 .patch(&patch_url)
709 .bearer_auth(access_token)
710 .json(&patch_body)
711 .send()
712 .await?;
713 let status = resp.status();
714 if !status.is_success() {
715 let text = resp.text().await.unwrap_or_default();
716 return Err(ClientError::Graph {
717 status: status.as_u16(),
718 message: text,
719 });
720 }
721 Ok(())
722}
723
724fn find_body_tag_end(html: &str) -> Option<usize> {
725 let lc = html.to_ascii_lowercase();
726 let start = lc.find("<body")?;
727 let after = &html[start..];
728 let close_rel = after.find('>')?;
729 Some(start + close_rel + 1)
730}
731
732pub async fn reply_message(
736 http: &reqwest::Client,
737 base_url: &str,
738 access_token: &str,
739 message_id: &str,
740 comment: &str,
741) -> Result<(), ClientError> {
742 let draft_id = create_reply_draft(http, base_url, access_token, message_id, comment).await?;
743 send_draft(http, base_url, access_token, &draft_id).await
744}
745
746pub async fn reply_all_message(
748 http: &reqwest::Client,
749 base_url: &str,
750 access_token: &str,
751 message_id: &str,
752 comment: &str,
753) -> Result<(), ClientError> {
754 let draft_id =
755 create_reply_all_draft(http, base_url, access_token, message_id, comment).await?;
756 send_draft(http, base_url, access_token, &draft_id).await
757}
758
759pub async fn forward_message(
761 http: &reqwest::Client,
762 base_url: &str,
763 access_token: &str,
764 message_id: &str,
765 to: &[String],
766 comment: &str,
767) -> Result<(), ClientError> {
768 let draft_id =
769 create_forward_draft(http, base_url, access_token, message_id, to, comment).await?;
770 send_draft(http, base_url, access_token, &draft_id).await
771}
772
773async fn post_no_body(
774 http: &reqwest::Client,
775 url: &str,
776 access_token: &str,
777 body: &serde_json::Value,
778) -> Result<(), ClientError> {
779 let resp = http
780 .post(url)
781 .bearer_auth(access_token)
782 .json(body)
783 .send()
784 .await?;
785 let status = resp.status();
786 if !status.is_success() {
787 let text = resp.text().await.unwrap_or_default();
788 return Err(ClientError::Graph {
789 status: status.as_u16(),
790 message: text,
791 });
792 }
793 Ok(())
794}
795
796pub async fn create_draft(
799 http: &reqwest::Client,
800 base_url: &str,
801 access_token: &str,
802 message: &Outgoing,
803) -> Result<String, ClientError> {
804 let url = format!("{base_url}/me/messages");
805 let body = message.to_graph_json();
806 let resp = http
807 .post(&url)
808 .bearer_auth(access_token)
809 .json(&body)
810 .send()
811 .await?;
812 parse_id_from_response(resp).await
813}
814
815pub async fn create_reply_draft(
822 http: &reqwest::Client,
823 base_url: &str,
824 access_token: &str,
825 message_id: &str,
826 comment: &str,
827) -> Result<String, ClientError> {
828 let url = format!("{base_url}/me/messages/{message_id}/createReply");
829 let resp = http
830 .post(&url)
831 .bearer_auth(access_token)
832 .json(&serde_json::json!({}))
833 .send()
834 .await?;
835 let draft_id = parse_id_from_response(resp).await?;
836 if !comment.is_empty() {
837 prepend_html_to_draft(
838 http,
839 base_url,
840 access_token,
841 &draft_id,
842 &text_to_html(comment),
843 )
844 .await?;
845 }
846 Ok(draft_id)
847}
848
849pub async fn create_reply_all_draft(
851 http: &reqwest::Client,
852 base_url: &str,
853 access_token: &str,
854 message_id: &str,
855 comment: &str,
856) -> Result<String, ClientError> {
857 let url = format!("{base_url}/me/messages/{message_id}/createReplyAll");
858 let resp = http
859 .post(&url)
860 .bearer_auth(access_token)
861 .json(&serde_json::json!({}))
862 .send()
863 .await?;
864 let draft_id = parse_id_from_response(resp).await?;
865 if !comment.is_empty() {
866 prepend_html_to_draft(
867 http,
868 base_url,
869 access_token,
870 &draft_id,
871 &text_to_html(comment),
872 )
873 .await?;
874 }
875 Ok(draft_id)
876}
877
878pub async fn create_forward_draft(
881 http: &reqwest::Client,
882 base_url: &str,
883 access_token: &str,
884 message_id: &str,
885 to: &[String],
886 comment: &str,
887) -> Result<String, ClientError> {
888 let url = format!("{base_url}/me/messages/{message_id}/createForward");
889 let resp = http
890 .post(&url)
891 .bearer_auth(access_token)
892 .json(&serde_json::json!({
893 "toRecipients": to.iter().map(|addr| serde_json::json!({
894 "emailAddress": { "address": addr }
895 })).collect::<Vec<_>>(),
896 }))
897 .send()
898 .await?;
899 let draft_id = parse_id_from_response(resp).await?;
900 if !comment.is_empty() {
901 prepend_html_to_draft(
902 http,
903 base_url,
904 access_token,
905 &draft_id,
906 &text_to_html(comment),
907 )
908 .await?;
909 }
910 Ok(draft_id)
911}
912
913pub async fn send_draft(
915 http: &reqwest::Client,
916 base_url: &str,
917 access_token: &str,
918 message_id: &str,
919) -> Result<(), ClientError> {
920 let url = format!("{base_url}/me/messages/{message_id}/send");
921 let resp = http
926 .post(&url)
927 .bearer_auth(access_token)
928 .header(reqwest::header::CONTENT_LENGTH, 0)
929 .body(reqwest::Body::from(""))
930 .send()
931 .await?;
932 let status = resp.status();
933 if !status.is_success() {
934 let text = resp.text().await.unwrap_or_default();
935 return Err(ClientError::Graph {
936 status: status.as_u16(),
937 message: text,
938 });
939 }
940 Ok(())
941}
942
943pub async fn update_draft(
946 http: &reqwest::Client,
947 base_url: &str,
948 access_token: &str,
949 message_id: &str,
950 message: &Outgoing,
951) -> Result<(), ClientError> {
952 let url = format!("{base_url}/me/messages/{message_id}");
953 let body = message.to_graph_json();
954 let resp = http
955 .patch(&url)
956 .bearer_auth(access_token)
957 .json(&body)
958 .send()
959 .await?;
960 let status = resp.status();
961 if !status.is_success() {
962 let text = resp.text().await.unwrap_or_default();
963 return Err(ClientError::Graph {
964 status: status.as_u16(),
965 message: text,
966 });
967 }
968 Ok(())
969}
970
971pub async fn delete_message(
975 http: &reqwest::Client,
976 base_url: &str,
977 access_token: &str,
978 message_id: &str,
979) -> Result<(), ClientError> {
980 let url = format!("{base_url}/me/messages/{message_id}");
981 let resp = http.delete(&url).bearer_auth(access_token).send().await?;
982 let status = resp.status();
983 if !status.is_success() {
984 let text = resp.text().await.unwrap_or_default();
985 return Err(ClientError::Graph {
986 status: status.as_u16(),
987 message: text,
988 });
989 }
990 Ok(())
991}
992
993pub async fn add_attachment(
999 http: &reqwest::Client,
1000 base_url: &str,
1001 access_token: &str,
1002 message_id: &str,
1003 name: &str,
1004 content_type: &str,
1005 bytes: &[u8],
1006) -> Result<String, ClientError> {
1007 use base64::Engine;
1008 use base64::engine::general_purpose::STANDARD;
1009
1010 let url = format!("{base_url}/me/messages/{message_id}/attachments");
1011 let body = serde_json::json!({
1012 "@odata.type": "#microsoft.graph.fileAttachment",
1013 "name": name,
1014 "contentType": content_type,
1015 "contentBytes": STANDARD.encode(bytes),
1016 });
1017 let resp = http
1018 .post(&url)
1019 .bearer_auth(access_token)
1020 .json(&body)
1021 .send()
1022 .await?;
1023 parse_id_from_response(resp).await
1024}
1025
1026pub async fn delete_attachment(
1028 http: &reqwest::Client,
1029 base_url: &str,
1030 access_token: &str,
1031 message_id: &str,
1032 attachment_id: &str,
1033) -> Result<(), ClientError> {
1034 let url = format!("{base_url}/me/messages/{message_id}/attachments/{attachment_id}");
1035 let resp = http.delete(&url).bearer_auth(access_token).send().await?;
1036 let status = resp.status();
1037 if !status.is_success() {
1038 let text = resp.text().await.unwrap_or_default();
1039 return Err(ClientError::Graph {
1040 status: status.as_u16(),
1041 message: text,
1042 });
1043 }
1044 Ok(())
1045}
1046
1047async fn parse_id_from_response(resp: reqwest::Response) -> Result<String, ClientError> {
1048 let status = resp.status();
1049 if !status.is_success() {
1050 let text = resp.text().await.unwrap_or_default();
1051 return Err(ClientError::Graph {
1052 status: status.as_u16(),
1053 message: text,
1054 });
1055 }
1056 let v: serde_json::Value = resp.json().await?;
1057 v["id"]
1058 .as_str()
1059 .map(|s| s.to_string())
1060 .ok_or_else(|| ClientError::Graph {
1061 status: 200,
1062 message: "draft response missing 'id'".to_string(),
1063 })
1064}
1065
1066#[derive(Debug, Clone)]
1068pub struct Outgoing {
1069 pub subject: String,
1070 pub body_text: String,
1071 pub to: Vec<String>,
1072 pub cc: Vec<String>,
1073 pub bcc: Vec<String>,
1074}
1075
1076impl Outgoing {
1077 fn to_graph_json(&self) -> serde_json::Value {
1078 fn addresses(list: &[String]) -> Vec<serde_json::Value> {
1079 list.iter()
1080 .map(|addr| serde_json::json!({ "emailAddress": { "address": addr } }))
1081 .collect()
1082 }
1083 serde_json::json!({
1084 "subject": self.subject,
1085 "body": {
1086 "contentType": "Text",
1087 "content": self.body_text,
1088 },
1089 "toRecipients": addresses(&self.to),
1090 "ccRecipients": addresses(&self.cc),
1091 "bccRecipients": addresses(&self.bcc),
1092 })
1093 }
1094}
1095
1096pub async fn move_message(
1103 http: &reqwest::Client,
1104 base_url: &str,
1105 access_token: &str,
1106 message_id: &str,
1107 destination: &str,
1108) -> Result<(), ClientError> {
1109 let url = format!("{base_url}/me/messages/{message_id}/move");
1110 let resp = http
1111 .post(&url)
1112 .bearer_auth(access_token)
1113 .json(&serde_json::json!({ "destinationId": destination }))
1114 .send()
1115 .await?;
1116 let status = resp.status();
1117 if !status.is_success() {
1118 let text = resp.text().await.unwrap_or_default();
1119 return Err(ClientError::Graph {
1120 status: status.as_u16(),
1121 message: text,
1122 });
1123 }
1124 Ok(())
1125}
1126
1127#[derive(Debug, Clone, Deserialize)]
1129pub struct MailFolder {
1130 pub id: String,
1131 #[serde(rename = "displayName")]
1132 pub display_name: String,
1133 #[serde(rename = "totalItemCount", default)]
1135 pub total_item_count: Option<u64>,
1136 #[serde(rename = "unreadItemCount", default)]
1138 pub unread_item_count: Option<u64>,
1139 #[serde(rename = "childFolderCount", default)]
1142 pub child_folder_count: Option<u64>,
1143}
1144
1145#[derive(Debug, Deserialize)]
1146struct GraphFolderList {
1147 value: Vec<MailFolder>,
1148 #[serde(rename = "@odata.nextLink", default)]
1149 next_link: Option<String>,
1150}
1151
1152const FOLDER_SELECT: &str = "id,displayName,totalItemCount,unreadItemCount,childFolderCount";
1155
1156async fn fetch_folder_pages(
1159 http: &reqwest::Client,
1160 access_token: &str,
1161 mut url: String,
1162) -> Result<Vec<MailFolder>, ClientError> {
1163 let mut folders: Vec<MailFolder> = Vec::new();
1164 loop {
1165 let resp = http.get(&url).bearer_auth(access_token).send().await?;
1166 let status = resp.status();
1167 if !status.is_success() {
1168 let text = resp.text().await.unwrap_or_default();
1169 return Err(ClientError::Graph {
1170 status: status.as_u16(),
1171 message: text,
1172 });
1173 }
1174 let list: GraphFolderList = resp.json().await?;
1175 folders.extend(list.value);
1176 match list.next_link {
1179 Some(next) => url = next,
1180 None => break,
1181 }
1182 }
1183 Ok(folders)
1184}
1185
1186pub async fn list_mail_folders(
1190 http: &reqwest::Client,
1191 base_url: &str,
1192 access_token: &str,
1193) -> Result<Vec<MailFolder>, ClientError> {
1194 let url = format!("{base_url}/me/mailFolders?$select={FOLDER_SELECT}&$top=100");
1195 fetch_folder_pages(http, access_token, url).await
1196}
1197
1198pub async fn list_child_folders(
1201 http: &reqwest::Client,
1202 base_url: &str,
1203 access_token: &str,
1204 parent_id: &str,
1205) -> Result<Vec<MailFolder>, ClientError> {
1206 let url = format!(
1207 "{base_url}/me/mailFolders/{parent_id}/childFolders?$select={FOLDER_SELECT}&$top=100"
1208 );
1209 fetch_folder_pages(http, access_token, url).await
1210}
1211
1212async fn post_folder(
1213 http: &reqwest::Client,
1214 url: &str,
1215 access_token: &str,
1216 display_name: &str,
1217) -> Result<MailFolder, ClientError> {
1218 let resp = http
1219 .post(url)
1220 .bearer_auth(access_token)
1221 .json(&serde_json::json!({ "displayName": display_name }))
1222 .send()
1223 .await?;
1224 let status = resp.status();
1225 if !status.is_success() {
1226 let text = resp.text().await.unwrap_or_default();
1227 return Err(ClientError::Graph {
1228 status: status.as_u16(),
1229 message: text,
1230 });
1231 }
1232 let folder: MailFolder = resp.json().await?;
1233 Ok(folder)
1234}
1235
1236pub async fn create_mail_folder(
1242 http: &reqwest::Client,
1243 base_url: &str,
1244 access_token: &str,
1245 display_name: &str,
1246) -> Result<MailFolder, ClientError> {
1247 let url = format!("{base_url}/me/mailFolders");
1248 post_folder(http, &url, access_token, display_name).await
1249}
1250
1251pub async fn create_child_folder(
1254 http: &reqwest::Client,
1255 base_url: &str,
1256 access_token: &str,
1257 parent_id: &str,
1258 display_name: &str,
1259) -> Result<MailFolder, ClientError> {
1260 let url = format!("{base_url}/me/mailFolders/{parent_id}/childFolders");
1261 post_folder(http, &url, access_token, display_name).await
1262}
1263
1264pub async fn delete_mail_folder(
1268 http: &reqwest::Client,
1269 base_url: &str,
1270 access_token: &str,
1271 folder_id: &str,
1272) -> Result<(), ClientError> {
1273 let url = format!("{base_url}/me/mailFolders/{folder_id}");
1274 let resp = http.delete(&url).bearer_auth(access_token).send().await?;
1275 let status = resp.status();
1276 if !status.is_success() {
1277 let text = resp.text().await.unwrap_or_default();
1278 return Err(ClientError::Graph {
1279 status: status.as_u16(),
1280 message: text,
1281 });
1282 }
1283 Ok(())
1284}
1285
1286#[cfg(test)]
1287mod tests {
1288 use super::*;
1289 use wiremock::matchers::{header, method, path, path_regex, query_param};
1290 use wiremock::{Mock, MockServer, ResponseTemplate};
1291
1292 #[tokio::test]
1293 async fn list_inbox_parses_graph_response() {
1294 let server = MockServer::start().await;
1295 Mock::given(method("GET"))
1296 .and(path("/me/mailFolders/inbox/messages"))
1297 .and(header("authorization", "Bearer AT"))
1298 .and(query_param("$top", "5"))
1299 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1300 "value": [
1301 {
1302 "id": "AAAA",
1303 "subject": "Hello",
1304 "from": {
1305 "emailAddress": {
1306 "name": "Maria",
1307 "address": "maria@mklab.se"
1308 }
1309 },
1310 "receivedDateTime": "2026-05-13T22:00:00Z",
1311 "isRead": false,
1312 "bodyPreview": "Hi there"
1313 }
1314 ]
1315 })))
1316 .mount(&server)
1317 .await;
1318
1319 let http = reqwest::Client::new();
1320 let page = list_inbox(&http, &server.uri(), "AT", "u@e.com", 5, 0, false)
1321 .await
1322 .unwrap();
1323 assert_eq!(page.messages.len(), 1);
1324 assert_eq!(page.messages[0].subject, "Hello");
1325 assert_eq!(page.messages[0].from.address, "maria@mklab.se");
1326 assert!(!page.messages[0].is_read);
1327 assert_eq!(page.messages[0].account, "u@e.com");
1328 assert!(!page.has_more);
1329 }
1330
1331 #[tokio::test]
1332 async fn list_inbox_adds_filter_when_unread_only() {
1333 let server = MockServer::start().await;
1334 Mock::given(method("GET"))
1335 .and(path("/me/mailFolders/inbox/messages"))
1336 .and(query_param("$filter", "isRead eq false"))
1337 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1338 "value": []
1339 })))
1340 .mount(&server)
1341 .await;
1342
1343 let http = reqwest::Client::new();
1344 let page = list_inbox(&http, &server.uri(), "AT", "u@e.com", 5, 0, true)
1345 .await
1346 .unwrap();
1347 assert!(page.messages.is_empty());
1348 }
1349
1350 #[tokio::test]
1351 async fn list_inbox_passes_skip_when_nonzero() {
1352 let server = MockServer::start().await;
1353 Mock::given(method("GET"))
1354 .and(path("/me/mailFolders/inbox/messages"))
1355 .and(query_param("$skip", "25"))
1356 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1357 "value": [],
1358 "@odata.nextLink": "https://graph.example/next"
1359 })))
1360 .mount(&server)
1361 .await;
1362
1363 let http = reqwest::Client::new();
1364 let page = list_inbox(&http, &server.uri(), "AT", "u@e.com", 25, 25, false)
1365 .await
1366 .unwrap();
1367 assert!(page.has_more);
1368 }
1369
1370 #[tokio::test]
1371 async fn search_messages_passes_search_query() {
1372 let server = MockServer::start().await;
1373 Mock::given(method("GET"))
1374 .and(path("/me/messages"))
1375 .and(query_param("$search", "\"alice budget\""))
1376 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1377 "value": [
1378 {
1379 "id": "S1",
1380 "subject": "Q4 budget review",
1381 "from": { "emailAddress": { "name": "Alice", "address": "alice@example.com" } },
1382 "receivedDateTime": "2026-05-13T22:00:00Z",
1383 "isRead": true,
1384 "bodyPreview": "Numbers attached"
1385 }
1386 ]
1387 })))
1388 .mount(&server)
1389 .await;
1390
1391 let http = reqwest::Client::new();
1392 let msgs = search_messages(&http, &server.uri(), "AT", "u@e.com", "alice budget", 25)
1393 .await
1394 .unwrap();
1395 assert_eq!(msgs.len(), 1);
1396 assert_eq!(msgs[0].subject, "Q4 budget review");
1397 }
1398
1399 #[tokio::test]
1400 async fn mark_unread_patches_isread_false() {
1401 let server = MockServer::start().await;
1402 Mock::given(method("PATCH"))
1403 .and(path_regex("/me/messages/[A-Za-z0-9]+"))
1404 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
1405 .mount(&server)
1406 .await;
1407 let http = reqwest::Client::new();
1408 mark_unread(&http, &server.uri(), "AT", "MSG")
1409 .await
1410 .unwrap();
1411 }
1412
1413 #[tokio::test]
1414 async fn set_flag_patches_flag_status() {
1415 let server = MockServer::start().await;
1416 Mock::given(method("PATCH"))
1417 .and(path_regex("/me/messages/[A-Za-z0-9]+"))
1418 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
1419 .mount(&server)
1420 .await;
1421 let http = reqwest::Client::new();
1422 set_flag(&http, &server.uri(), "AT", "MSG", true)
1423 .await
1424 .unwrap();
1425 set_flag(&http, &server.uri(), "AT", "MSG", false)
1426 .await
1427 .unwrap();
1428 }
1429
1430 #[tokio::test]
1431 async fn send_mail_wraps_outgoing_in_message_envelope() {
1432 use wiremock::matchers::body_partial_json;
1433 let server = MockServer::start().await;
1434 Mock::given(method("POST"))
1435 .and(path("/me/sendMail"))
1436 .and(body_partial_json(serde_json::json!({
1437 "saveToSentItems": true,
1438 "message": {
1439 "subject": "Hello",
1440 "body": { "contentType": "Text", "content": "Hi there" },
1441 "toRecipients": [{ "emailAddress": { "address": "alice@example.com" } }]
1442 }
1443 })))
1444 .respond_with(ResponseTemplate::new(202))
1445 .mount(&server)
1446 .await;
1447 let http = reqwest::Client::new();
1448 let msg = Outgoing {
1449 subject: "Hello".into(),
1450 body_text: "Hi there".into(),
1451 to: vec!["alice@example.com".into()],
1452 cc: vec![],
1453 bcc: vec![],
1454 };
1455 send_mail(&http, &server.uri(), "AT", &msg).await.unwrap();
1456 }
1457
1458 #[tokio::test]
1459 async fn reply_message_creates_draft_patches_body_and_sends() {
1460 use wiremock::matchers::body_partial_json;
1461 let server = MockServer::start().await;
1462
1463 Mock::given(method("POST"))
1465 .and(path_regex("/me/messages/MSG/createReply"))
1466 .respond_with(
1467 ResponseTemplate::new(201).set_body_json(serde_json::json!({ "id": "DRAFT" })),
1468 )
1469 .mount(&server)
1470 .await;
1471 Mock::given(method("GET"))
1473 .and(path("/me/messages/DRAFT"))
1474 .and(query_param("$select", "body"))
1475 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1476 "body": { "contentType": "HTML", "content": "<html><body><div></div></body></html>" }
1477 })))
1478 .mount(&server)
1479 .await;
1480 Mock::given(method("PATCH"))
1482 .and(path("/me/messages/DRAFT"))
1483 .and(body_partial_json(serde_json::json!({
1484 "body": { "contentType": "HTML" }
1485 })))
1486 .respond_with(ResponseTemplate::new(200))
1487 .mount(&server)
1488 .await;
1489 Mock::given(method("POST"))
1491 .and(path("/me/messages/DRAFT/send"))
1492 .respond_with(ResponseTemplate::new(202))
1493 .mount(&server)
1494 .await;
1495
1496 let http = reqwest::Client::new();
1497 reply_message(&http, &server.uri(), "AT", "MSG", "Thanks!")
1498 .await
1499 .unwrap();
1500 }
1501
1502 #[tokio::test]
1503 async fn forward_message_creates_draft_with_recipients_and_sends() {
1504 use wiremock::matchers::body_partial_json;
1505 let server = MockServer::start().await;
1506
1507 Mock::given(method("POST"))
1508 .and(path_regex("/me/messages/MSG/createForward"))
1509 .and(body_partial_json(serde_json::json!({
1510 "toRecipients": [{ "emailAddress": { "address": "bob@example.com" } }]
1511 })))
1512 .respond_with(
1513 ResponseTemplate::new(201).set_body_json(serde_json::json!({ "id": "DRAFT" })),
1514 )
1515 .mount(&server)
1516 .await;
1517 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></body></html>" }
1522 })))
1523 .mount(&server)
1524 .await;
1525 Mock::given(method("PATCH"))
1526 .and(path("/me/messages/DRAFT"))
1527 .respond_with(ResponseTemplate::new(200))
1528 .mount(&server)
1529 .await;
1530 Mock::given(method("POST"))
1531 .and(path("/me/messages/DRAFT/send"))
1532 .respond_with(ResponseTemplate::new(202))
1533 .mount(&server)
1534 .await;
1535
1536 let http = reqwest::Client::new();
1537 forward_message(
1538 &http,
1539 &server.uri(),
1540 "AT",
1541 "MSG",
1542 &["bob@example.com".into()],
1543 "FYI",
1544 )
1545 .await
1546 .unwrap();
1547 }
1548
1549 #[test]
1550 fn text_to_html_escapes_and_breaks_paragraphs() {
1551 let out = text_to_html("Hej Edward,\n\nLine 1\nLine 2\n\n<script>x</script>");
1552 assert!(out.contains("<p>Hej Edward,</p>"));
1553 assert!(out.contains("<p>Line 1<br>Line 2</p>"));
1554 assert!(out.contains("<script>x</script>"));
1555 assert!(!out.contains("<script>"));
1556 }
1557
1558 #[test]
1559 fn text_to_html_normalizes_crlf() {
1560 let out = text_to_html("A\r\nB\r\n\r\nC");
1561 assert!(out.contains("<p>A<br>B</p>"));
1562 assert!(out.contains("<p>C</p>"));
1563 }
1564
1565 #[test]
1566 fn find_body_tag_end_handles_attributes_and_case() {
1567 let html = "<html><BODY class=\"x\">content</BODY></html>";
1568 let pos = find_body_tag_end(html).unwrap();
1569 assert_eq!(&html[pos..pos + 7], "content");
1570 }
1571
1572 #[tokio::test]
1573 async fn list_mail_folders_parses_and_pages() {
1574 let server = MockServer::start().await;
1575 Mock::given(method("GET"))
1577 .and(path("/me/mailFolders"))
1578 .and(query_param("$top", "100"))
1579 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1580 "value": [
1581 { "id": "F1", "displayName": "Biljetter", "totalItemCount": 3, "unreadItemCount": 0 }
1582 ],
1583 "@odata.nextLink": format!("{}/me/mailFolders?page=2", server.uri())
1584 })))
1585 .mount(&server)
1586 .await;
1587 Mock::given(method("GET"))
1588 .and(path("/me/mailFolders"))
1589 .and(query_param("page", "2"))
1590 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1591 "value": [
1592 { "id": "F2", "displayName": "Kvitton", "totalItemCount": 7, "unreadItemCount": 2 }
1593 ]
1594 })))
1595 .mount(&server)
1596 .await;
1597
1598 let http = reqwest::Client::new();
1599 let folders = list_mail_folders(&http, &server.uri(), "AT").await.unwrap();
1600 assert_eq!(folders.len(), 2);
1601 assert_eq!(folders[0].display_name, "Biljetter");
1602 assert_eq!(folders[0].total_item_count, Some(3));
1603 assert_eq!(folders[1].display_name, "Kvitton");
1604 assert_eq!(folders[1].unread_item_count, Some(2));
1605 }
1606
1607 #[tokio::test]
1608 async fn create_mail_folder_posts_display_name() {
1609 use wiremock::matchers::body_partial_json;
1610 let server = MockServer::start().await;
1611 Mock::given(method("POST"))
1612 .and(path("/me/mailFolders"))
1613 .and(body_partial_json(
1614 serde_json::json!({ "displayName": "Biljetter" }),
1615 ))
1616 .respond_with(
1617 ResponseTemplate::new(201)
1618 .set_body_json(serde_json::json!({ "id": "NEWF", "displayName": "Biljetter" })),
1619 )
1620 .mount(&server)
1621 .await;
1622
1623 let http = reqwest::Client::new();
1624 let folder = create_mail_folder(&http, &server.uri(), "AT", "Biljetter")
1625 .await
1626 .unwrap();
1627 assert_eq!(folder.id, "NEWF");
1628 assert_eq!(folder.display_name, "Biljetter");
1629 }
1630
1631 #[tokio::test]
1632 async fn list_child_folders_hits_child_endpoint() {
1633 let server = MockServer::start().await;
1634 Mock::given(method("GET"))
1635 .and(path("/me/mailFolders/PARENT/childFolders"))
1636 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1637 "value": [
1638 { "id": "C1", "displayName": "MKLab", "totalItemCount": 5, "unreadItemCount": 0, "childFolderCount": 0 }
1639 ]
1640 })))
1641 .mount(&server)
1642 .await;
1643 let http = reqwest::Client::new();
1644 let children = list_child_folders(&http, &server.uri(), "AT", "PARENT")
1645 .await
1646 .unwrap();
1647 assert_eq!(children.len(), 1);
1648 assert_eq!(children[0].display_name, "MKLab");
1649 }
1650
1651 #[tokio::test]
1652 async fn create_child_folder_posts_to_parent() {
1653 use wiremock::matchers::body_partial_json;
1654 let server = MockServer::start().await;
1655 Mock::given(method("POST"))
1656 .and(path("/me/mailFolders/PARENT/childFolders"))
1657 .and(body_partial_json(
1658 serde_json::json!({ "displayName": "MKLab" }),
1659 ))
1660 .respond_with(
1661 ResponseTemplate::new(201)
1662 .set_body_json(serde_json::json!({ "id": "C9", "displayName": "MKLab" })),
1663 )
1664 .mount(&server)
1665 .await;
1666 let http = reqwest::Client::new();
1667 let folder = create_child_folder(&http, &server.uri(), "AT", "PARENT", "MKLab")
1668 .await
1669 .unwrap();
1670 assert_eq!(folder.id, "C9");
1671 }
1672
1673 #[tokio::test]
1674 async fn delete_mail_folder_hits_delete_endpoint() {
1675 let server = MockServer::start().await;
1676 Mock::given(method("DELETE"))
1677 .and(path("/me/mailFolders/F1"))
1678 .respond_with(ResponseTemplate::new(204))
1679 .mount(&server)
1680 .await;
1681 let http = reqwest::Client::new();
1682 delete_mail_folder(&http, &server.uri(), "AT", "F1")
1683 .await
1684 .unwrap();
1685 }
1686
1687 #[tokio::test]
1688 async fn move_message_posts_destination() {
1689 let server = MockServer::start().await;
1690 Mock::given(method("POST"))
1691 .and(path_regex("/me/messages/[A-Za-z0-9]+/move"))
1692 .respond_with(
1693 ResponseTemplate::new(201).set_body_json(serde_json::json!({"id": "NEW"})),
1694 )
1695 .mount(&server)
1696 .await;
1697 let http = reqwest::Client::new();
1698 move_message(&http, &server.uri(), "AT", "MSG", "archive")
1699 .await
1700 .unwrap();
1701 }
1702
1703 #[tokio::test]
1704 async fn get_message_parses_graph_response() {
1705 let server = MockServer::start().await;
1706 Mock::given(method("GET"))
1707 .and(path_regex("/me/messages/[A-Za-z0-9]+"))
1708 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1709 "id": "AAA",
1710 "subject": "Hello",
1711 "from": { "emailAddress": { "name": "Maria", "address": "maria@mklab.se" } },
1712 "toRecipients": [
1713 { "emailAddress": { "name": "Kristofer", "address": "kristofer@mklab.se" } }
1714 ],
1715 "ccRecipients": [],
1716 "bccRecipients": [],
1717 "receivedDateTime": "2026-05-14T22:00:00Z",
1718 "sentDateTime": "2026-05-14T21:59:30Z",
1719 "isRead": false,
1720 "body": { "contentType": "html", "content": "<p>Hi</p>" },
1721 "hasAttachments": true
1722 })))
1723 .mount(&server)
1724 .await;
1725
1726 let http = reqwest::Client::new();
1727 let m = get_message(&http, &server.uri(), "AT", "u@e.com", "AAA")
1728 .await
1729 .unwrap();
1730 assert_eq!(m.id, "AAA");
1731 assert_eq!(m.subject, "Hello");
1732 assert_eq!(m.from.name, "Maria");
1733 assert_eq!(m.to.len(), 1);
1734 assert_eq!(m.to[0].address, "kristofer@mklab.se");
1735 assert!(matches!(
1736 m.body_content_type,
1737 pidge_core::BodyContentType::Html
1738 ));
1739 assert_eq!(m.body_content, "<p>Hi</p>");
1740 assert!(m.has_attachments);
1741 }
1742
1743 #[tokio::test]
1744 async fn list_attachments_filters_file_attachments() {
1745 let server = MockServer::start().await;
1746 Mock::given(method("GET"))
1747 .and(path_regex("/me/messages/[A-Za-z0-9]+/attachments"))
1748 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1749 "value": [
1750 {
1751 "@odata.type": "#microsoft.graph.fileAttachment",
1752 "id": "att-1",
1753 "name": "report.pdf",
1754 "contentType": "application/pdf",
1755 "size": 12345,
1756 "isInline": false
1757 },
1758 {
1759 "@odata.type": "#microsoft.graph.itemAttachment",
1760 "id": "att-2",
1761 "name": "an-email.eml",
1762 "contentType": "message/rfc822",
1763 "size": 7777,
1764 "isInline": false
1765 }
1766 ]
1767 })))
1768 .mount(&server)
1769 .await;
1770
1771 let http = reqwest::Client::new();
1772 let atts = list_attachments(&http, &server.uri(), "AT", "MSG")
1773 .await
1774 .unwrap();
1775 assert_eq!(atts.len(), 1);
1776 assert_eq!(atts[0].name, "report.pdf");
1777 assert_eq!(atts[0].size_bytes, 12345);
1778 }
1779
1780 #[tokio::test]
1781 async fn get_attachment_bytes_decodes_base64() {
1782 let server = MockServer::start().await;
1783 Mock::given(method("GET"))
1784 .and(path_regex(
1785 "/me/messages/[A-Za-z0-9]+/attachments/[A-Za-z0-9-]+",
1786 ))
1787 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1788 "id": "att-1",
1789 "name": "report.pdf",
1790 "contentType": "application/pdf",
1791 "size": 5,
1792 "isInline": false,
1793 "contentBytes": "aGVsbG8="
1794 })))
1795 .mount(&server)
1796 .await;
1797
1798 let http = reqwest::Client::new();
1799 let bytes = get_attachment_bytes(&http, &server.uri(), "AT", "MSG", "att-1")
1800 .await
1801 .unwrap();
1802 assert_eq!(bytes, b"hello");
1803 }
1804
1805 #[tokio::test]
1806 async fn mark_read_patches_isread_true() {
1807 let server = MockServer::start().await;
1808 Mock::given(method("PATCH"))
1809 .and(path_regex("/me/messages/[A-Za-z0-9]+"))
1810 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
1811 .mount(&server)
1812 .await;
1813
1814 let http = reqwest::Client::new();
1815 mark_read(&http, &server.uri(), "AT", "MSG").await.unwrap();
1816 }
1817
1818 #[tokio::test]
1819 async fn fetch_message_headers_parses_array() {
1820 let server = MockServer::start().await;
1821 Mock::given(method("GET"))
1822 .and(path_regex(r"^/me/messages/.+$"))
1823 .and(query_param("$select", "internetMessageHeaders"))
1824 .and(header("authorization", "Bearer AT"))
1825 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1826 "internetMessageHeaders": [
1827 { "name": "List-Unsubscribe", "value": "<mailto:u@x>, <https://x/u>" },
1828 { "name": "List-Unsubscribe-Post", "value": "List-Unsubscribe=One-Click" }
1829 ]
1830 })))
1831 .mount(&server)
1832 .await;
1833
1834 let http = reqwest::Client::new();
1835 let headers = fetch_message_headers(&http, &server.uri(), "AT", "MSGID")
1836 .await
1837 .unwrap();
1838 assert_eq!(headers.len(), 2);
1839 assert_eq!(headers[0].0, "List-Unsubscribe");
1840 assert_eq!(headers[0].1, "<mailto:u@x>, <https://x/u>");
1841 assert_eq!(headers[1].0, "List-Unsubscribe-Post");
1842 }
1843}