1use pidge_core::{FlagStatus, Message, MessageFrom};
4use serde::Deserialize;
5
6use crate::error::ClientError;
7
8#[derive(Debug, Deserialize)]
9struct GraphMessage {
10 id: String,
11 subject: Option<String>,
12 from: Option<GraphFromWrapper>,
13 #[serde(rename = "receivedDateTime")]
14 received_date_time: chrono::DateTime<chrono::Utc>,
15 #[serde(rename = "isRead")]
16 is_read: Option<bool>,
17 #[serde(rename = "bodyPreview")]
18 body_preview: Option<String>,
19 body: Option<GraphBody>,
23 #[serde(rename = "hasAttachments")]
24 has_attachments: Option<bool>,
25 #[serde(default)]
26 flag: Option<GraphFlag>,
27}
28
29#[derive(Debug, Deserialize)]
30struct GraphFlag {
31 #[serde(rename = "flagStatus", default)]
32 flag_status: Option<String>,
33}
34
35fn flag_status_from(g: Option<GraphFlag>) -> FlagStatus {
36 match g.and_then(|f| f.flag_status).as_deref() {
37 Some("flagged") => FlagStatus::Flagged,
38 Some("complete") => FlagStatus::Complete,
39 _ => FlagStatus::NotFlagged,
40 }
41}
42
43#[derive(Debug, Deserialize)]
44struct GraphFromWrapper {
45 #[serde(rename = "emailAddress")]
46 email_address: GraphFromAddress,
47}
48
49#[derive(Debug, Deserialize)]
50struct GraphFromAddress {
51 name: Option<String>,
52 address: Option<String>,
53}
54
55#[derive(Debug, Deserialize)]
56struct GraphList {
57 value: Vec<GraphMessage>,
58 #[serde(rename = "@odata.nextLink", default)]
61 next_link: Option<String>,
62}
63
64pub struct InboxPage {
66 pub messages: Vec<Message>,
67 pub has_more: bool,
68}
69
70#[derive(Debug, Deserialize)]
71struct GraphFullMessage {
72 id: String,
73 subject: Option<String>,
74 from: Option<GraphFromWrapper>,
75 #[serde(rename = "toRecipients", default)]
76 to_recipients: Vec<GraphFromWrapper>,
77 #[serde(rename = "ccRecipients", default)]
78 cc_recipients: Vec<GraphFromWrapper>,
79 #[serde(rename = "bccRecipients", default)]
80 bcc_recipients: Vec<GraphFromWrapper>,
81 #[serde(rename = "receivedDateTime")]
82 received_date_time: chrono::DateTime<chrono::Utc>,
83 #[serde(rename = "sentDateTime")]
84 sent_date_time: chrono::DateTime<chrono::Utc>,
85 #[serde(rename = "isRead")]
86 is_read: Option<bool>,
87 body: GraphBody,
88 #[serde(rename = "hasAttachments")]
89 has_attachments: Option<bool>,
90 #[serde(default)]
91 flag: Option<GraphFlag>,
92}
93
94#[derive(Debug, Deserialize)]
95struct GraphBody {
96 #[serde(rename = "contentType")]
97 content_type: String,
98 content: String,
99}
100
101#[derive(Debug, Deserialize)]
102struct GraphAttachmentList {
103 value: Vec<GraphAttachment>,
104}
105
106#[derive(Debug, Deserialize)]
107struct GraphAttachment {
108 id: String,
109 name: Option<String>,
110 #[serde(rename = "contentType")]
111 content_type: Option<String>,
112 size: Option<u64>,
113 #[serde(rename = "isInline")]
114 is_inline: Option<bool>,
115 #[serde(rename = "contentId")]
116 content_id: Option<String>,
117 #[serde(rename = "@odata.type", default)]
118 odata_type: Option<String>,
119 #[serde(rename = "contentBytes", default)]
121 content_bytes: Option<String>,
122}
123
124pub async fn list_inbox(
130 http: &reqwest::Client,
131 base_url: &str,
132 access_token: &str,
133 account: &str,
134 limit: usize,
135 skip: usize,
136 unread_only: bool,
137) -> Result<InboxPage, ClientError> {
138 list_folder(
139 http,
140 base_url,
141 access_token,
142 account,
143 "inbox",
144 limit,
145 skip,
146 unread_only,
147 )
148 .await
149}
150
151pub async fn list_drafts(
154 http: &reqwest::Client,
155 base_url: &str,
156 access_token: &str,
157 account: &str,
158 limit: usize,
159 skip: usize,
160) -> Result<InboxPage, ClientError> {
161 list_folder(
162 http,
163 base_url,
164 access_token,
165 account,
166 "drafts",
167 limit,
168 skip,
169 false,
170 )
171 .await
172}
173
174#[allow(clippy::too_many_arguments)]
175async fn list_folder(
176 http: &reqwest::Client,
177 base_url: &str,
178 access_token: &str,
179 account: &str,
180 folder: &str,
181 limit: usize,
182 skip: usize,
183 unread_only: bool,
184) -> Result<InboxPage, ClientError> {
185 let url = format!("{base_url}/me/mailFolders/{folder}/messages");
186 let mut req = http.get(&url).bearer_auth(access_token).query(&[
190 (
191 "$select",
192 "id,subject,from,receivedDateTime,isRead,bodyPreview,body,hasAttachments,flag",
193 ),
194 ("$orderby", "receivedDateTime desc"),
195 ("$top", &limit.to_string()),
196 ]);
197 if skip > 0 {
198 req = req.query(&[("$skip", &skip.to_string())]);
199 }
200 if unread_only {
201 req = req.query(&[("$filter", "isRead eq false")]);
202 }
203
204 let resp = req.send().await?;
205 let status = resp.status();
206 if !status.is_success() {
207 let text = resp.text().await.unwrap_or_default();
208 return Err(ClientError::Graph {
209 status: status.as_u16(),
210 message: text,
211 });
212 }
213
214 let list: GraphList = resp.json().await?;
215 Ok(InboxPage {
216 has_more: list.next_link.is_some(),
217 messages: list
218 .value
219 .into_iter()
220 .map(|g| to_message(g, account))
221 .collect(),
222 })
223}
224
225pub async fn search_messages(
236 http: &reqwest::Client,
237 base_url: &str,
238 access_token: &str,
239 account: &str,
240 query: &str,
241 limit: usize,
242) -> Result<Vec<Message>, ClientError> {
243 let quoted = format!("\"{}\"", query.replace('"', "\\\""));
245 let url = format!("{base_url}/me/messages");
246 let resp = http
247 .get(&url)
248 .bearer_auth(access_token)
249 .header("Prefer", "outlook.body-content-type=\"text\"")
250 .query(&[
251 (
252 "$select",
253 "id,subject,from,receivedDateTime,isRead,bodyPreview,body,hasAttachments,flag",
254 ),
255 ("$top", &limit.to_string()),
256 ("$search", "ed),
257 ])
258 .send()
259 .await?;
260 let status = resp.status();
261 if !status.is_success() {
262 let text = resp.text().await.unwrap_or_default();
263 return Err(ClientError::Graph {
264 status: status.as_u16(),
265 message: text,
266 });
267 }
268 let list: GraphList = resp.json().await?;
269 Ok(list
270 .value
271 .into_iter()
272 .map(|g| to_message(g, account))
273 .collect())
274}
275
276fn to_message(g: GraphMessage, account: &str) -> Message {
277 let (body, body_content_type) = match g.body {
278 Some(b) => {
279 let kind = if b.content_type.eq_ignore_ascii_case("html") {
280 pidge_core::BodyContentType::Html
281 } else {
282 pidge_core::BodyContentType::Text
283 };
284 (b.content, kind)
285 }
286 None => (String::new(), pidge_core::BodyContentType::Text),
287 };
288 Message {
289 account: account.to_string(),
290 id: g.id,
291 from: MessageFrom {
292 name: g
293 .from
294 .as_ref()
295 .and_then(|f| f.email_address.name.clone())
296 .unwrap_or_default(),
297 address: g
298 .from
299 .as_ref()
300 .and_then(|f| f.email_address.address.clone())
301 .unwrap_or_default(),
302 },
303 subject: g.subject.unwrap_or_default(),
304 received_at: g.received_date_time,
305 is_read: g.is_read.unwrap_or(true),
306 preview: g.body_preview.unwrap_or_default(),
311 flag_status: flag_status_from(g.flag),
312 has_attachments: g.has_attachments.unwrap_or(false),
313 body,
314 body_content_type,
315 }
316}
317
318pub async fn get_message(
320 http: &reqwest::Client,
321 base_url: &str,
322 access_token: &str,
323 account: &str,
324 message_id: &str,
325) -> Result<pidge_core::FullMessage, ClientError> {
326 let url = format!(
327 "{base_url}/me/messages/{message_id}\
328 ?$select=id,subject,from,toRecipients,ccRecipients,bccRecipients,\
329receivedDateTime,sentDateTime,isRead,body,hasAttachments,flag"
330 );
331 let resp = http.get(&url).bearer_auth(access_token).send().await?;
332 let status = resp.status();
333 if !status.is_success() {
334 let text = resp.text().await.unwrap_or_default();
335 return Err(ClientError::Graph {
336 status: status.as_u16(),
337 message: text,
338 });
339 }
340 let g: GraphFullMessage = resp.json().await?;
341
342 fn from(addr: GraphFromAddress) -> pidge_core::MessageFrom {
343 pidge_core::MessageFrom {
344 name: addr.name.unwrap_or_default(),
345 address: addr.address.unwrap_or_default(),
346 }
347 }
348 fn unwrap_recipients(rs: Vec<GraphFromWrapper>) -> Vec<pidge_core::MessageFrom> {
349 rs.into_iter().map(|w| from(w.email_address)).collect()
350 }
351
352 let content_type = match g.body.content_type.to_lowercase().as_str() {
353 "html" => pidge_core::BodyContentType::Html,
354 _ => pidge_core::BodyContentType::Text,
355 };
356
357 Ok(pidge_core::FullMessage {
358 account: account.to_string(),
359 id: g.id,
360 from: g
361 .from
362 .map(|w| from(w.email_address))
363 .unwrap_or_else(|| pidge_core::MessageFrom {
364 name: String::new(),
365 address: String::new(),
366 }),
367 to: unwrap_recipients(g.to_recipients),
368 cc: unwrap_recipients(g.cc_recipients),
369 bcc: unwrap_recipients(g.bcc_recipients),
370 subject: g.subject.unwrap_or_default(),
371 received_at: g.received_date_time,
372 sent_at: g.sent_date_time,
373 is_read: g.is_read.unwrap_or(true),
374 body_content_type: content_type,
375 body_content: g.body.content,
376 has_attachments: g.has_attachments.unwrap_or(false),
377 flag_status: flag_status_from(g.flag),
378 })
379}
380
381pub async fn fetch_message_headers(
385 http: &reqwest::Client,
386 base_url: &str,
387 access_token: &str,
388 message_id: &str,
389) -> Result<Vec<(String, String)>, ClientError> {
390 let url = format!("{base_url}/me/messages/{message_id}?$select=internetMessageHeaders");
391 let resp = http.get(&url).bearer_auth(access_token).send().await?;
392 let status = resp.status();
393 if !status.is_success() {
394 let text = resp.text().await.unwrap_or_default();
395 return Err(ClientError::Graph {
396 status: status.as_u16(),
397 message: text,
398 });
399 }
400 let body: GraphHeadersResponse = resp.json().await?;
401 Ok(body
402 .internet_message_headers
403 .unwrap_or_default()
404 .into_iter()
405 .map(|h| (h.name, h.value))
406 .collect())
407}
408
409#[derive(serde::Deserialize)]
410struct GraphHeadersResponse {
411 #[serde(rename = "internetMessageHeaders", default)]
412 internet_message_headers: Option<Vec<GraphHeader>>,
413}
414
415#[derive(serde::Deserialize)]
416struct GraphHeader {
417 name: String,
418 value: String,
419}
420
421pub async fn list_attachments(
424 http: &reqwest::Client,
425 base_url: &str,
426 access_token: &str,
427 message_id: &str,
428) -> Result<Vec<pidge_core::Attachment>, ClientError> {
429 let url = format!(
436 "{base_url}/me/messages/{message_id}/attachments\
437 ?$select=id,name,contentType,size,isInline"
438 );
439 let resp = http.get(&url).bearer_auth(access_token).send().await?;
440 let status = resp.status();
441 if !status.is_success() {
442 let text = resp.text().await.unwrap_or_default();
443 return Err(ClientError::Graph {
444 status: status.as_u16(),
445 message: text,
446 });
447 }
448 let list: GraphAttachmentList = resp.json().await?;
449 Ok(list
450 .value
451 .into_iter()
452 .filter(|a| {
453 a.odata_type
454 .as_deref()
455 .map(|t| t == "#microsoft.graph.fileAttachment")
456 .unwrap_or(true)
457 })
458 .map(|a| pidge_core::Attachment {
459 id: a.id,
460 name: a.name.unwrap_or_default(),
461 content_type: a.content_type.unwrap_or_default(),
462 size_bytes: a.size.unwrap_or(0),
463 is_inline: a.is_inline.unwrap_or(false),
464 content_id: a.content_id,
465 })
466 .collect())
467}
468
469pub async fn get_attachment_bytes(
472 http: &reqwest::Client,
473 base_url: &str,
474 access_token: &str,
475 message_id: &str,
476 attachment_id: &str,
477) -> Result<Vec<u8>, ClientError> {
478 use base64::Engine;
479 use base64::engine::general_purpose::STANDARD;
480
481 let url = format!("{base_url}/me/messages/{message_id}/attachments/{attachment_id}");
482 let resp = http.get(&url).bearer_auth(access_token).send().await?;
483 let status = resp.status();
484 if !status.is_success() {
485 let text = resp.text().await.unwrap_or_default();
486 return Err(ClientError::Graph {
487 status: status.as_u16(),
488 message: text,
489 });
490 }
491 let g: GraphAttachment = resp.json().await?;
492 let b64 = g.content_bytes.ok_or_else(|| ClientError::Graph {
493 status: 200,
494 message: "attachment response missing contentBytes".to_string(),
495 })?;
496 STANDARD.decode(&b64).map_err(|e| ClientError::Graph {
497 status: 200,
498 message: format!("attachment base64 decode: {e}"),
499 })
500}
501
502pub async fn mark_read(
504 http: &reqwest::Client,
505 base_url: &str,
506 access_token: &str,
507 message_id: &str,
508) -> Result<(), ClientError> {
509 patch_message(
510 http,
511 base_url,
512 access_token,
513 message_id,
514 &serde_json::json!({ "isRead": true }),
515 )
516 .await
517}
518
519pub async fn mark_unread(
521 http: &reqwest::Client,
522 base_url: &str,
523 access_token: &str,
524 message_id: &str,
525) -> Result<(), ClientError> {
526 patch_message(
527 http,
528 base_url,
529 access_token,
530 message_id,
531 &serde_json::json!({ "isRead": false }),
532 )
533 .await
534}
535
536pub async fn set_flag(
538 http: &reqwest::Client,
539 base_url: &str,
540 access_token: &str,
541 message_id: &str,
542 flagged: bool,
543) -> Result<(), ClientError> {
544 let status = if flagged { "flagged" } else { "notFlagged" };
545 patch_message(
546 http,
547 base_url,
548 access_token,
549 message_id,
550 &serde_json::json!({ "flag": { "flagStatus": status } }),
551 )
552 .await
553}
554
555async fn patch_message(
556 http: &reqwest::Client,
557 base_url: &str,
558 access_token: &str,
559 message_id: &str,
560 body: &serde_json::Value,
561) -> Result<(), ClientError> {
562 let url = format!("{base_url}/me/messages/{message_id}");
563 let resp = http
564 .patch(&url)
565 .bearer_auth(access_token)
566 .json(body)
567 .send()
568 .await?;
569 let status = resp.status();
570 if !status.is_success() {
571 let text = resp.text().await.unwrap_or_default();
572 return Err(ClientError::Graph {
573 status: status.as_u16(),
574 message: text,
575 });
576 }
577 Ok(())
578}
579
580pub async fn send_mail(
586 http: &reqwest::Client,
587 base_url: &str,
588 access_token: &str,
589 message: &Outgoing,
590) -> Result<(), ClientError> {
591 let url = format!("{base_url}/me/sendMail");
592 let body = serde_json::json!({
593 "message": message.to_graph_json(),
594 "saveToSentItems": true,
595 });
596 post_no_body(http, &url, access_token, &body).await
597}
598
599pub async fn reply_message(
602 http: &reqwest::Client,
603 base_url: &str,
604 access_token: &str,
605 message_id: &str,
606 comment: &str,
607) -> Result<(), ClientError> {
608 let url = format!("{base_url}/me/messages/{message_id}/reply");
609 let body = serde_json::json!({ "comment": comment });
610 post_no_body(http, &url, access_token, &body).await
611}
612
613pub async fn reply_all_message(
615 http: &reqwest::Client,
616 base_url: &str,
617 access_token: &str,
618 message_id: &str,
619 comment: &str,
620) -> Result<(), ClientError> {
621 let url = format!("{base_url}/me/messages/{message_id}/replyAll");
622 let body = serde_json::json!({ "comment": comment });
623 post_no_body(http, &url, access_token, &body).await
624}
625
626pub async fn forward_message(
628 http: &reqwest::Client,
629 base_url: &str,
630 access_token: &str,
631 message_id: &str,
632 to: &[String],
633 comment: &str,
634) -> Result<(), ClientError> {
635 let url = format!("{base_url}/me/messages/{message_id}/forward");
636 let body = serde_json::json!({
637 "comment": comment,
638 "toRecipients": to.iter().map(|addr| serde_json::json!({
639 "emailAddress": { "address": addr }
640 })).collect::<Vec<_>>(),
641 });
642 post_no_body(http, &url, access_token, &body).await
643}
644
645async fn post_no_body(
646 http: &reqwest::Client,
647 url: &str,
648 access_token: &str,
649 body: &serde_json::Value,
650) -> Result<(), ClientError> {
651 let resp = http
652 .post(url)
653 .bearer_auth(access_token)
654 .json(body)
655 .send()
656 .await?;
657 let status = resp.status();
658 if !status.is_success() {
659 let text = resp.text().await.unwrap_or_default();
660 return Err(ClientError::Graph {
661 status: status.as_u16(),
662 message: text,
663 });
664 }
665 Ok(())
666}
667
668pub async fn create_draft(
671 http: &reqwest::Client,
672 base_url: &str,
673 access_token: &str,
674 message: &Outgoing,
675) -> Result<String, ClientError> {
676 let url = format!("{base_url}/me/messages");
677 let body = message.to_graph_json();
678 let resp = http
679 .post(&url)
680 .bearer_auth(access_token)
681 .json(&body)
682 .send()
683 .await?;
684 parse_id_from_response(resp).await
685}
686
687pub async fn create_reply_draft(
690 http: &reqwest::Client,
691 base_url: &str,
692 access_token: &str,
693 message_id: &str,
694 comment: &str,
695) -> Result<String, ClientError> {
696 let url = format!("{base_url}/me/messages/{message_id}/createReply");
697 let resp = http
698 .post(&url)
699 .bearer_auth(access_token)
700 .json(&serde_json::json!({ "comment": comment }))
701 .send()
702 .await?;
703 parse_id_from_response(resp).await
704}
705
706pub async fn create_reply_all_draft(
708 http: &reqwest::Client,
709 base_url: &str,
710 access_token: &str,
711 message_id: &str,
712 comment: &str,
713) -> Result<String, ClientError> {
714 let url = format!("{base_url}/me/messages/{message_id}/createReplyAll");
715 let resp = http
716 .post(&url)
717 .bearer_auth(access_token)
718 .json(&serde_json::json!({ "comment": comment }))
719 .send()
720 .await?;
721 parse_id_from_response(resp).await
722}
723
724pub async fn create_forward_draft(
727 http: &reqwest::Client,
728 base_url: &str,
729 access_token: &str,
730 message_id: &str,
731 to: &[String],
732 comment: &str,
733) -> Result<String, ClientError> {
734 let url = format!("{base_url}/me/messages/{message_id}/createForward");
735 let resp = http
736 .post(&url)
737 .bearer_auth(access_token)
738 .json(&serde_json::json!({
739 "comment": comment,
740 "toRecipients": to.iter().map(|addr| serde_json::json!({
741 "emailAddress": { "address": addr }
742 })).collect::<Vec<_>>(),
743 }))
744 .send()
745 .await?;
746 parse_id_from_response(resp).await
747}
748
749pub async fn send_draft(
751 http: &reqwest::Client,
752 base_url: &str,
753 access_token: &str,
754 message_id: &str,
755) -> Result<(), ClientError> {
756 let url = format!("{base_url}/me/messages/{message_id}/send");
757 let resp = http
762 .post(&url)
763 .bearer_auth(access_token)
764 .header(reqwest::header::CONTENT_LENGTH, 0)
765 .body(reqwest::Body::from(""))
766 .send()
767 .await?;
768 let status = resp.status();
769 if !status.is_success() {
770 let text = resp.text().await.unwrap_or_default();
771 return Err(ClientError::Graph {
772 status: status.as_u16(),
773 message: text,
774 });
775 }
776 Ok(())
777}
778
779pub async fn update_draft(
782 http: &reqwest::Client,
783 base_url: &str,
784 access_token: &str,
785 message_id: &str,
786 message: &Outgoing,
787) -> Result<(), ClientError> {
788 let url = format!("{base_url}/me/messages/{message_id}");
789 let body = message.to_graph_json();
790 let resp = http
791 .patch(&url)
792 .bearer_auth(access_token)
793 .json(&body)
794 .send()
795 .await?;
796 let status = resp.status();
797 if !status.is_success() {
798 let text = resp.text().await.unwrap_or_default();
799 return Err(ClientError::Graph {
800 status: status.as_u16(),
801 message: text,
802 });
803 }
804 Ok(())
805}
806
807pub async fn delete_message(
811 http: &reqwest::Client,
812 base_url: &str,
813 access_token: &str,
814 message_id: &str,
815) -> Result<(), ClientError> {
816 let url = format!("{base_url}/me/messages/{message_id}");
817 let resp = http.delete(&url).bearer_auth(access_token).send().await?;
818 let status = resp.status();
819 if !status.is_success() {
820 let text = resp.text().await.unwrap_or_default();
821 return Err(ClientError::Graph {
822 status: status.as_u16(),
823 message: text,
824 });
825 }
826 Ok(())
827}
828
829pub async fn add_attachment(
835 http: &reqwest::Client,
836 base_url: &str,
837 access_token: &str,
838 message_id: &str,
839 name: &str,
840 content_type: &str,
841 bytes: &[u8],
842) -> Result<String, ClientError> {
843 use base64::Engine;
844 use base64::engine::general_purpose::STANDARD;
845
846 let url = format!("{base_url}/me/messages/{message_id}/attachments");
847 let body = serde_json::json!({
848 "@odata.type": "#microsoft.graph.fileAttachment",
849 "name": name,
850 "contentType": content_type,
851 "contentBytes": STANDARD.encode(bytes),
852 });
853 let resp = http
854 .post(&url)
855 .bearer_auth(access_token)
856 .json(&body)
857 .send()
858 .await?;
859 parse_id_from_response(resp).await
860}
861
862pub async fn delete_attachment(
864 http: &reqwest::Client,
865 base_url: &str,
866 access_token: &str,
867 message_id: &str,
868 attachment_id: &str,
869) -> Result<(), ClientError> {
870 let url = format!("{base_url}/me/messages/{message_id}/attachments/{attachment_id}");
871 let resp = http.delete(&url).bearer_auth(access_token).send().await?;
872 let status = resp.status();
873 if !status.is_success() {
874 let text = resp.text().await.unwrap_or_default();
875 return Err(ClientError::Graph {
876 status: status.as_u16(),
877 message: text,
878 });
879 }
880 Ok(())
881}
882
883async fn parse_id_from_response(resp: reqwest::Response) -> Result<String, ClientError> {
884 let status = resp.status();
885 if !status.is_success() {
886 let text = resp.text().await.unwrap_or_default();
887 return Err(ClientError::Graph {
888 status: status.as_u16(),
889 message: text,
890 });
891 }
892 let v: serde_json::Value = resp.json().await?;
893 v["id"]
894 .as_str()
895 .map(|s| s.to_string())
896 .ok_or_else(|| ClientError::Graph {
897 status: 200,
898 message: "draft response missing 'id'".to_string(),
899 })
900}
901
902#[derive(Debug, Clone)]
904pub struct Outgoing {
905 pub subject: String,
906 pub body_text: String,
907 pub to: Vec<String>,
908 pub cc: Vec<String>,
909 pub bcc: Vec<String>,
910}
911
912impl Outgoing {
913 fn to_graph_json(&self) -> serde_json::Value {
914 fn addresses(list: &[String]) -> Vec<serde_json::Value> {
915 list.iter()
916 .map(|addr| serde_json::json!({ "emailAddress": { "address": addr } }))
917 .collect()
918 }
919 serde_json::json!({
920 "subject": self.subject,
921 "body": {
922 "contentType": "Text",
923 "content": self.body_text,
924 },
925 "toRecipients": addresses(&self.to),
926 "ccRecipients": addresses(&self.cc),
927 "bccRecipients": addresses(&self.bcc),
928 })
929 }
930}
931
932pub async fn move_message(
939 http: &reqwest::Client,
940 base_url: &str,
941 access_token: &str,
942 message_id: &str,
943 destination: &str,
944) -> Result<(), ClientError> {
945 let url = format!("{base_url}/me/messages/{message_id}/move");
946 let resp = http
947 .post(&url)
948 .bearer_auth(access_token)
949 .json(&serde_json::json!({ "destinationId": destination }))
950 .send()
951 .await?;
952 let status = resp.status();
953 if !status.is_success() {
954 let text = resp.text().await.unwrap_or_default();
955 return Err(ClientError::Graph {
956 status: status.as_u16(),
957 message: text,
958 });
959 }
960 Ok(())
961}
962
963#[cfg(test)]
964mod tests {
965 use super::*;
966 use wiremock::matchers::{header, method, path, path_regex, query_param};
967 use wiremock::{Mock, MockServer, ResponseTemplate};
968
969 #[tokio::test]
970 async fn list_inbox_parses_graph_response() {
971 let server = MockServer::start().await;
972 Mock::given(method("GET"))
973 .and(path("/me/mailFolders/inbox/messages"))
974 .and(header("authorization", "Bearer AT"))
975 .and(query_param("$top", "5"))
976 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
977 "value": [
978 {
979 "id": "AAAA",
980 "subject": "Hello",
981 "from": {
982 "emailAddress": {
983 "name": "Maria",
984 "address": "maria@mklab.se"
985 }
986 },
987 "receivedDateTime": "2026-05-13T22:00:00Z",
988 "isRead": false,
989 "bodyPreview": "Hi there"
990 }
991 ]
992 })))
993 .mount(&server)
994 .await;
995
996 let http = reqwest::Client::new();
997 let page = list_inbox(&http, &server.uri(), "AT", "u@e.com", 5, 0, false)
998 .await
999 .unwrap();
1000 assert_eq!(page.messages.len(), 1);
1001 assert_eq!(page.messages[0].subject, "Hello");
1002 assert_eq!(page.messages[0].from.address, "maria@mklab.se");
1003 assert!(!page.messages[0].is_read);
1004 assert_eq!(page.messages[0].account, "u@e.com");
1005 assert!(!page.has_more);
1006 }
1007
1008 #[tokio::test]
1009 async fn list_inbox_adds_filter_when_unread_only() {
1010 let server = MockServer::start().await;
1011 Mock::given(method("GET"))
1012 .and(path("/me/mailFolders/inbox/messages"))
1013 .and(query_param("$filter", "isRead eq false"))
1014 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1015 "value": []
1016 })))
1017 .mount(&server)
1018 .await;
1019
1020 let http = reqwest::Client::new();
1021 let page = list_inbox(&http, &server.uri(), "AT", "u@e.com", 5, 0, true)
1022 .await
1023 .unwrap();
1024 assert!(page.messages.is_empty());
1025 }
1026
1027 #[tokio::test]
1028 async fn list_inbox_passes_skip_when_nonzero() {
1029 let server = MockServer::start().await;
1030 Mock::given(method("GET"))
1031 .and(path("/me/mailFolders/inbox/messages"))
1032 .and(query_param("$skip", "25"))
1033 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1034 "value": [],
1035 "@odata.nextLink": "https://graph.example/next"
1036 })))
1037 .mount(&server)
1038 .await;
1039
1040 let http = reqwest::Client::new();
1041 let page = list_inbox(&http, &server.uri(), "AT", "u@e.com", 25, 25, false)
1042 .await
1043 .unwrap();
1044 assert!(page.has_more);
1045 }
1046
1047 #[tokio::test]
1048 async fn search_messages_passes_search_query() {
1049 let server = MockServer::start().await;
1050 Mock::given(method("GET"))
1051 .and(path("/me/messages"))
1052 .and(query_param("$search", "\"alice budget\""))
1053 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1054 "value": [
1055 {
1056 "id": "S1",
1057 "subject": "Q4 budget review",
1058 "from": { "emailAddress": { "name": "Alice", "address": "alice@example.com" } },
1059 "receivedDateTime": "2026-05-13T22:00:00Z",
1060 "isRead": true,
1061 "bodyPreview": "Numbers attached"
1062 }
1063 ]
1064 })))
1065 .mount(&server)
1066 .await;
1067
1068 let http = reqwest::Client::new();
1069 let msgs = search_messages(&http, &server.uri(), "AT", "u@e.com", "alice budget", 25)
1070 .await
1071 .unwrap();
1072 assert_eq!(msgs.len(), 1);
1073 assert_eq!(msgs[0].subject, "Q4 budget review");
1074 }
1075
1076 #[tokio::test]
1077 async fn mark_unread_patches_isread_false() {
1078 let server = MockServer::start().await;
1079 Mock::given(method("PATCH"))
1080 .and(path_regex("/me/messages/[A-Za-z0-9]+"))
1081 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
1082 .mount(&server)
1083 .await;
1084 let http = reqwest::Client::new();
1085 mark_unread(&http, &server.uri(), "AT", "MSG")
1086 .await
1087 .unwrap();
1088 }
1089
1090 #[tokio::test]
1091 async fn set_flag_patches_flag_status() {
1092 let server = MockServer::start().await;
1093 Mock::given(method("PATCH"))
1094 .and(path_regex("/me/messages/[A-Za-z0-9]+"))
1095 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
1096 .mount(&server)
1097 .await;
1098 let http = reqwest::Client::new();
1099 set_flag(&http, &server.uri(), "AT", "MSG", true)
1100 .await
1101 .unwrap();
1102 set_flag(&http, &server.uri(), "AT", "MSG", false)
1103 .await
1104 .unwrap();
1105 }
1106
1107 #[tokio::test]
1108 async fn send_mail_wraps_outgoing_in_message_envelope() {
1109 use wiremock::matchers::body_partial_json;
1110 let server = MockServer::start().await;
1111 Mock::given(method("POST"))
1112 .and(path("/me/sendMail"))
1113 .and(body_partial_json(serde_json::json!({
1114 "saveToSentItems": true,
1115 "message": {
1116 "subject": "Hello",
1117 "body": { "contentType": "Text", "content": "Hi there" },
1118 "toRecipients": [{ "emailAddress": { "address": "alice@example.com" } }]
1119 }
1120 })))
1121 .respond_with(ResponseTemplate::new(202))
1122 .mount(&server)
1123 .await;
1124 let http = reqwest::Client::new();
1125 let msg = Outgoing {
1126 subject: "Hello".into(),
1127 body_text: "Hi there".into(),
1128 to: vec!["alice@example.com".into()],
1129 cc: vec![],
1130 bcc: vec![],
1131 };
1132 send_mail(&http, &server.uri(), "AT", &msg).await.unwrap();
1133 }
1134
1135 #[tokio::test]
1136 async fn reply_message_posts_comment() {
1137 use wiremock::matchers::body_partial_json;
1138 let server = MockServer::start().await;
1139 Mock::given(method("POST"))
1140 .and(path_regex("/me/messages/[A-Za-z0-9]+/reply"))
1141 .and(body_partial_json(
1142 serde_json::json!({ "comment": "Thanks!" }),
1143 ))
1144 .respond_with(ResponseTemplate::new(202))
1145 .mount(&server)
1146 .await;
1147 let http = reqwest::Client::new();
1148 reply_message(&http, &server.uri(), "AT", "MSG", "Thanks!")
1149 .await
1150 .unwrap();
1151 }
1152
1153 #[tokio::test]
1154 async fn forward_message_posts_recipients_and_comment() {
1155 use wiremock::matchers::body_partial_json;
1156 let server = MockServer::start().await;
1157 Mock::given(method("POST"))
1158 .and(path_regex("/me/messages/[A-Za-z0-9]+/forward"))
1159 .and(body_partial_json(serde_json::json!({
1160 "comment": "FYI",
1161 "toRecipients": [{ "emailAddress": { "address": "bob@example.com" } }]
1162 })))
1163 .respond_with(ResponseTemplate::new(202))
1164 .mount(&server)
1165 .await;
1166 let http = reqwest::Client::new();
1167 forward_message(
1168 &http,
1169 &server.uri(),
1170 "AT",
1171 "MSG",
1172 &["bob@example.com".into()],
1173 "FYI",
1174 )
1175 .await
1176 .unwrap();
1177 }
1178
1179 #[tokio::test]
1180 async fn move_message_posts_destination() {
1181 let server = MockServer::start().await;
1182 Mock::given(method("POST"))
1183 .and(path_regex("/me/messages/[A-Za-z0-9]+/move"))
1184 .respond_with(
1185 ResponseTemplate::new(201).set_body_json(serde_json::json!({"id": "NEW"})),
1186 )
1187 .mount(&server)
1188 .await;
1189 let http = reqwest::Client::new();
1190 move_message(&http, &server.uri(), "AT", "MSG", "archive")
1191 .await
1192 .unwrap();
1193 }
1194
1195 #[tokio::test]
1196 async fn get_message_parses_graph_response() {
1197 let server = MockServer::start().await;
1198 Mock::given(method("GET"))
1199 .and(path_regex("/me/messages/[A-Za-z0-9]+"))
1200 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1201 "id": "AAA",
1202 "subject": "Hello",
1203 "from": { "emailAddress": { "name": "Maria", "address": "maria@mklab.se" } },
1204 "toRecipients": [
1205 { "emailAddress": { "name": "Kristofer", "address": "kristofer@mklab.se" } }
1206 ],
1207 "ccRecipients": [],
1208 "bccRecipients": [],
1209 "receivedDateTime": "2026-05-14T22:00:00Z",
1210 "sentDateTime": "2026-05-14T21:59:30Z",
1211 "isRead": false,
1212 "body": { "contentType": "html", "content": "<p>Hi</p>" },
1213 "hasAttachments": true
1214 })))
1215 .mount(&server)
1216 .await;
1217
1218 let http = reqwest::Client::new();
1219 let m = get_message(&http, &server.uri(), "AT", "u@e.com", "AAA")
1220 .await
1221 .unwrap();
1222 assert_eq!(m.id, "AAA");
1223 assert_eq!(m.subject, "Hello");
1224 assert_eq!(m.from.name, "Maria");
1225 assert_eq!(m.to.len(), 1);
1226 assert_eq!(m.to[0].address, "kristofer@mklab.se");
1227 assert!(matches!(
1228 m.body_content_type,
1229 pidge_core::BodyContentType::Html
1230 ));
1231 assert_eq!(m.body_content, "<p>Hi</p>");
1232 assert!(m.has_attachments);
1233 }
1234
1235 #[tokio::test]
1236 async fn list_attachments_filters_file_attachments() {
1237 let server = MockServer::start().await;
1238 Mock::given(method("GET"))
1239 .and(path_regex("/me/messages/[A-Za-z0-9]+/attachments"))
1240 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1241 "value": [
1242 {
1243 "@odata.type": "#microsoft.graph.fileAttachment",
1244 "id": "att-1",
1245 "name": "report.pdf",
1246 "contentType": "application/pdf",
1247 "size": 12345,
1248 "isInline": false
1249 },
1250 {
1251 "@odata.type": "#microsoft.graph.itemAttachment",
1252 "id": "att-2",
1253 "name": "an-email.eml",
1254 "contentType": "message/rfc822",
1255 "size": 7777,
1256 "isInline": false
1257 }
1258 ]
1259 })))
1260 .mount(&server)
1261 .await;
1262
1263 let http = reqwest::Client::new();
1264 let atts = list_attachments(&http, &server.uri(), "AT", "MSG")
1265 .await
1266 .unwrap();
1267 assert_eq!(atts.len(), 1);
1268 assert_eq!(atts[0].name, "report.pdf");
1269 assert_eq!(atts[0].size_bytes, 12345);
1270 }
1271
1272 #[tokio::test]
1273 async fn get_attachment_bytes_decodes_base64() {
1274 let server = MockServer::start().await;
1275 Mock::given(method("GET"))
1276 .and(path_regex(
1277 "/me/messages/[A-Za-z0-9]+/attachments/[A-Za-z0-9-]+",
1278 ))
1279 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1280 "id": "att-1",
1281 "name": "report.pdf",
1282 "contentType": "application/pdf",
1283 "size": 5,
1284 "isInline": false,
1285 "contentBytes": "aGVsbG8="
1286 })))
1287 .mount(&server)
1288 .await;
1289
1290 let http = reqwest::Client::new();
1291 let bytes = get_attachment_bytes(&http, &server.uri(), "AT", "MSG", "att-1")
1292 .await
1293 .unwrap();
1294 assert_eq!(bytes, b"hello");
1295 }
1296
1297 #[tokio::test]
1298 async fn mark_read_patches_isread_true() {
1299 let server = MockServer::start().await;
1300 Mock::given(method("PATCH"))
1301 .and(path_regex("/me/messages/[A-Za-z0-9]+"))
1302 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
1303 .mount(&server)
1304 .await;
1305
1306 let http = reqwest::Client::new();
1307 mark_read(&http, &server.uri(), "AT", "MSG").await.unwrap();
1308 }
1309
1310 #[tokio::test]
1311 async fn fetch_message_headers_parses_array() {
1312 let server = MockServer::start().await;
1313 Mock::given(method("GET"))
1314 .and(path_regex(r"^/me/messages/.+$"))
1315 .and(query_param("$select", "internetMessageHeaders"))
1316 .and(header("authorization", "Bearer AT"))
1317 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1318 "internetMessageHeaders": [
1319 { "name": "List-Unsubscribe", "value": "<mailto:u@x>, <https://x/u>" },
1320 { "name": "List-Unsubscribe-Post", "value": "List-Unsubscribe=One-Click" }
1321 ]
1322 })))
1323 .mount(&server)
1324 .await;
1325
1326 let http = reqwest::Client::new();
1327 let headers = fetch_message_headers(&http, &server.uri(), "AT", "MSGID")
1328 .await
1329 .unwrap();
1330 assert_eq!(headers.len(), 2);
1331 assert_eq!(headers[0].0, "List-Unsubscribe");
1332 assert_eq!(headers[0].1, "<mailto:u@x>, <https://x/u>");
1333 assert_eq!(headers[1].0, "List-Unsubscribe-Post");
1334 }
1335}