1use pidge_core::{FlagStatus, Message, MessageFrom};
4use serde::Deserialize;
5
6use crate::error::ClientError;
7
8#[derive(Debug, Deserialize)]
9struct GraphMessage {
10 id: String,
11 subject: Option<String>,
12 from: Option<GraphFromWrapper>,
13 #[serde(rename = "receivedDateTime")]
14 received_date_time: chrono::DateTime<chrono::Utc>,
15 #[serde(rename = "isRead")]
16 is_read: Option<bool>,
17 #[serde(rename = "bodyPreview")]
18 body_preview: Option<String>,
19 body: Option<GraphBody>,
23 #[serde(rename = "hasAttachments")]
24 has_attachments: Option<bool>,
25 #[serde(rename = "conversationId", default)]
26 conversation_id: Option<String>,
27 #[serde(default)]
28 flag: Option<GraphFlag>,
29}
30
31#[derive(Debug, Deserialize)]
32struct GraphFlag {
33 #[serde(rename = "flagStatus", default)]
34 flag_status: Option<String>,
35}
36
37fn flag_status_from(g: Option<GraphFlag>) -> FlagStatus {
38 match g.and_then(|f| f.flag_status).as_deref() {
39 Some("flagged") => FlagStatus::Flagged,
40 Some("complete") => FlagStatus::Complete,
41 _ => FlagStatus::NotFlagged,
42 }
43}
44
45#[derive(Debug, Deserialize)]
46struct GraphFromWrapper {
47 #[serde(rename = "emailAddress")]
48 email_address: GraphFromAddress,
49}
50
51#[derive(Debug, Deserialize)]
52struct GraphFromAddress {
53 name: Option<String>,
54 address: Option<String>,
55}
56
57#[derive(Debug, Deserialize)]
58struct GraphList {
59 value: Vec<GraphMessage>,
60 #[serde(rename = "@odata.nextLink", default)]
63 next_link: Option<String>,
64}
65
66pub struct InboxPage {
68 pub messages: Vec<Message>,
69 pub has_more: bool,
70 pub next_link: Option<String>,
72}
73
74#[derive(Debug, Deserialize)]
75struct GraphFullMessage {
76 id: String,
77 #[serde(rename = "conversationId", default)]
78 conversation_id: Option<String>,
79 subject: Option<String>,
80 from: Option<GraphFromWrapper>,
81 #[serde(rename = "toRecipients", default)]
82 to_recipients: Vec<GraphFromWrapper>,
83 #[serde(rename = "ccRecipients", default)]
84 cc_recipients: Vec<GraphFromWrapper>,
85 #[serde(rename = "bccRecipients", default)]
86 bcc_recipients: Vec<GraphFromWrapper>,
87 #[serde(rename = "receivedDateTime")]
88 received_date_time: chrono::DateTime<chrono::Utc>,
89 #[serde(rename = "sentDateTime")]
90 sent_date_time: chrono::DateTime<chrono::Utc>,
91 #[serde(rename = "isRead")]
92 is_read: Option<bool>,
93 body: GraphBody,
94 #[serde(rename = "hasAttachments")]
95 has_attachments: Option<bool>,
96 #[serde(default)]
97 flag: Option<GraphFlag>,
98}
99
100#[derive(Debug, Deserialize)]
101struct GraphBody {
102 #[serde(rename = "contentType")]
103 content_type: String,
104 content: String,
105}
106
107#[derive(Debug, Deserialize)]
108struct GraphAttachmentList {
109 value: Vec<GraphAttachment>,
110}
111
112#[derive(Debug, Deserialize)]
113struct GraphAttachment {
114 id: String,
115 name: Option<String>,
116 #[serde(rename = "contentType")]
117 content_type: Option<String>,
118 size: Option<u64>,
119 #[serde(rename = "isInline")]
120 is_inline: Option<bool>,
121 #[serde(rename = "contentId")]
122 content_id: Option<String>,
123 #[serde(rename = "@odata.type", default)]
124 odata_type: Option<String>,
125 #[serde(rename = "contentBytes", default)]
127 content_bytes: Option<String>,
128}
129
130pub async fn list_inbox(
136 http: &reqwest::Client,
137 base_url: &str,
138 access_token: &str,
139 account: &str,
140 limit: usize,
141 skip: usize,
142 unread_only: bool,
143) -> Result<InboxPage, ClientError> {
144 list_folder(
145 http,
146 base_url,
147 access_token,
148 account,
149 "inbox",
150 limit,
151 skip,
152 unread_only,
153 )
154 .await
155}
156
157#[allow(clippy::too_many_arguments)]
161pub async fn list_folder_messages(
162 http: &reqwest::Client,
163 base_url: &str,
164 access_token: &str,
165 account: &str,
166 folder_id: &str,
167 limit: usize,
168 skip: usize,
169 unread_only: bool,
170) -> Result<InboxPage, ClientError> {
171 list_folder(
172 http,
173 base_url,
174 access_token,
175 account,
176 folder_id,
177 limit,
178 skip,
179 unread_only,
180 )
181 .await
182}
183
184pub async fn list_drafts(
187 http: &reqwest::Client,
188 base_url: &str,
189 access_token: &str,
190 account: &str,
191 limit: usize,
192 skip: usize,
193) -> Result<InboxPage, ClientError> {
194 list_folder(
195 http,
196 base_url,
197 access_token,
198 account,
199 "drafts",
200 limit,
201 skip,
202 false,
203 )
204 .await
205}
206
207#[allow(clippy::too_many_arguments)]
208async fn list_folder(
209 http: &reqwest::Client,
210 base_url: &str,
211 access_token: &str,
212 account: &str,
213 folder: &str,
214 limit: usize,
215 skip: usize,
216 unread_only: bool,
217) -> Result<InboxPage, ClientError> {
218 let url = format!("{base_url}/me/mailFolders/{folder}/messages");
219 let mut req = http.get(&url).bearer_auth(access_token).query(&[
223 (
224 "$select",
225 "id,subject,from,receivedDateTime,isRead,bodyPreview,body,hasAttachments,flag,conversationId",
226 ),
227 ("$orderby", "receivedDateTime desc"),
228 ("$top", &limit.to_string()),
229 ]);
230 if skip > 0 {
231 req = req.query(&[("$skip", &skip.to_string())]);
232 }
233 if unread_only {
234 req = req.query(&[("$filter", "isRead eq false")]);
235 }
236
237 let resp = super::send_with_retry(req).await?;
238 let status = resp.status();
239 if !status.is_success() {
240 let text = resp.text().await.unwrap_or_default();
241 return Err(ClientError::Graph {
242 status: status.as_u16(),
243 message: text,
244 });
245 }
246
247 let list: GraphList = resp.json().await?;
248 Ok(InboxPage {
249 has_more: list.next_link.is_some(),
250 next_link: list.next_link,
251 messages: list
252 .value
253 .into_iter()
254 .map(|g| to_message(g, account))
255 .collect(),
256 })
257}
258
259pub(crate) fn message_from_delta_value(
273 value: serde_json::Value,
274 account: &str,
275) -> Option<pidge_core::Message> {
276 let g: GraphMessage = serde_json::from_value(value).ok()?;
277 Some(to_message(g, account))
278}
279
280pub async fn list_conversation(
282 http: &reqwest::Client,
283 base_url: &str,
284 access_token: &str,
285 account: &str,
286 conversation_id: &str,
287) -> Result<Vec<pidge_core::Message>, ClientError> {
288 let url = format!("{base_url}/me/messages");
289 let filter = format!(
290 "conversationId eq '{}'",
291 conversation_id.replace('\'', "''")
292 );
293 let req = http
294 .get(&url)
295 .bearer_auth(access_token)
296 .header("Prefer", "outlook.body-content-type=\"text\"")
297 .query(&[
298 (
299 "$select",
300 "id,subject,from,receivedDateTime,isRead,bodyPreview,body,hasAttachments,flag,conversationId",
301 ),
302 ("$filter", filter.as_str()),
305 ("$top", "100"),
306 ]);
307 let resp = super::send_with_retry(req).await?;
308 let status = resp.status();
309 if !status.is_success() {
310 let text = resp.text().await.unwrap_or_default();
311 return Err(ClientError::Graph {
312 status: status.as_u16(),
313 message: text,
314 });
315 }
316 let list: GraphList = resp.json().await?;
317 let mut messages: Vec<pidge_core::Message> = list
318 .value
319 .into_iter()
320 .map(|g| to_message(g, account))
321 .collect();
322 messages.sort_by_key(|m| m.received_at);
323 Ok(messages)
324}
325
326pub async fn list_messages_at(
329 http: &reqwest::Client,
330 access_token: &str,
331 account: &str,
332 url: &str,
333) -> Result<InboxPage, ClientError> {
334 let req = http.get(url).bearer_auth(access_token);
335 let resp = super::send_with_retry(req).await?;
336 let status = resp.status();
337 if !status.is_success() {
338 let text = resp.text().await.unwrap_or_default();
339 return Err(ClientError::Graph {
340 status: status.as_u16(),
341 message: text,
342 });
343 }
344 let list: GraphList = resp.json().await?;
345 Ok(InboxPage {
346 has_more: list.next_link.is_some(),
347 next_link: list.next_link,
348 messages: list
349 .value
350 .into_iter()
351 .map(|g| to_message(g, account))
352 .collect(),
353 })
354}
355
356pub async fn search_messages(
357 http: &reqwest::Client,
358 base_url: &str,
359 access_token: &str,
360 account: &str,
361 query: &str,
362 limit: usize,
363) -> Result<InboxPage, ClientError> {
364 let quoted = format!("\"{}\"", query.replace('"', "\\\""));
366 let url = format!("{base_url}/me/messages");
367 let resp = super::send_with_retry(
368 http.get(&url)
369 .bearer_auth(access_token)
370 .header("Prefer", "outlook.body-content-type=\"text\"")
371 .query(&[
372 (
373 "$select",
374 "id,subject,from,receivedDateTime,isRead,bodyPreview,body,hasAttachments,flag,conversationId",
375 ),
376 ("$top", &limit.to_string()),
377 ("$search", "ed),
378 ]),
379 )
380 .await?;
381 let status = resp.status();
382 if !status.is_success() {
383 let text = resp.text().await.unwrap_or_default();
384 return Err(ClientError::Graph {
385 status: status.as_u16(),
386 message: text,
387 });
388 }
389 let list: GraphList = resp.json().await?;
390 Ok(InboxPage {
391 has_more: list.next_link.is_some(),
392 next_link: list.next_link,
393 messages: list
394 .value
395 .into_iter()
396 .map(|g| to_message(g, account))
397 .collect(),
398 })
399}
400
401fn to_message(g: GraphMessage, account: &str) -> Message {
402 let (body, body_content_type) = match g.body {
403 Some(b) => {
404 let kind = if b.content_type.eq_ignore_ascii_case("html") {
405 pidge_core::BodyContentType::Html
406 } else {
407 pidge_core::BodyContentType::Text
408 };
409 (b.content, kind)
410 }
411 None => (String::new(), pidge_core::BodyContentType::Text),
412 };
413 Message {
414 account: account.to_string(),
415 id: g.id,
416 conversation_id: g.conversation_id.unwrap_or_default(),
417 from: MessageFrom {
418 name: g
419 .from
420 .as_ref()
421 .and_then(|f| f.email_address.name.clone())
422 .unwrap_or_default(),
423 address: g
424 .from
425 .as_ref()
426 .and_then(|f| f.email_address.address.clone())
427 .unwrap_or_default(),
428 },
429 subject: g.subject.unwrap_or_default(),
430 received_at: g.received_date_time,
431 is_read: g.is_read.unwrap_or(true),
432 preview: g.body_preview.unwrap_or_default(),
437 flag_status: flag_status_from(g.flag),
438 has_attachments: g.has_attachments.unwrap_or(false),
439 body,
440 body_content_type,
441 }
442}
443
444pub async fn get_message(
446 http: &reqwest::Client,
447 base_url: &str,
448 access_token: &str,
449 account: &str,
450 message_id: &str,
451) -> Result<pidge_core::FullMessage, ClientError> {
452 let url = format!(
453 "{base_url}/me/messages/{message_id}\
454 ?$select=id,subject,from,toRecipients,ccRecipients,bccRecipients,\
455receivedDateTime,sentDateTime,isRead,body,hasAttachments,flag,conversationId"
456 );
457 let resp = super::send_with_retry(http.get(&url).bearer_auth(access_token)).await?;
458 let status = resp.status();
459 if !status.is_success() {
460 let text = resp.text().await.unwrap_or_default();
461 return Err(ClientError::Graph {
462 status: status.as_u16(),
463 message: text,
464 });
465 }
466 let g: GraphFullMessage = resp.json().await?;
467
468 fn from(addr: GraphFromAddress) -> pidge_core::MessageFrom {
469 pidge_core::MessageFrom {
470 name: addr.name.unwrap_or_default(),
471 address: addr.address.unwrap_or_default(),
472 }
473 }
474 fn unwrap_recipients(rs: Vec<GraphFromWrapper>) -> Vec<pidge_core::MessageFrom> {
475 rs.into_iter().map(|w| from(w.email_address)).collect()
476 }
477
478 let content_type = match g.body.content_type.to_lowercase().as_str() {
479 "html" => pidge_core::BodyContentType::Html,
480 _ => pidge_core::BodyContentType::Text,
481 };
482
483 Ok(pidge_core::FullMessage {
484 account: account.to_string(),
485 id: g.id,
486 conversation_id: g.conversation_id.unwrap_or_default(),
487 from: g
488 .from
489 .map(|w| from(w.email_address))
490 .unwrap_or_else(|| pidge_core::MessageFrom {
491 name: String::new(),
492 address: String::new(),
493 }),
494 to: unwrap_recipients(g.to_recipients),
495 cc: unwrap_recipients(g.cc_recipients),
496 bcc: unwrap_recipients(g.bcc_recipients),
497 subject: g.subject.unwrap_or_default(),
498 received_at: g.received_date_time,
499 sent_at: g.sent_date_time,
500 is_read: g.is_read.unwrap_or(true),
501 body_content_type: content_type,
502 body_content: g.body.content,
503 has_attachments: g.has_attachments.unwrap_or(false),
504 flag_status: flag_status_from(g.flag),
505 })
506}
507
508pub async fn fetch_message_headers(
512 http: &reqwest::Client,
513 base_url: &str,
514 access_token: &str,
515 message_id: &str,
516) -> Result<Vec<(String, String)>, ClientError> {
517 let url = format!("{base_url}/me/messages/{message_id}?$select=internetMessageHeaders");
518 let resp = super::send_with_retry(http.get(&url).bearer_auth(access_token)).await?;
519 let status = resp.status();
520 if !status.is_success() {
521 let text = resp.text().await.unwrap_or_default();
522 return Err(ClientError::Graph {
523 status: status.as_u16(),
524 message: text,
525 });
526 }
527 let body: GraphHeadersResponse = resp.json().await?;
528 Ok(body
529 .internet_message_headers
530 .unwrap_or_default()
531 .into_iter()
532 .map(|h| (h.name, h.value))
533 .collect())
534}
535
536#[derive(serde::Deserialize)]
537struct GraphHeadersResponse {
538 #[serde(rename = "internetMessageHeaders", default)]
539 internet_message_headers: Option<Vec<GraphHeader>>,
540}
541
542#[derive(serde::Deserialize)]
543struct GraphHeader {
544 name: String,
545 value: String,
546}
547
548pub async fn list_attachments(
551 http: &reqwest::Client,
552 base_url: &str,
553 access_token: &str,
554 message_id: &str,
555) -> Result<Vec<pidge_core::Attachment>, ClientError> {
556 let url = format!(
563 "{base_url}/me/messages/{message_id}/attachments\
564 ?$select=id,name,contentType,size,isInline"
565 );
566 let resp = super::send_with_retry(http.get(&url).bearer_auth(access_token)).await?;
567 let status = resp.status();
568 if !status.is_success() {
569 let text = resp.text().await.unwrap_or_default();
570 return Err(ClientError::Graph {
571 status: status.as_u16(),
572 message: text,
573 });
574 }
575 let list: GraphAttachmentList = resp.json().await?;
576 Ok(list
577 .value
578 .into_iter()
579 .filter(|a| {
580 a.odata_type
581 .as_deref()
582 .map(|t| t == "#microsoft.graph.fileAttachment")
583 .unwrap_or(true)
584 })
585 .map(|a| pidge_core::Attachment {
586 id: a.id,
587 name: a.name.unwrap_or_default(),
588 content_type: a.content_type.unwrap_or_default(),
589 size_bytes: a.size.unwrap_or(0),
590 is_inline: a.is_inline.unwrap_or(false),
591 content_id: a.content_id,
592 })
593 .collect())
594}
595
596pub async fn get_attachment_bytes(
599 http: &reqwest::Client,
600 base_url: &str,
601 access_token: &str,
602 message_id: &str,
603 attachment_id: &str,
604) -> Result<Vec<u8>, ClientError> {
605 use base64::Engine;
606 use base64::engine::general_purpose::STANDARD;
607
608 let url = format!("{base_url}/me/messages/{message_id}/attachments/{attachment_id}");
609 let resp = super::send_with_retry(http.get(&url).bearer_auth(access_token)).await?;
610 let status = resp.status();
611 if !status.is_success() {
612 let text = resp.text().await.unwrap_or_default();
613 return Err(ClientError::Graph {
614 status: status.as_u16(),
615 message: text,
616 });
617 }
618 let g: GraphAttachment = resp.json().await?;
619 let b64 = g.content_bytes.ok_or_else(|| ClientError::Graph {
620 status: 200,
621 message: "attachment response missing contentBytes".to_string(),
622 })?;
623 STANDARD.decode(&b64).map_err(|e| ClientError::Graph {
624 status: 200,
625 message: format!("attachment base64 decode: {e}"),
626 })
627}
628
629pub async fn mark_read(
631 http: &reqwest::Client,
632 base_url: &str,
633 access_token: &str,
634 message_id: &str,
635) -> Result<(), ClientError> {
636 patch_message(
637 http,
638 base_url,
639 access_token,
640 message_id,
641 &serde_json::json!({ "isRead": true }),
642 )
643 .await
644}
645
646pub async fn mark_unread(
648 http: &reqwest::Client,
649 base_url: &str,
650 access_token: &str,
651 message_id: &str,
652) -> Result<(), ClientError> {
653 patch_message(
654 http,
655 base_url,
656 access_token,
657 message_id,
658 &serde_json::json!({ "isRead": false }),
659 )
660 .await
661}
662
663pub async fn set_flag(
665 http: &reqwest::Client,
666 base_url: &str,
667 access_token: &str,
668 message_id: &str,
669 flagged: bool,
670) -> Result<(), ClientError> {
671 let status = if flagged { "flagged" } else { "notFlagged" };
672 patch_message(
673 http,
674 base_url,
675 access_token,
676 message_id,
677 &serde_json::json!({ "flag": { "flagStatus": status } }),
678 )
679 .await
680}
681
682#[derive(serde::Deserialize)]
683struct GraphCategories {
684 #[serde(default)]
685 categories: Vec<String>,
686}
687
688pub async fn get_categories(
690 http: &reqwest::Client,
691 base_url: &str,
692 access_token: &str,
693 message_id: &str,
694) -> Result<Vec<String>, ClientError> {
695 let url = format!("{base_url}/me/messages/{message_id}?$select=categories");
696 let resp = super::send_with_retry(http.get(&url).bearer_auth(access_token)).await?;
697 let status = resp.status();
698 if !status.is_success() {
699 let text = resp.text().await.unwrap_or_default();
700 return Err(ClientError::Graph {
701 status: status.as_u16(),
702 message: text,
703 });
704 }
705 let body: GraphCategories = resp.json().await?;
706 Ok(body.categories)
707}
708
709pub async fn set_categories(
711 http: &reqwest::Client,
712 base_url: &str,
713 access_token: &str,
714 message_id: &str,
715 categories: &[String],
716) -> Result<(), ClientError> {
717 patch_message(
718 http,
719 base_url,
720 access_token,
721 message_id,
722 &serde_json::json!({ "categories": categories }),
723 )
724 .await
725}
726
727async fn patch_message(
728 http: &reqwest::Client,
729 base_url: &str,
730 access_token: &str,
731 message_id: &str,
732 body: &serde_json::Value,
733) -> Result<(), ClientError> {
734 let url = format!("{base_url}/me/messages/{message_id}");
735 let resp =
736 super::send_with_retry(http.patch(&url).bearer_auth(access_token).json(body)).await?;
737 let status = resp.status();
738 if !status.is_success() {
739 let text = resp.text().await.unwrap_or_default();
740 return Err(ClientError::Graph {
741 status: status.as_u16(),
742 message: text,
743 });
744 }
745 Ok(())
746}
747
748pub async fn send_mail(
754 http: &reqwest::Client,
755 base_url: &str,
756 access_token: &str,
757 message: &Outgoing,
758) -> Result<(), ClientError> {
759 let url = format!("{base_url}/me/sendMail");
760 let body = serde_json::json!({
761 "message": message.to_graph_json(),
762 "saveToSentItems": true,
763 });
764 post_no_body(http, &url, access_token, &body).await
765}
766
767fn text_to_html(text: &str) -> String {
777 fn escape(s: &str) -> String {
778 s.replace('&', "&")
779 .replace('<', "<")
780 .replace('>', ">")
781 .replace('"', """)
782 }
783 let normalized = text.replace("\r\n", "\n").replace('\r', "\n");
784 normalized
785 .split("\n\n")
786 .filter(|p| !p.trim().is_empty())
787 .map(|paragraph| {
788 let lines: Vec<String> = paragraph
789 .trim_matches('\n')
790 .split('\n')
791 .map(escape)
792 .collect();
793 format!("<p>{}</p>", lines.join("<br>"))
794 })
795 .collect::<Vec<_>>()
796 .join("")
797}
798
799#[derive(Debug, Deserialize)]
800struct GraphBodyOnly {
801 body: GraphBody,
802}
803
804async fn prepend_html_to_draft(
808 http: &reqwest::Client,
809 base_url: &str,
810 access_token: &str,
811 message_id: &str,
812 html_to_prepend: &str,
813) -> Result<(), ClientError> {
814 let get_url = format!("{base_url}/me/messages/{message_id}?$select=body");
815 let resp = super::send_with_retry(http.get(&get_url).bearer_auth(access_token)).await?;
816 let status = resp.status();
817 if !status.is_success() {
818 let text = resp.text().await.unwrap_or_default();
819 return Err(ClientError::Graph {
820 status: status.as_u16(),
821 message: text,
822 });
823 }
824 let existing: GraphBodyOnly = resp.json().await?;
825 let existing_content = existing.body.content;
826
827 let new_content = match find_body_tag_end(&existing_content) {
831 Some(pos) => {
832 let mut s = String::with_capacity(existing_content.len() + html_to_prepend.len());
833 s.push_str(&existing_content[..pos]);
834 s.push_str(html_to_prepend);
835 s.push_str(&existing_content[pos..]);
836 s
837 }
838 None => format!("{html_to_prepend}{existing_content}"),
839 };
840
841 let patch_url = format!("{base_url}/me/messages/{message_id}");
842 let patch_body = serde_json::json!({
843 "body": {
844 "contentType": "HTML",
845 "content": new_content,
846 }
847 });
848 let resp = super::send_with_retry(
849 http.patch(&patch_url)
850 .bearer_auth(access_token)
851 .json(&patch_body),
852 )
853 .await?;
854 let status = resp.status();
855 if !status.is_success() {
856 let text = resp.text().await.unwrap_or_default();
857 return Err(ClientError::Graph {
858 status: status.as_u16(),
859 message: text,
860 });
861 }
862 Ok(())
863}
864
865fn find_body_tag_end(html: &str) -> Option<usize> {
866 let lc = html.to_ascii_lowercase();
867 let start = lc.find("<body")?;
868 let after = &html[start..];
869 let close_rel = after.find('>')?;
870 Some(start + close_rel + 1)
871}
872
873pub async fn reply_message(
877 http: &reqwest::Client,
878 base_url: &str,
879 access_token: &str,
880 message_id: &str,
881 comment: &str,
882) -> Result<(), ClientError> {
883 let draft_id = create_reply_draft(http, base_url, access_token, message_id, comment).await?;
884 send_draft(http, base_url, access_token, &draft_id).await
885}
886
887pub async fn reply_all_message(
889 http: &reqwest::Client,
890 base_url: &str,
891 access_token: &str,
892 message_id: &str,
893 comment: &str,
894) -> Result<(), ClientError> {
895 let draft_id =
896 create_reply_all_draft(http, base_url, access_token, message_id, comment).await?;
897 send_draft(http, base_url, access_token, &draft_id).await
898}
899
900pub async fn forward_message(
902 http: &reqwest::Client,
903 base_url: &str,
904 access_token: &str,
905 message_id: &str,
906 to: &[String],
907 comment: &str,
908) -> Result<(), ClientError> {
909 let draft_id =
910 create_forward_draft(http, base_url, access_token, message_id, to, comment).await?;
911 send_draft(http, base_url, access_token, &draft_id).await
912}
913
914async fn post_no_body(
915 http: &reqwest::Client,
916 url: &str,
917 access_token: &str,
918 body: &serde_json::Value,
919) -> Result<(), ClientError> {
920 let resp = super::send_with_retry(http.post(url).bearer_auth(access_token).json(body)).await?;
921 let status = resp.status();
922 if !status.is_success() {
923 let text = resp.text().await.unwrap_or_default();
924 return Err(ClientError::Graph {
925 status: status.as_u16(),
926 message: text,
927 });
928 }
929 Ok(())
930}
931
932pub async fn create_draft(
935 http: &reqwest::Client,
936 base_url: &str,
937 access_token: &str,
938 message: &Outgoing,
939) -> Result<String, ClientError> {
940 let url = format!("{base_url}/me/messages");
941 let body = message.to_graph_json();
942 let resp =
943 super::send_with_retry(http.post(&url).bearer_auth(access_token).json(&body)).await?;
944 parse_id_from_response(resp).await
945}
946
947pub async fn create_reply_draft(
954 http: &reqwest::Client,
955 base_url: &str,
956 access_token: &str,
957 message_id: &str,
958 comment: &str,
959) -> Result<String, ClientError> {
960 let url = format!("{base_url}/me/messages/{message_id}/createReply");
961 let resp = super::send_with_retry(
962 http.post(&url)
963 .bearer_auth(access_token)
964 .json(&serde_json::json!({})),
965 )
966 .await?;
967 let draft_id = parse_id_from_response(resp).await?;
968 if !comment.is_empty() {
969 prepend_html_to_draft(
970 http,
971 base_url,
972 access_token,
973 &draft_id,
974 &text_to_html(comment),
975 )
976 .await?;
977 }
978 Ok(draft_id)
979}
980
981pub async fn create_reply_all_draft(
983 http: &reqwest::Client,
984 base_url: &str,
985 access_token: &str,
986 message_id: &str,
987 comment: &str,
988) -> Result<String, ClientError> {
989 let url = format!("{base_url}/me/messages/{message_id}/createReplyAll");
990 let resp = super::send_with_retry(
991 http.post(&url)
992 .bearer_auth(access_token)
993 .json(&serde_json::json!({})),
994 )
995 .await?;
996 let draft_id = parse_id_from_response(resp).await?;
997 if !comment.is_empty() {
998 prepend_html_to_draft(
999 http,
1000 base_url,
1001 access_token,
1002 &draft_id,
1003 &text_to_html(comment),
1004 )
1005 .await?;
1006 }
1007 Ok(draft_id)
1008}
1009
1010pub async fn create_forward_draft(
1013 http: &reqwest::Client,
1014 base_url: &str,
1015 access_token: &str,
1016 message_id: &str,
1017 to: &[String],
1018 comment: &str,
1019) -> Result<String, ClientError> {
1020 let url = format!("{base_url}/me/messages/{message_id}/createForward");
1021 let resp = super::send_with_retry(http.post(&url).bearer_auth(access_token).json(
1022 &serde_json::json!({
1023 "toRecipients": to.iter().map(|addr| serde_json::json!({
1024 "emailAddress": { "address": addr }
1025 })).collect::<Vec<_>>(),
1026 }),
1027 ))
1028 .await?;
1029 let draft_id = parse_id_from_response(resp).await?;
1030 if !comment.is_empty() {
1031 prepend_html_to_draft(
1032 http,
1033 base_url,
1034 access_token,
1035 &draft_id,
1036 &text_to_html(comment),
1037 )
1038 .await?;
1039 }
1040 Ok(draft_id)
1041}
1042
1043pub async fn send_draft(
1045 http: &reqwest::Client,
1046 base_url: &str,
1047 access_token: &str,
1048 message_id: &str,
1049) -> Result<(), ClientError> {
1050 let url = format!("{base_url}/me/messages/{message_id}/send");
1051 let resp = super::send_with_retry(
1056 http.post(&url)
1057 .bearer_auth(access_token)
1058 .header(reqwest::header::CONTENT_LENGTH, 0)
1059 .body(reqwest::Body::from("")),
1060 )
1061 .await?;
1062 let status = resp.status();
1063 if !status.is_success() {
1064 let text = resp.text().await.unwrap_or_default();
1065 return Err(ClientError::Graph {
1066 status: status.as_u16(),
1067 message: text,
1068 });
1069 }
1070 Ok(())
1071}
1072
1073pub async fn update_draft(
1076 http: &reqwest::Client,
1077 base_url: &str,
1078 access_token: &str,
1079 message_id: &str,
1080 message: &Outgoing,
1081) -> Result<(), ClientError> {
1082 let url = format!("{base_url}/me/messages/{message_id}");
1083 let body = message.to_graph_json();
1084 let resp =
1085 super::send_with_retry(http.patch(&url).bearer_auth(access_token).json(&body)).await?;
1086 let status = resp.status();
1087 if !status.is_success() {
1088 let text = resp.text().await.unwrap_or_default();
1089 return Err(ClientError::Graph {
1090 status: status.as_u16(),
1091 message: text,
1092 });
1093 }
1094 Ok(())
1095}
1096
1097pub async fn delete_message(
1101 http: &reqwest::Client,
1102 base_url: &str,
1103 access_token: &str,
1104 message_id: &str,
1105) -> Result<(), ClientError> {
1106 let url = format!("{base_url}/me/messages/{message_id}");
1107 let resp = super::send_with_retry(http.delete(&url).bearer_auth(access_token)).await?;
1108 let status = resp.status();
1109 if !status.is_success() {
1110 let text = resp.text().await.unwrap_or_default();
1111 return Err(ClientError::Graph {
1112 status: status.as_u16(),
1113 message: text,
1114 });
1115 }
1116 Ok(())
1117}
1118
1119pub async fn add_attachment(
1125 http: &reqwest::Client,
1126 base_url: &str,
1127 access_token: &str,
1128 message_id: &str,
1129 name: &str,
1130 content_type: &str,
1131 bytes: &[u8],
1132) -> Result<String, ClientError> {
1133 use base64::Engine;
1134 use base64::engine::general_purpose::STANDARD;
1135
1136 let url = format!("{base_url}/me/messages/{message_id}/attachments");
1137 let body = serde_json::json!({
1138 "@odata.type": "#microsoft.graph.fileAttachment",
1139 "name": name,
1140 "contentType": content_type,
1141 "contentBytes": STANDARD.encode(bytes),
1142 });
1143 let resp =
1144 super::send_with_retry(http.post(&url).bearer_auth(access_token).json(&body)).await?;
1145 parse_id_from_response(resp).await
1146}
1147
1148pub async fn delete_attachment(
1150 http: &reqwest::Client,
1151 base_url: &str,
1152 access_token: &str,
1153 message_id: &str,
1154 attachment_id: &str,
1155) -> Result<(), ClientError> {
1156 let url = format!("{base_url}/me/messages/{message_id}/attachments/{attachment_id}");
1157 let resp = super::send_with_retry(http.delete(&url).bearer_auth(access_token)).await?;
1158 let status = resp.status();
1159 if !status.is_success() {
1160 let text = resp.text().await.unwrap_or_default();
1161 return Err(ClientError::Graph {
1162 status: status.as_u16(),
1163 message: text,
1164 });
1165 }
1166 Ok(())
1167}
1168
1169async fn parse_id_from_response(resp: reqwest::Response) -> Result<String, ClientError> {
1170 let status = resp.status();
1171 if !status.is_success() {
1172 let text = resp.text().await.unwrap_or_default();
1173 return Err(ClientError::Graph {
1174 status: status.as_u16(),
1175 message: text,
1176 });
1177 }
1178 let v: serde_json::Value = resp.json().await?;
1179 v["id"]
1180 .as_str()
1181 .map(|s| s.to_string())
1182 .ok_or_else(|| ClientError::Graph {
1183 status: 200,
1184 message: "draft response missing 'id'".to_string(),
1185 })
1186}
1187
1188#[derive(Debug, Clone)]
1190pub struct Outgoing {
1191 pub subject: String,
1192 pub body_text: String,
1193 pub to: Vec<String>,
1194 pub cc: Vec<String>,
1195 pub bcc: Vec<String>,
1196}
1197
1198impl Outgoing {
1199 fn to_graph_json(&self) -> serde_json::Value {
1200 fn addresses(list: &[String]) -> Vec<serde_json::Value> {
1201 list.iter()
1202 .map(|addr| serde_json::json!({ "emailAddress": { "address": addr } }))
1203 .collect()
1204 }
1205 serde_json::json!({
1206 "subject": self.subject,
1207 "body": {
1208 "contentType": "Text",
1209 "content": self.body_text,
1210 },
1211 "toRecipients": addresses(&self.to),
1212 "ccRecipients": addresses(&self.cc),
1213 "bccRecipients": addresses(&self.bcc),
1214 })
1215 }
1216}
1217
1218pub async fn move_message(
1225 http: &reqwest::Client,
1226 base_url: &str,
1227 access_token: &str,
1228 message_id: &str,
1229 destination: &str,
1230) -> Result<(), ClientError> {
1231 let url = format!("{base_url}/me/messages/{message_id}/move");
1232 let resp = super::send_with_retry(
1233 http.post(&url)
1234 .bearer_auth(access_token)
1235 .json(&serde_json::json!({ "destinationId": destination })),
1236 )
1237 .await?;
1238 let status = resp.status();
1239 if !status.is_success() {
1240 let text = resp.text().await.unwrap_or_default();
1241 return Err(ClientError::Graph {
1242 status: status.as_u16(),
1243 message: text,
1244 });
1245 }
1246 Ok(())
1247}
1248
1249#[derive(Debug, Clone, Deserialize)]
1251pub struct MailFolder {
1252 pub id: String,
1253 #[serde(rename = "displayName")]
1254 pub display_name: String,
1255 #[serde(rename = "totalItemCount", default)]
1257 pub total_item_count: Option<u64>,
1258 #[serde(rename = "unreadItemCount", default)]
1260 pub unread_item_count: Option<u64>,
1261 #[serde(rename = "childFolderCount", default)]
1264 pub child_folder_count: Option<u64>,
1265}
1266
1267#[derive(Debug, Deserialize)]
1268struct GraphFolderList {
1269 value: Vec<MailFolder>,
1270 #[serde(rename = "@odata.nextLink", default)]
1271 next_link: Option<String>,
1272}
1273
1274const FOLDER_SELECT: &str = "id,displayName,totalItemCount,unreadItemCount,childFolderCount";
1277
1278async fn fetch_folder_pages(
1281 http: &reqwest::Client,
1282 access_token: &str,
1283 mut url: String,
1284) -> Result<Vec<MailFolder>, ClientError> {
1285 let mut folders: Vec<MailFolder> = Vec::new();
1286 loop {
1287 let resp = super::send_with_retry(http.get(&url).bearer_auth(access_token)).await?;
1288 let status = resp.status();
1289 if !status.is_success() {
1290 let text = resp.text().await.unwrap_or_default();
1291 return Err(ClientError::Graph {
1292 status: status.as_u16(),
1293 message: text,
1294 });
1295 }
1296 let list: GraphFolderList = resp.json().await?;
1297 folders.extend(list.value);
1298 match list.next_link {
1301 Some(next) => url = next,
1302 None => break,
1303 }
1304 }
1305 Ok(folders)
1306}
1307
1308pub async fn list_mail_folders(
1312 http: &reqwest::Client,
1313 base_url: &str,
1314 access_token: &str,
1315) -> Result<Vec<MailFolder>, ClientError> {
1316 let url = format!("{base_url}/me/mailFolders?$select={FOLDER_SELECT}&$top=100");
1317 fetch_folder_pages(http, access_token, url).await
1318}
1319
1320pub async fn list_child_folders(
1323 http: &reqwest::Client,
1324 base_url: &str,
1325 access_token: &str,
1326 parent_id: &str,
1327) -> Result<Vec<MailFolder>, ClientError> {
1328 let url = format!(
1329 "{base_url}/me/mailFolders/{parent_id}/childFolders?$select={FOLDER_SELECT}&$top=100"
1330 );
1331 fetch_folder_pages(http, access_token, url).await
1332}
1333
1334async fn post_folder(
1335 http: &reqwest::Client,
1336 url: &str,
1337 access_token: &str,
1338 display_name: &str,
1339) -> Result<MailFolder, ClientError> {
1340 let resp = super::send_with_retry(
1341 http.post(url)
1342 .bearer_auth(access_token)
1343 .json(&serde_json::json!({ "displayName": display_name })),
1344 )
1345 .await?;
1346 let status = resp.status();
1347 if !status.is_success() {
1348 let text = resp.text().await.unwrap_or_default();
1349 return Err(ClientError::Graph {
1350 status: status.as_u16(),
1351 message: text,
1352 });
1353 }
1354 let folder: MailFolder = resp.json().await?;
1355 Ok(folder)
1356}
1357
1358pub async fn create_mail_folder(
1364 http: &reqwest::Client,
1365 base_url: &str,
1366 access_token: &str,
1367 display_name: &str,
1368) -> Result<MailFolder, ClientError> {
1369 let url = format!("{base_url}/me/mailFolders");
1370 post_folder(http, &url, access_token, display_name).await
1371}
1372
1373pub async fn create_child_folder(
1376 http: &reqwest::Client,
1377 base_url: &str,
1378 access_token: &str,
1379 parent_id: &str,
1380 display_name: &str,
1381) -> Result<MailFolder, ClientError> {
1382 let url = format!("{base_url}/me/mailFolders/{parent_id}/childFolders");
1383 post_folder(http, &url, access_token, display_name).await
1384}
1385
1386pub async fn delete_mail_folder(
1390 http: &reqwest::Client,
1391 base_url: &str,
1392 access_token: &str,
1393 folder_id: &str,
1394) -> Result<(), ClientError> {
1395 let url = format!("{base_url}/me/mailFolders/{folder_id}");
1396 let resp = super::send_with_retry(http.delete(&url).bearer_auth(access_token)).await?;
1397 let status = resp.status();
1398 if !status.is_success() {
1399 let text = resp.text().await.unwrap_or_default();
1400 return Err(ClientError::Graph {
1401 status: status.as_u16(),
1402 message: text,
1403 });
1404 }
1405 Ok(())
1406}
1407
1408#[cfg(test)]
1409mod tests {
1410 use super::*;
1411 use wiremock::matchers::{header, method, path, path_regex, query_param};
1412 use wiremock::{Mock, MockServer, ResponseTemplate};
1413
1414 #[tokio::test]
1415 async fn list_inbox_parses_graph_response() {
1416 let server = MockServer::start().await;
1417 Mock::given(method("GET"))
1418 .and(path("/me/mailFolders/inbox/messages"))
1419 .and(header("authorization", "Bearer AT"))
1420 .and(query_param("$top", "5"))
1421 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1422 "value": [
1423 {
1424 "id": "AAAA",
1425 "subject": "Hello",
1426 "from": {
1427 "emailAddress": {
1428 "name": "Maria",
1429 "address": "maria@mklab.se"
1430 }
1431 },
1432 "receivedDateTime": "2026-05-13T22:00:00Z",
1433 "isRead": false,
1434 "bodyPreview": "Hi there"
1435 }
1436 ]
1437 })))
1438 .mount(&server)
1439 .await;
1440
1441 let http = reqwest::Client::new();
1442 let page = list_inbox(&http, &server.uri(), "AT", "u@e.com", 5, 0, false)
1443 .await
1444 .unwrap();
1445 assert_eq!(page.messages.len(), 1);
1446 assert_eq!(page.messages[0].subject, "Hello");
1447 assert_eq!(page.messages[0].from.address, "maria@mklab.se");
1448 assert!(!page.messages[0].is_read);
1449 assert_eq!(page.messages[0].account, "u@e.com");
1450 assert!(!page.has_more);
1451 }
1452
1453 #[tokio::test]
1454 async fn list_inbox_adds_filter_when_unread_only() {
1455 let server = MockServer::start().await;
1456 Mock::given(method("GET"))
1457 .and(path("/me/mailFolders/inbox/messages"))
1458 .and(query_param("$filter", "isRead eq false"))
1459 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1460 "value": []
1461 })))
1462 .mount(&server)
1463 .await;
1464
1465 let http = reqwest::Client::new();
1466 let page = list_inbox(&http, &server.uri(), "AT", "u@e.com", 5, 0, true)
1467 .await
1468 .unwrap();
1469 assert!(page.messages.is_empty());
1470 }
1471
1472 #[tokio::test]
1473 async fn list_inbox_passes_skip_when_nonzero() {
1474 let server = MockServer::start().await;
1475 Mock::given(method("GET"))
1476 .and(path("/me/mailFolders/inbox/messages"))
1477 .and(query_param("$skip", "25"))
1478 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1479 "value": [],
1480 "@odata.nextLink": "https://graph.example/next"
1481 })))
1482 .mount(&server)
1483 .await;
1484
1485 let http = reqwest::Client::new();
1486 let page = list_inbox(&http, &server.uri(), "AT", "u@e.com", 25, 25, false)
1487 .await
1488 .unwrap();
1489 assert!(page.has_more);
1490 }
1491
1492 #[tokio::test]
1493 async fn search_messages_passes_search_query() {
1494 let server = MockServer::start().await;
1495 Mock::given(method("GET"))
1496 .and(path("/me/messages"))
1497 .and(query_param("$search", "\"alice budget\""))
1498 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1499 "value": [
1500 {
1501 "id": "S1",
1502 "subject": "Q4 budget review",
1503 "from": { "emailAddress": { "name": "Alice", "address": "alice@example.com" } },
1504 "receivedDateTime": "2026-05-13T22:00:00Z",
1505 "isRead": true,
1506 "bodyPreview": "Numbers attached"
1507 }
1508 ]
1509 })))
1510 .mount(&server)
1511 .await;
1512
1513 let http = reqwest::Client::new();
1514 let msgs = search_messages(&http, &server.uri(), "AT", "u@e.com", "alice budget", 25)
1515 .await
1516 .unwrap()
1517 .messages;
1518 assert_eq!(msgs.len(), 1);
1519 assert_eq!(msgs[0].subject, "Q4 budget review");
1520 }
1521
1522 #[tokio::test]
1523 async fn mark_unread_patches_isread_false() {
1524 let server = MockServer::start().await;
1525 Mock::given(method("PATCH"))
1526 .and(path_regex("/me/messages/[A-Za-z0-9]+"))
1527 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
1528 .mount(&server)
1529 .await;
1530 let http = reqwest::Client::new();
1531 mark_unread(&http, &server.uri(), "AT", "MSG")
1532 .await
1533 .unwrap();
1534 }
1535
1536 #[tokio::test]
1537 async fn set_flag_patches_flag_status() {
1538 let server = MockServer::start().await;
1539 Mock::given(method("PATCH"))
1540 .and(path_regex("/me/messages/[A-Za-z0-9]+"))
1541 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
1542 .mount(&server)
1543 .await;
1544 let http = reqwest::Client::new();
1545 set_flag(&http, &server.uri(), "AT", "MSG", true)
1546 .await
1547 .unwrap();
1548 set_flag(&http, &server.uri(), "AT", "MSG", false)
1549 .await
1550 .unwrap();
1551 }
1552
1553 #[tokio::test]
1554 async fn send_mail_wraps_outgoing_in_message_envelope() {
1555 use wiremock::matchers::body_partial_json;
1556 let server = MockServer::start().await;
1557 Mock::given(method("POST"))
1558 .and(path("/me/sendMail"))
1559 .and(body_partial_json(serde_json::json!({
1560 "saveToSentItems": true,
1561 "message": {
1562 "subject": "Hello",
1563 "body": { "contentType": "Text", "content": "Hi there" },
1564 "toRecipients": [{ "emailAddress": { "address": "alice@example.com" } }]
1565 }
1566 })))
1567 .respond_with(ResponseTemplate::new(202))
1568 .mount(&server)
1569 .await;
1570 let http = reqwest::Client::new();
1571 let msg = Outgoing {
1572 subject: "Hello".into(),
1573 body_text: "Hi there".into(),
1574 to: vec!["alice@example.com".into()],
1575 cc: vec![],
1576 bcc: vec![],
1577 };
1578 send_mail(&http, &server.uri(), "AT", &msg).await.unwrap();
1579 }
1580
1581 #[tokio::test]
1582 async fn reply_message_creates_draft_patches_body_and_sends() {
1583 use wiremock::matchers::body_partial_json;
1584 let server = MockServer::start().await;
1585
1586 Mock::given(method("POST"))
1588 .and(path_regex("/me/messages/MSG/createReply"))
1589 .respond_with(
1590 ResponseTemplate::new(201).set_body_json(serde_json::json!({ "id": "DRAFT" })),
1591 )
1592 .mount(&server)
1593 .await;
1594 Mock::given(method("GET"))
1596 .and(path("/me/messages/DRAFT"))
1597 .and(query_param("$select", "body"))
1598 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1599 "body": { "contentType": "HTML", "content": "<html><body><div></div></body></html>" }
1600 })))
1601 .mount(&server)
1602 .await;
1603 Mock::given(method("PATCH"))
1605 .and(path("/me/messages/DRAFT"))
1606 .and(body_partial_json(serde_json::json!({
1607 "body": { "contentType": "HTML" }
1608 })))
1609 .respond_with(ResponseTemplate::new(200))
1610 .mount(&server)
1611 .await;
1612 Mock::given(method("POST"))
1614 .and(path("/me/messages/DRAFT/send"))
1615 .respond_with(ResponseTemplate::new(202))
1616 .mount(&server)
1617 .await;
1618
1619 let http = reqwest::Client::new();
1620 reply_message(&http, &server.uri(), "AT", "MSG", "Thanks!")
1621 .await
1622 .unwrap();
1623 }
1624
1625 #[tokio::test]
1626 async fn forward_message_creates_draft_with_recipients_and_sends() {
1627 use wiremock::matchers::body_partial_json;
1628 let server = MockServer::start().await;
1629
1630 Mock::given(method("POST"))
1631 .and(path_regex("/me/messages/MSG/createForward"))
1632 .and(body_partial_json(serde_json::json!({
1633 "toRecipients": [{ "emailAddress": { "address": "bob@example.com" } }]
1634 })))
1635 .respond_with(
1636 ResponseTemplate::new(201).set_body_json(serde_json::json!({ "id": "DRAFT" })),
1637 )
1638 .mount(&server)
1639 .await;
1640 Mock::given(method("GET"))
1641 .and(path("/me/messages/DRAFT"))
1642 .and(query_param("$select", "body"))
1643 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1644 "body": { "contentType": "HTML", "content": "<html><body></body></html>" }
1645 })))
1646 .mount(&server)
1647 .await;
1648 Mock::given(method("PATCH"))
1649 .and(path("/me/messages/DRAFT"))
1650 .respond_with(ResponseTemplate::new(200))
1651 .mount(&server)
1652 .await;
1653 Mock::given(method("POST"))
1654 .and(path("/me/messages/DRAFT/send"))
1655 .respond_with(ResponseTemplate::new(202))
1656 .mount(&server)
1657 .await;
1658
1659 let http = reqwest::Client::new();
1660 forward_message(
1661 &http,
1662 &server.uri(),
1663 "AT",
1664 "MSG",
1665 &["bob@example.com".into()],
1666 "FYI",
1667 )
1668 .await
1669 .unwrap();
1670 }
1671
1672 #[test]
1673 fn text_to_html_escapes_and_breaks_paragraphs() {
1674 let out = text_to_html("Hej Edward,\n\nLine 1\nLine 2\n\n<script>x</script>");
1675 assert!(out.contains("<p>Hej Edward,</p>"));
1676 assert!(out.contains("<p>Line 1<br>Line 2</p>"));
1677 assert!(out.contains("<script>x</script>"));
1678 assert!(!out.contains("<script>"));
1679 }
1680
1681 #[test]
1682 fn text_to_html_normalizes_crlf() {
1683 let out = text_to_html("A\r\nB\r\n\r\nC");
1684 assert!(out.contains("<p>A<br>B</p>"));
1685 assert!(out.contains("<p>C</p>"));
1686 }
1687
1688 #[test]
1689 fn find_body_tag_end_handles_attributes_and_case() {
1690 let html = "<html><BODY class=\"x\">content</BODY></html>";
1691 let pos = find_body_tag_end(html).unwrap();
1692 assert_eq!(&html[pos..pos + 7], "content");
1693 }
1694
1695 #[tokio::test]
1696 async fn list_mail_folders_parses_and_pages() {
1697 let server = MockServer::start().await;
1698 Mock::given(method("GET"))
1700 .and(path("/me/mailFolders"))
1701 .and(query_param("$top", "100"))
1702 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1703 "value": [
1704 { "id": "F1", "displayName": "Biljetter", "totalItemCount": 3, "unreadItemCount": 0 }
1705 ],
1706 "@odata.nextLink": format!("{}/me/mailFolders?page=2", server.uri())
1707 })))
1708 .mount(&server)
1709 .await;
1710 Mock::given(method("GET"))
1711 .and(path("/me/mailFolders"))
1712 .and(query_param("page", "2"))
1713 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1714 "value": [
1715 { "id": "F2", "displayName": "Kvitton", "totalItemCount": 7, "unreadItemCount": 2 }
1716 ]
1717 })))
1718 .mount(&server)
1719 .await;
1720
1721 let http = reqwest::Client::new();
1722 let folders = list_mail_folders(&http, &server.uri(), "AT").await.unwrap();
1723 assert_eq!(folders.len(), 2);
1724 assert_eq!(folders[0].display_name, "Biljetter");
1725 assert_eq!(folders[0].total_item_count, Some(3));
1726 assert_eq!(folders[1].display_name, "Kvitton");
1727 assert_eq!(folders[1].unread_item_count, Some(2));
1728 }
1729
1730 #[tokio::test]
1731 async fn create_mail_folder_posts_display_name() {
1732 use wiremock::matchers::body_partial_json;
1733 let server = MockServer::start().await;
1734 Mock::given(method("POST"))
1735 .and(path("/me/mailFolders"))
1736 .and(body_partial_json(
1737 serde_json::json!({ "displayName": "Biljetter" }),
1738 ))
1739 .respond_with(
1740 ResponseTemplate::new(201)
1741 .set_body_json(serde_json::json!({ "id": "NEWF", "displayName": "Biljetter" })),
1742 )
1743 .mount(&server)
1744 .await;
1745
1746 let http = reqwest::Client::new();
1747 let folder = create_mail_folder(&http, &server.uri(), "AT", "Biljetter")
1748 .await
1749 .unwrap();
1750 assert_eq!(folder.id, "NEWF");
1751 assert_eq!(folder.display_name, "Biljetter");
1752 }
1753
1754 #[tokio::test]
1755 async fn list_child_folders_hits_child_endpoint() {
1756 let server = MockServer::start().await;
1757 Mock::given(method("GET"))
1758 .and(path("/me/mailFolders/PARENT/childFolders"))
1759 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1760 "value": [
1761 { "id": "C1", "displayName": "MKLab", "totalItemCount": 5, "unreadItemCount": 0, "childFolderCount": 0 }
1762 ]
1763 })))
1764 .mount(&server)
1765 .await;
1766 let http = reqwest::Client::new();
1767 let children = list_child_folders(&http, &server.uri(), "AT", "PARENT")
1768 .await
1769 .unwrap();
1770 assert_eq!(children.len(), 1);
1771 assert_eq!(children[0].display_name, "MKLab");
1772 }
1773
1774 #[tokio::test]
1775 async fn create_child_folder_posts_to_parent() {
1776 use wiremock::matchers::body_partial_json;
1777 let server = MockServer::start().await;
1778 Mock::given(method("POST"))
1779 .and(path("/me/mailFolders/PARENT/childFolders"))
1780 .and(body_partial_json(
1781 serde_json::json!({ "displayName": "MKLab" }),
1782 ))
1783 .respond_with(
1784 ResponseTemplate::new(201)
1785 .set_body_json(serde_json::json!({ "id": "C9", "displayName": "MKLab" })),
1786 )
1787 .mount(&server)
1788 .await;
1789 let http = reqwest::Client::new();
1790 let folder = create_child_folder(&http, &server.uri(), "AT", "PARENT", "MKLab")
1791 .await
1792 .unwrap();
1793 assert_eq!(folder.id, "C9");
1794 }
1795
1796 #[tokio::test]
1797 async fn delete_mail_folder_hits_delete_endpoint() {
1798 let server = MockServer::start().await;
1799 Mock::given(method("DELETE"))
1800 .and(path("/me/mailFolders/F1"))
1801 .respond_with(ResponseTemplate::new(204))
1802 .mount(&server)
1803 .await;
1804 let http = reqwest::Client::new();
1805 delete_mail_folder(&http, &server.uri(), "AT", "F1")
1806 .await
1807 .unwrap();
1808 }
1809
1810 #[tokio::test]
1811 async fn get_categories_parses_field() {
1812 let server = MockServer::start().await;
1813 Mock::given(method("GET"))
1814 .and(path_regex(r"^/me/messages/.+$"))
1815 .and(query_param("$select", "categories"))
1816 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1817 "categories": ["Receipts", "Urgent"]
1818 })))
1819 .mount(&server)
1820 .await;
1821 let http = reqwest::Client::new();
1822 let cats = get_categories(&http, &server.uri(), "AT", "MSG")
1823 .await
1824 .unwrap();
1825 assert_eq!(cats, vec!["Receipts", "Urgent"]);
1826 }
1827
1828 #[tokio::test]
1829 async fn set_categories_patches_array() {
1830 use wiremock::matchers::body_partial_json;
1831 let server = MockServer::start().await;
1832 Mock::given(method("PATCH"))
1833 .and(path_regex(r"^/me/messages/.+$"))
1834 .and(body_partial_json(
1835 serde_json::json!({ "categories": ["receipt", "ticket"] }),
1836 ))
1837 .respond_with(ResponseTemplate::new(200))
1838 .mount(&server)
1839 .await;
1840 let http = reqwest::Client::new();
1841 set_categories(
1842 &http,
1843 &server.uri(),
1844 "AT",
1845 "MSG",
1846 &["receipt".to_string(), "ticket".to_string()],
1847 )
1848 .await
1849 .unwrap();
1850 }
1851
1852 #[tokio::test]
1853 async fn move_message_posts_destination() {
1854 let server = MockServer::start().await;
1855 Mock::given(method("POST"))
1856 .and(path_regex("/me/messages/[A-Za-z0-9]+/move"))
1857 .respond_with(
1858 ResponseTemplate::new(201).set_body_json(serde_json::json!({"id": "NEW"})),
1859 )
1860 .mount(&server)
1861 .await;
1862 let http = reqwest::Client::new();
1863 move_message(&http, &server.uri(), "AT", "MSG", "archive")
1864 .await
1865 .unwrap();
1866 }
1867
1868 #[tokio::test]
1869 async fn get_message_parses_graph_response() {
1870 let server = MockServer::start().await;
1871 Mock::given(method("GET"))
1872 .and(path_regex("/me/messages/[A-Za-z0-9]+"))
1873 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1874 "id": "AAA",
1875 "subject": "Hello",
1876 "from": { "emailAddress": { "name": "Maria", "address": "maria@mklab.se" } },
1877 "toRecipients": [
1878 { "emailAddress": { "name": "Kristofer", "address": "kristofer@mklab.se" } }
1879 ],
1880 "ccRecipients": [],
1881 "bccRecipients": [],
1882 "receivedDateTime": "2026-05-14T22:00:00Z",
1883 "sentDateTime": "2026-05-14T21:59:30Z",
1884 "isRead": false,
1885 "body": { "contentType": "html", "content": "<p>Hi</p>" },
1886 "hasAttachments": true
1887 })))
1888 .mount(&server)
1889 .await;
1890
1891 let http = reqwest::Client::new();
1892 let m = get_message(&http, &server.uri(), "AT", "u@e.com", "AAA")
1893 .await
1894 .unwrap();
1895 assert_eq!(m.id, "AAA");
1896 assert_eq!(m.subject, "Hello");
1897 assert_eq!(m.from.name, "Maria");
1898 assert_eq!(m.to.len(), 1);
1899 assert_eq!(m.to[0].address, "kristofer@mklab.se");
1900 assert!(matches!(
1901 m.body_content_type,
1902 pidge_core::BodyContentType::Html
1903 ));
1904 assert_eq!(m.body_content, "<p>Hi</p>");
1905 assert!(m.has_attachments);
1906 }
1907
1908 #[tokio::test]
1909 async fn list_attachments_filters_file_attachments() {
1910 let server = MockServer::start().await;
1911 Mock::given(method("GET"))
1912 .and(path_regex("/me/messages/[A-Za-z0-9]+/attachments"))
1913 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1914 "value": [
1915 {
1916 "@odata.type": "#microsoft.graph.fileAttachment",
1917 "id": "att-1",
1918 "name": "report.pdf",
1919 "contentType": "application/pdf",
1920 "size": 12345,
1921 "isInline": false
1922 },
1923 {
1924 "@odata.type": "#microsoft.graph.itemAttachment",
1925 "id": "att-2",
1926 "name": "an-email.eml",
1927 "contentType": "message/rfc822",
1928 "size": 7777,
1929 "isInline": false
1930 }
1931 ]
1932 })))
1933 .mount(&server)
1934 .await;
1935
1936 let http = reqwest::Client::new();
1937 let atts = list_attachments(&http, &server.uri(), "AT", "MSG")
1938 .await
1939 .unwrap();
1940 assert_eq!(atts.len(), 1);
1941 assert_eq!(atts[0].name, "report.pdf");
1942 assert_eq!(atts[0].size_bytes, 12345);
1943 }
1944
1945 #[tokio::test]
1946 async fn get_attachment_bytes_decodes_base64() {
1947 let server = MockServer::start().await;
1948 Mock::given(method("GET"))
1949 .and(path_regex(
1950 "/me/messages/[A-Za-z0-9]+/attachments/[A-Za-z0-9-]+",
1951 ))
1952 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1953 "id": "att-1",
1954 "name": "report.pdf",
1955 "contentType": "application/pdf",
1956 "size": 5,
1957 "isInline": false,
1958 "contentBytes": "aGVsbG8="
1959 })))
1960 .mount(&server)
1961 .await;
1962
1963 let http = reqwest::Client::new();
1964 let bytes = get_attachment_bytes(&http, &server.uri(), "AT", "MSG", "att-1")
1965 .await
1966 .unwrap();
1967 assert_eq!(bytes, b"hello");
1968 }
1969
1970 #[tokio::test]
1971 async fn mark_read_patches_isread_true() {
1972 let server = MockServer::start().await;
1973 Mock::given(method("PATCH"))
1974 .and(path_regex("/me/messages/[A-Za-z0-9]+"))
1975 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
1976 .mount(&server)
1977 .await;
1978
1979 let http = reqwest::Client::new();
1980 mark_read(&http, &server.uri(), "AT", "MSG").await.unwrap();
1981 }
1982
1983 #[tokio::test]
1984 async fn fetch_message_headers_parses_array() {
1985 let server = MockServer::start().await;
1986 Mock::given(method("GET"))
1987 .and(path_regex(r"^/me/messages/.+$"))
1988 .and(query_param("$select", "internetMessageHeaders"))
1989 .and(header("authorization", "Bearer AT"))
1990 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1991 "internetMessageHeaders": [
1992 { "name": "List-Unsubscribe", "value": "<mailto:u@x>, <https://x/u>" },
1993 { "name": "List-Unsubscribe-Post", "value": "List-Unsubscribe=One-Click" }
1994 ]
1995 })))
1996 .mount(&server)
1997 .await;
1998
1999 let http = reqwest::Client::new();
2000 let headers = fetch_message_headers(&http, &server.uri(), "AT", "MSGID")
2001 .await
2002 .unwrap();
2003 assert_eq!(headers.len(), 2);
2004 assert_eq!(headers[0].0, "List-Unsubscribe");
2005 assert_eq!(headers[0].1, "<mailto:u@x>, <https://x/u>");
2006 assert_eq!(headers[1].0, "List-Unsubscribe-Post");
2007 }
2008}
2009
2010#[cfg(test)]
2011mod cursor_paging_tests {
2012 use super::*;
2013 use wiremock::matchers::{method, path, query_param};
2014 use wiremock::{Mock, MockServer, ResponseTemplate};
2015
2016 fn msg(id: &str, received: &str) -> serde_json::Value {
2017 serde_json::json!({
2018 "id": id,
2019 "subject": format!("s-{id}"),
2020 "from": {"emailAddress": {"name": "N", "address": "n@x.se"}},
2021 "receivedDateTime": received,
2022 "isRead": true,
2023 "bodyPreview": "p",
2024 "hasAttachments": false
2025 })
2026 }
2027
2028 #[tokio::test]
2029 async fn next_link_is_surfaced_and_followable() {
2030 let server = MockServer::start().await;
2031 let page2_url = format!("{}/me/mailFolders/inbox/messages?page=2", server.uri());
2032 Mock::given(method("GET"))
2033 .and(path("/me/mailFolders/inbox/messages"))
2034 .and(query_param("page", "2"))
2035 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
2036 "value": [msg("m3", "2026-07-01T10:00:00Z")]
2037 })))
2038 .mount(&server)
2039 .await;
2040 Mock::given(method("GET"))
2041 .and(path("/me/mailFolders/inbox/messages"))
2042 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
2043 "value": [msg("m1", "2026-07-03T10:00:00Z"), msg("m2", "2026-07-02T10:00:00Z")],
2044 "@odata.nextLink": page2_url
2045 })))
2046 .mount(&server)
2047 .await;
2048
2049 let http = reqwest::Client::new();
2050 let page1 = list_inbox(&http, &server.uri(), "tok", "a@b.se", 2, 0, false)
2051 .await
2052 .unwrap();
2053 assert_eq!(page1.messages.len(), 2);
2054 let next = page1.next_link.expect("first page links onward");
2055
2056 let page2 = list_messages_at(&http, "tok", "a@b.se", &next)
2057 .await
2058 .unwrap();
2059 assert_eq!(page2.messages.len(), 1);
2060 assert_eq!(page2.messages[0].id, "m3");
2061 assert!(page2.next_link.is_none(), "final page has no continuation");
2062 }
2063}