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 list_attachments(
384 http: &reqwest::Client,
385 base_url: &str,
386 access_token: &str,
387 message_id: &str,
388) -> Result<Vec<pidge_core::Attachment>, ClientError> {
389 let url = format!(
396 "{base_url}/me/messages/{message_id}/attachments\
397 ?$select=id,name,contentType,size,isInline"
398 );
399 let resp = http.get(&url).bearer_auth(access_token).send().await?;
400 let status = resp.status();
401 if !status.is_success() {
402 let text = resp.text().await.unwrap_or_default();
403 return Err(ClientError::Graph {
404 status: status.as_u16(),
405 message: text,
406 });
407 }
408 let list: GraphAttachmentList = resp.json().await?;
409 Ok(list
410 .value
411 .into_iter()
412 .filter(|a| {
413 a.odata_type
414 .as_deref()
415 .map(|t| t == "#microsoft.graph.fileAttachment")
416 .unwrap_or(true)
417 })
418 .map(|a| pidge_core::Attachment {
419 id: a.id,
420 name: a.name.unwrap_or_default(),
421 content_type: a.content_type.unwrap_or_default(),
422 size_bytes: a.size.unwrap_or(0),
423 is_inline: a.is_inline.unwrap_or(false),
424 content_id: a.content_id,
425 })
426 .collect())
427}
428
429pub async fn get_attachment_bytes(
432 http: &reqwest::Client,
433 base_url: &str,
434 access_token: &str,
435 message_id: &str,
436 attachment_id: &str,
437) -> Result<Vec<u8>, ClientError> {
438 use base64::Engine;
439 use base64::engine::general_purpose::STANDARD;
440
441 let url = format!("{base_url}/me/messages/{message_id}/attachments/{attachment_id}");
442 let resp = http.get(&url).bearer_auth(access_token).send().await?;
443 let status = resp.status();
444 if !status.is_success() {
445 let text = resp.text().await.unwrap_or_default();
446 return Err(ClientError::Graph {
447 status: status.as_u16(),
448 message: text,
449 });
450 }
451 let g: GraphAttachment = resp.json().await?;
452 let b64 = g.content_bytes.ok_or_else(|| ClientError::Graph {
453 status: 200,
454 message: "attachment response missing contentBytes".to_string(),
455 })?;
456 STANDARD.decode(&b64).map_err(|e| ClientError::Graph {
457 status: 200,
458 message: format!("attachment base64 decode: {e}"),
459 })
460}
461
462pub async fn mark_read(
464 http: &reqwest::Client,
465 base_url: &str,
466 access_token: &str,
467 message_id: &str,
468) -> Result<(), ClientError> {
469 patch_message(
470 http,
471 base_url,
472 access_token,
473 message_id,
474 &serde_json::json!({ "isRead": true }),
475 )
476 .await
477}
478
479pub async fn mark_unread(
481 http: &reqwest::Client,
482 base_url: &str,
483 access_token: &str,
484 message_id: &str,
485) -> Result<(), ClientError> {
486 patch_message(
487 http,
488 base_url,
489 access_token,
490 message_id,
491 &serde_json::json!({ "isRead": false }),
492 )
493 .await
494}
495
496pub async fn set_flag(
498 http: &reqwest::Client,
499 base_url: &str,
500 access_token: &str,
501 message_id: &str,
502 flagged: bool,
503) -> Result<(), ClientError> {
504 let status = if flagged { "flagged" } else { "notFlagged" };
505 patch_message(
506 http,
507 base_url,
508 access_token,
509 message_id,
510 &serde_json::json!({ "flag": { "flagStatus": status } }),
511 )
512 .await
513}
514
515async fn patch_message(
516 http: &reqwest::Client,
517 base_url: &str,
518 access_token: &str,
519 message_id: &str,
520 body: &serde_json::Value,
521) -> Result<(), ClientError> {
522 let url = format!("{base_url}/me/messages/{message_id}");
523 let resp = http
524 .patch(&url)
525 .bearer_auth(access_token)
526 .json(body)
527 .send()
528 .await?;
529 let status = resp.status();
530 if !status.is_success() {
531 let text = resp.text().await.unwrap_or_default();
532 return Err(ClientError::Graph {
533 status: status.as_u16(),
534 message: text,
535 });
536 }
537 Ok(())
538}
539
540pub async fn send_mail(
546 http: &reqwest::Client,
547 base_url: &str,
548 access_token: &str,
549 message: &Outgoing,
550) -> Result<(), ClientError> {
551 let url = format!("{base_url}/me/sendMail");
552 let body = serde_json::json!({
553 "message": message.to_graph_json(),
554 "saveToSentItems": true,
555 });
556 post_no_body(http, &url, access_token, &body).await
557}
558
559pub async fn reply_message(
562 http: &reqwest::Client,
563 base_url: &str,
564 access_token: &str,
565 message_id: &str,
566 comment: &str,
567) -> Result<(), ClientError> {
568 let url = format!("{base_url}/me/messages/{message_id}/reply");
569 let body = serde_json::json!({ "comment": comment });
570 post_no_body(http, &url, access_token, &body).await
571}
572
573pub async fn reply_all_message(
575 http: &reqwest::Client,
576 base_url: &str,
577 access_token: &str,
578 message_id: &str,
579 comment: &str,
580) -> Result<(), ClientError> {
581 let url = format!("{base_url}/me/messages/{message_id}/replyAll");
582 let body = serde_json::json!({ "comment": comment });
583 post_no_body(http, &url, access_token, &body).await
584}
585
586pub async fn forward_message(
588 http: &reqwest::Client,
589 base_url: &str,
590 access_token: &str,
591 message_id: &str,
592 to: &[String],
593 comment: &str,
594) -> Result<(), ClientError> {
595 let url = format!("{base_url}/me/messages/{message_id}/forward");
596 let body = serde_json::json!({
597 "comment": comment,
598 "toRecipients": to.iter().map(|addr| serde_json::json!({
599 "emailAddress": { "address": addr }
600 })).collect::<Vec<_>>(),
601 });
602 post_no_body(http, &url, access_token, &body).await
603}
604
605async fn post_no_body(
606 http: &reqwest::Client,
607 url: &str,
608 access_token: &str,
609 body: &serde_json::Value,
610) -> Result<(), ClientError> {
611 let resp = http
612 .post(url)
613 .bearer_auth(access_token)
614 .json(body)
615 .send()
616 .await?;
617 let status = resp.status();
618 if !status.is_success() {
619 let text = resp.text().await.unwrap_or_default();
620 return Err(ClientError::Graph {
621 status: status.as_u16(),
622 message: text,
623 });
624 }
625 Ok(())
626}
627
628pub async fn create_draft(
631 http: &reqwest::Client,
632 base_url: &str,
633 access_token: &str,
634 message: &Outgoing,
635) -> Result<String, ClientError> {
636 let url = format!("{base_url}/me/messages");
637 let body = message.to_graph_json();
638 let resp = http
639 .post(&url)
640 .bearer_auth(access_token)
641 .json(&body)
642 .send()
643 .await?;
644 parse_id_from_response(resp).await
645}
646
647pub async fn create_reply_draft(
650 http: &reqwest::Client,
651 base_url: &str,
652 access_token: &str,
653 message_id: &str,
654 comment: &str,
655) -> Result<String, ClientError> {
656 let url = format!("{base_url}/me/messages/{message_id}/createReply");
657 let resp = http
658 .post(&url)
659 .bearer_auth(access_token)
660 .json(&serde_json::json!({ "comment": comment }))
661 .send()
662 .await?;
663 parse_id_from_response(resp).await
664}
665
666pub async fn create_reply_all_draft(
668 http: &reqwest::Client,
669 base_url: &str,
670 access_token: &str,
671 message_id: &str,
672 comment: &str,
673) -> Result<String, ClientError> {
674 let url = format!("{base_url}/me/messages/{message_id}/createReplyAll");
675 let resp = http
676 .post(&url)
677 .bearer_auth(access_token)
678 .json(&serde_json::json!({ "comment": comment }))
679 .send()
680 .await?;
681 parse_id_from_response(resp).await
682}
683
684pub async fn create_forward_draft(
687 http: &reqwest::Client,
688 base_url: &str,
689 access_token: &str,
690 message_id: &str,
691 to: &[String],
692 comment: &str,
693) -> Result<String, ClientError> {
694 let url = format!("{base_url}/me/messages/{message_id}/createForward");
695 let resp = http
696 .post(&url)
697 .bearer_auth(access_token)
698 .json(&serde_json::json!({
699 "comment": comment,
700 "toRecipients": to.iter().map(|addr| serde_json::json!({
701 "emailAddress": { "address": addr }
702 })).collect::<Vec<_>>(),
703 }))
704 .send()
705 .await?;
706 parse_id_from_response(resp).await
707}
708
709pub async fn send_draft(
711 http: &reqwest::Client,
712 base_url: &str,
713 access_token: &str,
714 message_id: &str,
715) -> Result<(), ClientError> {
716 let url = format!("{base_url}/me/messages/{message_id}/send");
717 let resp = http
722 .post(&url)
723 .bearer_auth(access_token)
724 .header(reqwest::header::CONTENT_LENGTH, 0)
725 .body(reqwest::Body::from(""))
726 .send()
727 .await?;
728 let status = resp.status();
729 if !status.is_success() {
730 let text = resp.text().await.unwrap_or_default();
731 return Err(ClientError::Graph {
732 status: status.as_u16(),
733 message: text,
734 });
735 }
736 Ok(())
737}
738
739pub async fn update_draft(
742 http: &reqwest::Client,
743 base_url: &str,
744 access_token: &str,
745 message_id: &str,
746 message: &Outgoing,
747) -> Result<(), ClientError> {
748 let url = format!("{base_url}/me/messages/{message_id}");
749 let body = message.to_graph_json();
750 let resp = http
751 .patch(&url)
752 .bearer_auth(access_token)
753 .json(&body)
754 .send()
755 .await?;
756 let status = resp.status();
757 if !status.is_success() {
758 let text = resp.text().await.unwrap_or_default();
759 return Err(ClientError::Graph {
760 status: status.as_u16(),
761 message: text,
762 });
763 }
764 Ok(())
765}
766
767pub async fn delete_message(
771 http: &reqwest::Client,
772 base_url: &str,
773 access_token: &str,
774 message_id: &str,
775) -> Result<(), ClientError> {
776 let url = format!("{base_url}/me/messages/{message_id}");
777 let resp = http.delete(&url).bearer_auth(access_token).send().await?;
778 let status = resp.status();
779 if !status.is_success() {
780 let text = resp.text().await.unwrap_or_default();
781 return Err(ClientError::Graph {
782 status: status.as_u16(),
783 message: text,
784 });
785 }
786 Ok(())
787}
788
789pub async fn add_attachment(
795 http: &reqwest::Client,
796 base_url: &str,
797 access_token: &str,
798 message_id: &str,
799 name: &str,
800 content_type: &str,
801 bytes: &[u8],
802) -> Result<String, ClientError> {
803 use base64::Engine;
804 use base64::engine::general_purpose::STANDARD;
805
806 let url = format!("{base_url}/me/messages/{message_id}/attachments");
807 let body = serde_json::json!({
808 "@odata.type": "#microsoft.graph.fileAttachment",
809 "name": name,
810 "contentType": content_type,
811 "contentBytes": STANDARD.encode(bytes),
812 });
813 let resp = http
814 .post(&url)
815 .bearer_auth(access_token)
816 .json(&body)
817 .send()
818 .await?;
819 parse_id_from_response(resp).await
820}
821
822pub async fn delete_attachment(
824 http: &reqwest::Client,
825 base_url: &str,
826 access_token: &str,
827 message_id: &str,
828 attachment_id: &str,
829) -> Result<(), ClientError> {
830 let url = format!("{base_url}/me/messages/{message_id}/attachments/{attachment_id}");
831 let resp = http.delete(&url).bearer_auth(access_token).send().await?;
832 let status = resp.status();
833 if !status.is_success() {
834 let text = resp.text().await.unwrap_or_default();
835 return Err(ClientError::Graph {
836 status: status.as_u16(),
837 message: text,
838 });
839 }
840 Ok(())
841}
842
843async fn parse_id_from_response(resp: reqwest::Response) -> Result<String, ClientError> {
844 let status = resp.status();
845 if !status.is_success() {
846 let text = resp.text().await.unwrap_or_default();
847 return Err(ClientError::Graph {
848 status: status.as_u16(),
849 message: text,
850 });
851 }
852 let v: serde_json::Value = resp.json().await?;
853 v["id"]
854 .as_str()
855 .map(|s| s.to_string())
856 .ok_or_else(|| ClientError::Graph {
857 status: 200,
858 message: "draft response missing 'id'".to_string(),
859 })
860}
861
862#[derive(Debug, Clone)]
864pub struct Outgoing {
865 pub subject: String,
866 pub body_text: String,
867 pub to: Vec<String>,
868 pub cc: Vec<String>,
869 pub bcc: Vec<String>,
870}
871
872impl Outgoing {
873 fn to_graph_json(&self) -> serde_json::Value {
874 fn addresses(list: &[String]) -> Vec<serde_json::Value> {
875 list.iter()
876 .map(|addr| serde_json::json!({ "emailAddress": { "address": addr } }))
877 .collect()
878 }
879 serde_json::json!({
880 "subject": self.subject,
881 "body": {
882 "contentType": "Text",
883 "content": self.body_text,
884 },
885 "toRecipients": addresses(&self.to),
886 "ccRecipients": addresses(&self.cc),
887 "bccRecipients": addresses(&self.bcc),
888 })
889 }
890}
891
892pub async fn move_message(
899 http: &reqwest::Client,
900 base_url: &str,
901 access_token: &str,
902 message_id: &str,
903 destination: &str,
904) -> Result<(), ClientError> {
905 let url = format!("{base_url}/me/messages/{message_id}/move");
906 let resp = http
907 .post(&url)
908 .bearer_auth(access_token)
909 .json(&serde_json::json!({ "destinationId": destination }))
910 .send()
911 .await?;
912 let status = resp.status();
913 if !status.is_success() {
914 let text = resp.text().await.unwrap_or_default();
915 return Err(ClientError::Graph {
916 status: status.as_u16(),
917 message: text,
918 });
919 }
920 Ok(())
921}
922
923#[cfg(test)]
924mod tests {
925 use super::*;
926 use wiremock::matchers::{header, method, path, path_regex, query_param};
927 use wiremock::{Mock, MockServer, ResponseTemplate};
928
929 #[tokio::test]
930 async fn list_inbox_parses_graph_response() {
931 let server = MockServer::start().await;
932 Mock::given(method("GET"))
933 .and(path("/me/mailFolders/inbox/messages"))
934 .and(header("authorization", "Bearer AT"))
935 .and(query_param("$top", "5"))
936 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
937 "value": [
938 {
939 "id": "AAAA",
940 "subject": "Hello",
941 "from": {
942 "emailAddress": {
943 "name": "Maria",
944 "address": "maria@mklab.se"
945 }
946 },
947 "receivedDateTime": "2026-05-13T22:00:00Z",
948 "isRead": false,
949 "bodyPreview": "Hi there"
950 }
951 ]
952 })))
953 .mount(&server)
954 .await;
955
956 let http = reqwest::Client::new();
957 let page = list_inbox(&http, &server.uri(), "AT", "u@e.com", 5, 0, false)
958 .await
959 .unwrap();
960 assert_eq!(page.messages.len(), 1);
961 assert_eq!(page.messages[0].subject, "Hello");
962 assert_eq!(page.messages[0].from.address, "maria@mklab.se");
963 assert!(!page.messages[0].is_read);
964 assert_eq!(page.messages[0].account, "u@e.com");
965 assert!(!page.has_more);
966 }
967
968 #[tokio::test]
969 async fn list_inbox_adds_filter_when_unread_only() {
970 let server = MockServer::start().await;
971 Mock::given(method("GET"))
972 .and(path("/me/mailFolders/inbox/messages"))
973 .and(query_param("$filter", "isRead eq false"))
974 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
975 "value": []
976 })))
977 .mount(&server)
978 .await;
979
980 let http = reqwest::Client::new();
981 let page = list_inbox(&http, &server.uri(), "AT", "u@e.com", 5, 0, true)
982 .await
983 .unwrap();
984 assert!(page.messages.is_empty());
985 }
986
987 #[tokio::test]
988 async fn list_inbox_passes_skip_when_nonzero() {
989 let server = MockServer::start().await;
990 Mock::given(method("GET"))
991 .and(path("/me/mailFolders/inbox/messages"))
992 .and(query_param("$skip", "25"))
993 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
994 "value": [],
995 "@odata.nextLink": "https://graph.example/next"
996 })))
997 .mount(&server)
998 .await;
999
1000 let http = reqwest::Client::new();
1001 let page = list_inbox(&http, &server.uri(), "AT", "u@e.com", 25, 25, false)
1002 .await
1003 .unwrap();
1004 assert!(page.has_more);
1005 }
1006
1007 #[tokio::test]
1008 async fn search_messages_passes_search_query() {
1009 let server = MockServer::start().await;
1010 Mock::given(method("GET"))
1011 .and(path("/me/messages"))
1012 .and(query_param("$search", "\"alice budget\""))
1013 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1014 "value": [
1015 {
1016 "id": "S1",
1017 "subject": "Q4 budget review",
1018 "from": { "emailAddress": { "name": "Alice", "address": "alice@example.com" } },
1019 "receivedDateTime": "2026-05-13T22:00:00Z",
1020 "isRead": true,
1021 "bodyPreview": "Numbers attached"
1022 }
1023 ]
1024 })))
1025 .mount(&server)
1026 .await;
1027
1028 let http = reqwest::Client::new();
1029 let msgs = search_messages(&http, &server.uri(), "AT", "u@e.com", "alice budget", 25)
1030 .await
1031 .unwrap();
1032 assert_eq!(msgs.len(), 1);
1033 assert_eq!(msgs[0].subject, "Q4 budget review");
1034 }
1035
1036 #[tokio::test]
1037 async fn mark_unread_patches_isread_false() {
1038 let server = MockServer::start().await;
1039 Mock::given(method("PATCH"))
1040 .and(path_regex("/me/messages/[A-Za-z0-9]+"))
1041 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
1042 .mount(&server)
1043 .await;
1044 let http = reqwest::Client::new();
1045 mark_unread(&http, &server.uri(), "AT", "MSG")
1046 .await
1047 .unwrap();
1048 }
1049
1050 #[tokio::test]
1051 async fn set_flag_patches_flag_status() {
1052 let server = MockServer::start().await;
1053 Mock::given(method("PATCH"))
1054 .and(path_regex("/me/messages/[A-Za-z0-9]+"))
1055 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
1056 .mount(&server)
1057 .await;
1058 let http = reqwest::Client::new();
1059 set_flag(&http, &server.uri(), "AT", "MSG", true)
1060 .await
1061 .unwrap();
1062 set_flag(&http, &server.uri(), "AT", "MSG", false)
1063 .await
1064 .unwrap();
1065 }
1066
1067 #[tokio::test]
1068 async fn send_mail_wraps_outgoing_in_message_envelope() {
1069 use wiremock::matchers::body_partial_json;
1070 let server = MockServer::start().await;
1071 Mock::given(method("POST"))
1072 .and(path("/me/sendMail"))
1073 .and(body_partial_json(serde_json::json!({
1074 "saveToSentItems": true,
1075 "message": {
1076 "subject": "Hello",
1077 "body": { "contentType": "Text", "content": "Hi there" },
1078 "toRecipients": [{ "emailAddress": { "address": "alice@example.com" } }]
1079 }
1080 })))
1081 .respond_with(ResponseTemplate::new(202))
1082 .mount(&server)
1083 .await;
1084 let http = reqwest::Client::new();
1085 let msg = Outgoing {
1086 subject: "Hello".into(),
1087 body_text: "Hi there".into(),
1088 to: vec!["alice@example.com".into()],
1089 cc: vec![],
1090 bcc: vec![],
1091 };
1092 send_mail(&http, &server.uri(), "AT", &msg).await.unwrap();
1093 }
1094
1095 #[tokio::test]
1096 async fn reply_message_posts_comment() {
1097 use wiremock::matchers::body_partial_json;
1098 let server = MockServer::start().await;
1099 Mock::given(method("POST"))
1100 .and(path_regex("/me/messages/[A-Za-z0-9]+/reply"))
1101 .and(body_partial_json(
1102 serde_json::json!({ "comment": "Thanks!" }),
1103 ))
1104 .respond_with(ResponseTemplate::new(202))
1105 .mount(&server)
1106 .await;
1107 let http = reqwest::Client::new();
1108 reply_message(&http, &server.uri(), "AT", "MSG", "Thanks!")
1109 .await
1110 .unwrap();
1111 }
1112
1113 #[tokio::test]
1114 async fn forward_message_posts_recipients_and_comment() {
1115 use wiremock::matchers::body_partial_json;
1116 let server = MockServer::start().await;
1117 Mock::given(method("POST"))
1118 .and(path_regex("/me/messages/[A-Za-z0-9]+/forward"))
1119 .and(body_partial_json(serde_json::json!({
1120 "comment": "FYI",
1121 "toRecipients": [{ "emailAddress": { "address": "bob@example.com" } }]
1122 })))
1123 .respond_with(ResponseTemplate::new(202))
1124 .mount(&server)
1125 .await;
1126 let http = reqwest::Client::new();
1127 forward_message(
1128 &http,
1129 &server.uri(),
1130 "AT",
1131 "MSG",
1132 &["bob@example.com".into()],
1133 "FYI",
1134 )
1135 .await
1136 .unwrap();
1137 }
1138
1139 #[tokio::test]
1140 async fn move_message_posts_destination() {
1141 let server = MockServer::start().await;
1142 Mock::given(method("POST"))
1143 .and(path_regex("/me/messages/[A-Za-z0-9]+/move"))
1144 .respond_with(
1145 ResponseTemplate::new(201).set_body_json(serde_json::json!({"id": "NEW"})),
1146 )
1147 .mount(&server)
1148 .await;
1149 let http = reqwest::Client::new();
1150 move_message(&http, &server.uri(), "AT", "MSG", "archive")
1151 .await
1152 .unwrap();
1153 }
1154
1155 #[tokio::test]
1156 async fn get_message_parses_graph_response() {
1157 let server = MockServer::start().await;
1158 Mock::given(method("GET"))
1159 .and(path_regex("/me/messages/[A-Za-z0-9]+"))
1160 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1161 "id": "AAA",
1162 "subject": "Hello",
1163 "from": { "emailAddress": { "name": "Maria", "address": "maria@mklab.se" } },
1164 "toRecipients": [
1165 { "emailAddress": { "name": "Kristofer", "address": "kristofer@mklab.se" } }
1166 ],
1167 "ccRecipients": [],
1168 "bccRecipients": [],
1169 "receivedDateTime": "2026-05-14T22:00:00Z",
1170 "sentDateTime": "2026-05-14T21:59:30Z",
1171 "isRead": false,
1172 "body": { "contentType": "html", "content": "<p>Hi</p>" },
1173 "hasAttachments": true
1174 })))
1175 .mount(&server)
1176 .await;
1177
1178 let http = reqwest::Client::new();
1179 let m = get_message(&http, &server.uri(), "AT", "u@e.com", "AAA")
1180 .await
1181 .unwrap();
1182 assert_eq!(m.id, "AAA");
1183 assert_eq!(m.subject, "Hello");
1184 assert_eq!(m.from.name, "Maria");
1185 assert_eq!(m.to.len(), 1);
1186 assert_eq!(m.to[0].address, "kristofer@mklab.se");
1187 assert!(matches!(
1188 m.body_content_type,
1189 pidge_core::BodyContentType::Html
1190 ));
1191 assert_eq!(m.body_content, "<p>Hi</p>");
1192 assert!(m.has_attachments);
1193 }
1194
1195 #[tokio::test]
1196 async fn list_attachments_filters_file_attachments() {
1197 let server = MockServer::start().await;
1198 Mock::given(method("GET"))
1199 .and(path_regex("/me/messages/[A-Za-z0-9]+/attachments"))
1200 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1201 "value": [
1202 {
1203 "@odata.type": "#microsoft.graph.fileAttachment",
1204 "id": "att-1",
1205 "name": "report.pdf",
1206 "contentType": "application/pdf",
1207 "size": 12345,
1208 "isInline": false
1209 },
1210 {
1211 "@odata.type": "#microsoft.graph.itemAttachment",
1212 "id": "att-2",
1213 "name": "an-email.eml",
1214 "contentType": "message/rfc822",
1215 "size": 7777,
1216 "isInline": false
1217 }
1218 ]
1219 })))
1220 .mount(&server)
1221 .await;
1222
1223 let http = reqwest::Client::new();
1224 let atts = list_attachments(&http, &server.uri(), "AT", "MSG")
1225 .await
1226 .unwrap();
1227 assert_eq!(atts.len(), 1);
1228 assert_eq!(atts[0].name, "report.pdf");
1229 assert_eq!(atts[0].size_bytes, 12345);
1230 }
1231
1232 #[tokio::test]
1233 async fn get_attachment_bytes_decodes_base64() {
1234 let server = MockServer::start().await;
1235 Mock::given(method("GET"))
1236 .and(path_regex(
1237 "/me/messages/[A-Za-z0-9]+/attachments/[A-Za-z0-9-]+",
1238 ))
1239 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1240 "id": "att-1",
1241 "name": "report.pdf",
1242 "contentType": "application/pdf",
1243 "size": 5,
1244 "isInline": false,
1245 "contentBytes": "aGVsbG8="
1246 })))
1247 .mount(&server)
1248 .await;
1249
1250 let http = reqwest::Client::new();
1251 let bytes = get_attachment_bytes(&http, &server.uri(), "AT", "MSG", "att-1")
1252 .await
1253 .unwrap();
1254 assert_eq!(bytes, b"hello");
1255 }
1256
1257 #[tokio::test]
1258 async fn mark_read_patches_isread_true() {
1259 let server = MockServer::start().await;
1260 Mock::given(method("PATCH"))
1261 .and(path_regex("/me/messages/[A-Za-z0-9]+"))
1262 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
1263 .mount(&server)
1264 .await;
1265
1266 let http = reqwest::Client::new();
1267 mark_read(&http, &server.uri(), "AT", "MSG").await.unwrap();
1268 }
1269}