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
599fn text_to_html(text: &str) -> String {
609 fn escape(s: &str) -> String {
610 s.replace('&', "&")
611 .replace('<', "<")
612 .replace('>', ">")
613 .replace('"', """)
614 }
615 let normalized = text.replace("\r\n", "\n").replace('\r', "\n");
616 normalized
617 .split("\n\n")
618 .filter(|p| !p.trim().is_empty())
619 .map(|paragraph| {
620 let lines: Vec<String> = paragraph
621 .trim_matches('\n')
622 .split('\n')
623 .map(escape)
624 .collect();
625 format!("<p>{}</p>", lines.join("<br>"))
626 })
627 .collect::<Vec<_>>()
628 .join("")
629}
630
631#[derive(Debug, Deserialize)]
632struct GraphBodyOnly {
633 body: GraphBody,
634}
635
636async fn prepend_html_to_draft(
640 http: &reqwest::Client,
641 base_url: &str,
642 access_token: &str,
643 message_id: &str,
644 html_to_prepend: &str,
645) -> Result<(), ClientError> {
646 let get_url = format!("{base_url}/me/messages/{message_id}?$select=body");
647 let resp = http.get(&get_url).bearer_auth(access_token).send().await?;
648 let status = resp.status();
649 if !status.is_success() {
650 let text = resp.text().await.unwrap_or_default();
651 return Err(ClientError::Graph {
652 status: status.as_u16(),
653 message: text,
654 });
655 }
656 let existing: GraphBodyOnly = resp.json().await?;
657 let existing_content = existing.body.content;
658
659 let new_content = match find_body_tag_end(&existing_content) {
663 Some(pos) => {
664 let mut s = String::with_capacity(existing_content.len() + html_to_prepend.len());
665 s.push_str(&existing_content[..pos]);
666 s.push_str(html_to_prepend);
667 s.push_str(&existing_content[pos..]);
668 s
669 }
670 None => format!("{html_to_prepend}{existing_content}"),
671 };
672
673 let patch_url = format!("{base_url}/me/messages/{message_id}");
674 let patch_body = serde_json::json!({
675 "body": {
676 "contentType": "HTML",
677 "content": new_content,
678 }
679 });
680 let resp = http
681 .patch(&patch_url)
682 .bearer_auth(access_token)
683 .json(&patch_body)
684 .send()
685 .await?;
686 let status = resp.status();
687 if !status.is_success() {
688 let text = resp.text().await.unwrap_or_default();
689 return Err(ClientError::Graph {
690 status: status.as_u16(),
691 message: text,
692 });
693 }
694 Ok(())
695}
696
697fn find_body_tag_end(html: &str) -> Option<usize> {
698 let lc = html.to_ascii_lowercase();
699 let start = lc.find("<body")?;
700 let after = &html[start..];
701 let close_rel = after.find('>')?;
702 Some(start + close_rel + 1)
703}
704
705pub async fn reply_message(
709 http: &reqwest::Client,
710 base_url: &str,
711 access_token: &str,
712 message_id: &str,
713 comment: &str,
714) -> Result<(), ClientError> {
715 let draft_id = create_reply_draft(http, base_url, access_token, message_id, comment).await?;
716 send_draft(http, base_url, access_token, &draft_id).await
717}
718
719pub async fn reply_all_message(
721 http: &reqwest::Client,
722 base_url: &str,
723 access_token: &str,
724 message_id: &str,
725 comment: &str,
726) -> Result<(), ClientError> {
727 let draft_id =
728 create_reply_all_draft(http, base_url, access_token, message_id, comment).await?;
729 send_draft(http, base_url, access_token, &draft_id).await
730}
731
732pub async fn forward_message(
734 http: &reqwest::Client,
735 base_url: &str,
736 access_token: &str,
737 message_id: &str,
738 to: &[String],
739 comment: &str,
740) -> Result<(), ClientError> {
741 let draft_id =
742 create_forward_draft(http, base_url, access_token, message_id, to, comment).await?;
743 send_draft(http, base_url, access_token, &draft_id).await
744}
745
746async fn post_no_body(
747 http: &reqwest::Client,
748 url: &str,
749 access_token: &str,
750 body: &serde_json::Value,
751) -> Result<(), ClientError> {
752 let resp = http
753 .post(url)
754 .bearer_auth(access_token)
755 .json(body)
756 .send()
757 .await?;
758 let status = resp.status();
759 if !status.is_success() {
760 let text = resp.text().await.unwrap_or_default();
761 return Err(ClientError::Graph {
762 status: status.as_u16(),
763 message: text,
764 });
765 }
766 Ok(())
767}
768
769pub async fn create_draft(
772 http: &reqwest::Client,
773 base_url: &str,
774 access_token: &str,
775 message: &Outgoing,
776) -> Result<String, ClientError> {
777 let url = format!("{base_url}/me/messages");
778 let body = message.to_graph_json();
779 let resp = http
780 .post(&url)
781 .bearer_auth(access_token)
782 .json(&body)
783 .send()
784 .await?;
785 parse_id_from_response(resp).await
786}
787
788pub async fn create_reply_draft(
795 http: &reqwest::Client,
796 base_url: &str,
797 access_token: &str,
798 message_id: &str,
799 comment: &str,
800) -> Result<String, ClientError> {
801 let url = format!("{base_url}/me/messages/{message_id}/createReply");
802 let resp = http
803 .post(&url)
804 .bearer_auth(access_token)
805 .json(&serde_json::json!({}))
806 .send()
807 .await?;
808 let draft_id = parse_id_from_response(resp).await?;
809 if !comment.is_empty() {
810 prepend_html_to_draft(
811 http,
812 base_url,
813 access_token,
814 &draft_id,
815 &text_to_html(comment),
816 )
817 .await?;
818 }
819 Ok(draft_id)
820}
821
822pub async fn create_reply_all_draft(
824 http: &reqwest::Client,
825 base_url: &str,
826 access_token: &str,
827 message_id: &str,
828 comment: &str,
829) -> Result<String, ClientError> {
830 let url = format!("{base_url}/me/messages/{message_id}/createReplyAll");
831 let resp = http
832 .post(&url)
833 .bearer_auth(access_token)
834 .json(&serde_json::json!({}))
835 .send()
836 .await?;
837 let draft_id = parse_id_from_response(resp).await?;
838 if !comment.is_empty() {
839 prepend_html_to_draft(
840 http,
841 base_url,
842 access_token,
843 &draft_id,
844 &text_to_html(comment),
845 )
846 .await?;
847 }
848 Ok(draft_id)
849}
850
851pub async fn create_forward_draft(
854 http: &reqwest::Client,
855 base_url: &str,
856 access_token: &str,
857 message_id: &str,
858 to: &[String],
859 comment: &str,
860) -> Result<String, ClientError> {
861 let url = format!("{base_url}/me/messages/{message_id}/createForward");
862 let resp = http
863 .post(&url)
864 .bearer_auth(access_token)
865 .json(&serde_json::json!({
866 "toRecipients": to.iter().map(|addr| serde_json::json!({
867 "emailAddress": { "address": addr }
868 })).collect::<Vec<_>>(),
869 }))
870 .send()
871 .await?;
872 let draft_id = parse_id_from_response(resp).await?;
873 if !comment.is_empty() {
874 prepend_html_to_draft(
875 http,
876 base_url,
877 access_token,
878 &draft_id,
879 &text_to_html(comment),
880 )
881 .await?;
882 }
883 Ok(draft_id)
884}
885
886pub async fn send_draft(
888 http: &reqwest::Client,
889 base_url: &str,
890 access_token: &str,
891 message_id: &str,
892) -> Result<(), ClientError> {
893 let url = format!("{base_url}/me/messages/{message_id}/send");
894 let resp = http
899 .post(&url)
900 .bearer_auth(access_token)
901 .header(reqwest::header::CONTENT_LENGTH, 0)
902 .body(reqwest::Body::from(""))
903 .send()
904 .await?;
905 let status = resp.status();
906 if !status.is_success() {
907 let text = resp.text().await.unwrap_or_default();
908 return Err(ClientError::Graph {
909 status: status.as_u16(),
910 message: text,
911 });
912 }
913 Ok(())
914}
915
916pub async fn update_draft(
919 http: &reqwest::Client,
920 base_url: &str,
921 access_token: &str,
922 message_id: &str,
923 message: &Outgoing,
924) -> Result<(), ClientError> {
925 let url = format!("{base_url}/me/messages/{message_id}");
926 let body = message.to_graph_json();
927 let resp = http
928 .patch(&url)
929 .bearer_auth(access_token)
930 .json(&body)
931 .send()
932 .await?;
933 let status = resp.status();
934 if !status.is_success() {
935 let text = resp.text().await.unwrap_or_default();
936 return Err(ClientError::Graph {
937 status: status.as_u16(),
938 message: text,
939 });
940 }
941 Ok(())
942}
943
944pub async fn delete_message(
948 http: &reqwest::Client,
949 base_url: &str,
950 access_token: &str,
951 message_id: &str,
952) -> Result<(), ClientError> {
953 let url = format!("{base_url}/me/messages/{message_id}");
954 let resp = http.delete(&url).bearer_auth(access_token).send().await?;
955 let status = resp.status();
956 if !status.is_success() {
957 let text = resp.text().await.unwrap_or_default();
958 return Err(ClientError::Graph {
959 status: status.as_u16(),
960 message: text,
961 });
962 }
963 Ok(())
964}
965
966pub async fn add_attachment(
972 http: &reqwest::Client,
973 base_url: &str,
974 access_token: &str,
975 message_id: &str,
976 name: &str,
977 content_type: &str,
978 bytes: &[u8],
979) -> Result<String, ClientError> {
980 use base64::Engine;
981 use base64::engine::general_purpose::STANDARD;
982
983 let url = format!("{base_url}/me/messages/{message_id}/attachments");
984 let body = serde_json::json!({
985 "@odata.type": "#microsoft.graph.fileAttachment",
986 "name": name,
987 "contentType": content_type,
988 "contentBytes": STANDARD.encode(bytes),
989 });
990 let resp = http
991 .post(&url)
992 .bearer_auth(access_token)
993 .json(&body)
994 .send()
995 .await?;
996 parse_id_from_response(resp).await
997}
998
999pub async fn delete_attachment(
1001 http: &reqwest::Client,
1002 base_url: &str,
1003 access_token: &str,
1004 message_id: &str,
1005 attachment_id: &str,
1006) -> Result<(), ClientError> {
1007 let url = format!("{base_url}/me/messages/{message_id}/attachments/{attachment_id}");
1008 let resp = http.delete(&url).bearer_auth(access_token).send().await?;
1009 let status = resp.status();
1010 if !status.is_success() {
1011 let text = resp.text().await.unwrap_or_default();
1012 return Err(ClientError::Graph {
1013 status: status.as_u16(),
1014 message: text,
1015 });
1016 }
1017 Ok(())
1018}
1019
1020async fn parse_id_from_response(resp: reqwest::Response) -> Result<String, ClientError> {
1021 let status = resp.status();
1022 if !status.is_success() {
1023 let text = resp.text().await.unwrap_or_default();
1024 return Err(ClientError::Graph {
1025 status: status.as_u16(),
1026 message: text,
1027 });
1028 }
1029 let v: serde_json::Value = resp.json().await?;
1030 v["id"]
1031 .as_str()
1032 .map(|s| s.to_string())
1033 .ok_or_else(|| ClientError::Graph {
1034 status: 200,
1035 message: "draft response missing 'id'".to_string(),
1036 })
1037}
1038
1039#[derive(Debug, Clone)]
1041pub struct Outgoing {
1042 pub subject: String,
1043 pub body_text: String,
1044 pub to: Vec<String>,
1045 pub cc: Vec<String>,
1046 pub bcc: Vec<String>,
1047}
1048
1049impl Outgoing {
1050 fn to_graph_json(&self) -> serde_json::Value {
1051 fn addresses(list: &[String]) -> Vec<serde_json::Value> {
1052 list.iter()
1053 .map(|addr| serde_json::json!({ "emailAddress": { "address": addr } }))
1054 .collect()
1055 }
1056 serde_json::json!({
1057 "subject": self.subject,
1058 "body": {
1059 "contentType": "Text",
1060 "content": self.body_text,
1061 },
1062 "toRecipients": addresses(&self.to),
1063 "ccRecipients": addresses(&self.cc),
1064 "bccRecipients": addresses(&self.bcc),
1065 })
1066 }
1067}
1068
1069pub async fn move_message(
1076 http: &reqwest::Client,
1077 base_url: &str,
1078 access_token: &str,
1079 message_id: &str,
1080 destination: &str,
1081) -> Result<(), ClientError> {
1082 let url = format!("{base_url}/me/messages/{message_id}/move");
1083 let resp = http
1084 .post(&url)
1085 .bearer_auth(access_token)
1086 .json(&serde_json::json!({ "destinationId": destination }))
1087 .send()
1088 .await?;
1089 let status = resp.status();
1090 if !status.is_success() {
1091 let text = resp.text().await.unwrap_or_default();
1092 return Err(ClientError::Graph {
1093 status: status.as_u16(),
1094 message: text,
1095 });
1096 }
1097 Ok(())
1098}
1099
1100#[cfg(test)]
1101mod tests {
1102 use super::*;
1103 use wiremock::matchers::{header, method, path, path_regex, query_param};
1104 use wiremock::{Mock, MockServer, ResponseTemplate};
1105
1106 #[tokio::test]
1107 async fn list_inbox_parses_graph_response() {
1108 let server = MockServer::start().await;
1109 Mock::given(method("GET"))
1110 .and(path("/me/mailFolders/inbox/messages"))
1111 .and(header("authorization", "Bearer AT"))
1112 .and(query_param("$top", "5"))
1113 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1114 "value": [
1115 {
1116 "id": "AAAA",
1117 "subject": "Hello",
1118 "from": {
1119 "emailAddress": {
1120 "name": "Maria",
1121 "address": "maria@mklab.se"
1122 }
1123 },
1124 "receivedDateTime": "2026-05-13T22:00:00Z",
1125 "isRead": false,
1126 "bodyPreview": "Hi there"
1127 }
1128 ]
1129 })))
1130 .mount(&server)
1131 .await;
1132
1133 let http = reqwest::Client::new();
1134 let page = list_inbox(&http, &server.uri(), "AT", "u@e.com", 5, 0, false)
1135 .await
1136 .unwrap();
1137 assert_eq!(page.messages.len(), 1);
1138 assert_eq!(page.messages[0].subject, "Hello");
1139 assert_eq!(page.messages[0].from.address, "maria@mklab.se");
1140 assert!(!page.messages[0].is_read);
1141 assert_eq!(page.messages[0].account, "u@e.com");
1142 assert!(!page.has_more);
1143 }
1144
1145 #[tokio::test]
1146 async fn list_inbox_adds_filter_when_unread_only() {
1147 let server = MockServer::start().await;
1148 Mock::given(method("GET"))
1149 .and(path("/me/mailFolders/inbox/messages"))
1150 .and(query_param("$filter", "isRead eq false"))
1151 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1152 "value": []
1153 })))
1154 .mount(&server)
1155 .await;
1156
1157 let http = reqwest::Client::new();
1158 let page = list_inbox(&http, &server.uri(), "AT", "u@e.com", 5, 0, true)
1159 .await
1160 .unwrap();
1161 assert!(page.messages.is_empty());
1162 }
1163
1164 #[tokio::test]
1165 async fn list_inbox_passes_skip_when_nonzero() {
1166 let server = MockServer::start().await;
1167 Mock::given(method("GET"))
1168 .and(path("/me/mailFolders/inbox/messages"))
1169 .and(query_param("$skip", "25"))
1170 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1171 "value": [],
1172 "@odata.nextLink": "https://graph.example/next"
1173 })))
1174 .mount(&server)
1175 .await;
1176
1177 let http = reqwest::Client::new();
1178 let page = list_inbox(&http, &server.uri(), "AT", "u@e.com", 25, 25, false)
1179 .await
1180 .unwrap();
1181 assert!(page.has_more);
1182 }
1183
1184 #[tokio::test]
1185 async fn search_messages_passes_search_query() {
1186 let server = MockServer::start().await;
1187 Mock::given(method("GET"))
1188 .and(path("/me/messages"))
1189 .and(query_param("$search", "\"alice budget\""))
1190 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1191 "value": [
1192 {
1193 "id": "S1",
1194 "subject": "Q4 budget review",
1195 "from": { "emailAddress": { "name": "Alice", "address": "alice@example.com" } },
1196 "receivedDateTime": "2026-05-13T22:00:00Z",
1197 "isRead": true,
1198 "bodyPreview": "Numbers attached"
1199 }
1200 ]
1201 })))
1202 .mount(&server)
1203 .await;
1204
1205 let http = reqwest::Client::new();
1206 let msgs = search_messages(&http, &server.uri(), "AT", "u@e.com", "alice budget", 25)
1207 .await
1208 .unwrap();
1209 assert_eq!(msgs.len(), 1);
1210 assert_eq!(msgs[0].subject, "Q4 budget review");
1211 }
1212
1213 #[tokio::test]
1214 async fn mark_unread_patches_isread_false() {
1215 let server = MockServer::start().await;
1216 Mock::given(method("PATCH"))
1217 .and(path_regex("/me/messages/[A-Za-z0-9]+"))
1218 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
1219 .mount(&server)
1220 .await;
1221 let http = reqwest::Client::new();
1222 mark_unread(&http, &server.uri(), "AT", "MSG")
1223 .await
1224 .unwrap();
1225 }
1226
1227 #[tokio::test]
1228 async fn set_flag_patches_flag_status() {
1229 let server = MockServer::start().await;
1230 Mock::given(method("PATCH"))
1231 .and(path_regex("/me/messages/[A-Za-z0-9]+"))
1232 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
1233 .mount(&server)
1234 .await;
1235 let http = reqwest::Client::new();
1236 set_flag(&http, &server.uri(), "AT", "MSG", true)
1237 .await
1238 .unwrap();
1239 set_flag(&http, &server.uri(), "AT", "MSG", false)
1240 .await
1241 .unwrap();
1242 }
1243
1244 #[tokio::test]
1245 async fn send_mail_wraps_outgoing_in_message_envelope() {
1246 use wiremock::matchers::body_partial_json;
1247 let server = MockServer::start().await;
1248 Mock::given(method("POST"))
1249 .and(path("/me/sendMail"))
1250 .and(body_partial_json(serde_json::json!({
1251 "saveToSentItems": true,
1252 "message": {
1253 "subject": "Hello",
1254 "body": { "contentType": "Text", "content": "Hi there" },
1255 "toRecipients": [{ "emailAddress": { "address": "alice@example.com" } }]
1256 }
1257 })))
1258 .respond_with(ResponseTemplate::new(202))
1259 .mount(&server)
1260 .await;
1261 let http = reqwest::Client::new();
1262 let msg = Outgoing {
1263 subject: "Hello".into(),
1264 body_text: "Hi there".into(),
1265 to: vec!["alice@example.com".into()],
1266 cc: vec![],
1267 bcc: vec![],
1268 };
1269 send_mail(&http, &server.uri(), "AT", &msg).await.unwrap();
1270 }
1271
1272 #[tokio::test]
1273 async fn reply_message_creates_draft_patches_body_and_sends() {
1274 use wiremock::matchers::body_partial_json;
1275 let server = MockServer::start().await;
1276
1277 Mock::given(method("POST"))
1279 .and(path_regex("/me/messages/MSG/createReply"))
1280 .respond_with(
1281 ResponseTemplate::new(201).set_body_json(serde_json::json!({ "id": "DRAFT" })),
1282 )
1283 .mount(&server)
1284 .await;
1285 Mock::given(method("GET"))
1287 .and(path("/me/messages/DRAFT"))
1288 .and(query_param("$select", "body"))
1289 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1290 "body": { "contentType": "HTML", "content": "<html><body><div></div></body></html>" }
1291 })))
1292 .mount(&server)
1293 .await;
1294 Mock::given(method("PATCH"))
1296 .and(path("/me/messages/DRAFT"))
1297 .and(body_partial_json(serde_json::json!({
1298 "body": { "contentType": "HTML" }
1299 })))
1300 .respond_with(ResponseTemplate::new(200))
1301 .mount(&server)
1302 .await;
1303 Mock::given(method("POST"))
1305 .and(path("/me/messages/DRAFT/send"))
1306 .respond_with(ResponseTemplate::new(202))
1307 .mount(&server)
1308 .await;
1309
1310 let http = reqwest::Client::new();
1311 reply_message(&http, &server.uri(), "AT", "MSG", "Thanks!")
1312 .await
1313 .unwrap();
1314 }
1315
1316 #[tokio::test]
1317 async fn forward_message_creates_draft_with_recipients_and_sends() {
1318 use wiremock::matchers::body_partial_json;
1319 let server = MockServer::start().await;
1320
1321 Mock::given(method("POST"))
1322 .and(path_regex("/me/messages/MSG/createForward"))
1323 .and(body_partial_json(serde_json::json!({
1324 "toRecipients": [{ "emailAddress": { "address": "bob@example.com" } }]
1325 })))
1326 .respond_with(
1327 ResponseTemplate::new(201).set_body_json(serde_json::json!({ "id": "DRAFT" })),
1328 )
1329 .mount(&server)
1330 .await;
1331 Mock::given(method("GET"))
1332 .and(path("/me/messages/DRAFT"))
1333 .and(query_param("$select", "body"))
1334 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1335 "body": { "contentType": "HTML", "content": "<html><body></body></html>" }
1336 })))
1337 .mount(&server)
1338 .await;
1339 Mock::given(method("PATCH"))
1340 .and(path("/me/messages/DRAFT"))
1341 .respond_with(ResponseTemplate::new(200))
1342 .mount(&server)
1343 .await;
1344 Mock::given(method("POST"))
1345 .and(path("/me/messages/DRAFT/send"))
1346 .respond_with(ResponseTemplate::new(202))
1347 .mount(&server)
1348 .await;
1349
1350 let http = reqwest::Client::new();
1351 forward_message(
1352 &http,
1353 &server.uri(),
1354 "AT",
1355 "MSG",
1356 &["bob@example.com".into()],
1357 "FYI",
1358 )
1359 .await
1360 .unwrap();
1361 }
1362
1363 #[test]
1364 fn text_to_html_escapes_and_breaks_paragraphs() {
1365 let out = text_to_html("Hej Edward,\n\nLine 1\nLine 2\n\n<script>x</script>");
1366 assert!(out.contains("<p>Hej Edward,</p>"));
1367 assert!(out.contains("<p>Line 1<br>Line 2</p>"));
1368 assert!(out.contains("<script>x</script>"));
1369 assert!(!out.contains("<script>"));
1370 }
1371
1372 #[test]
1373 fn text_to_html_normalizes_crlf() {
1374 let out = text_to_html("A\r\nB\r\n\r\nC");
1375 assert!(out.contains("<p>A<br>B</p>"));
1376 assert!(out.contains("<p>C</p>"));
1377 }
1378
1379 #[test]
1380 fn find_body_tag_end_handles_attributes_and_case() {
1381 let html = "<html><BODY class=\"x\">content</BODY></html>";
1382 let pos = find_body_tag_end(html).unwrap();
1383 assert_eq!(&html[pos..pos + 7], "content");
1384 }
1385
1386 #[tokio::test]
1387 async fn move_message_posts_destination() {
1388 let server = MockServer::start().await;
1389 Mock::given(method("POST"))
1390 .and(path_regex("/me/messages/[A-Za-z0-9]+/move"))
1391 .respond_with(
1392 ResponseTemplate::new(201).set_body_json(serde_json::json!({"id": "NEW"})),
1393 )
1394 .mount(&server)
1395 .await;
1396 let http = reqwest::Client::new();
1397 move_message(&http, &server.uri(), "AT", "MSG", "archive")
1398 .await
1399 .unwrap();
1400 }
1401
1402 #[tokio::test]
1403 async fn get_message_parses_graph_response() {
1404 let server = MockServer::start().await;
1405 Mock::given(method("GET"))
1406 .and(path_regex("/me/messages/[A-Za-z0-9]+"))
1407 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1408 "id": "AAA",
1409 "subject": "Hello",
1410 "from": { "emailAddress": { "name": "Maria", "address": "maria@mklab.se" } },
1411 "toRecipients": [
1412 { "emailAddress": { "name": "Kristofer", "address": "kristofer@mklab.se" } }
1413 ],
1414 "ccRecipients": [],
1415 "bccRecipients": [],
1416 "receivedDateTime": "2026-05-14T22:00:00Z",
1417 "sentDateTime": "2026-05-14T21:59:30Z",
1418 "isRead": false,
1419 "body": { "contentType": "html", "content": "<p>Hi</p>" },
1420 "hasAttachments": true
1421 })))
1422 .mount(&server)
1423 .await;
1424
1425 let http = reqwest::Client::new();
1426 let m = get_message(&http, &server.uri(), "AT", "u@e.com", "AAA")
1427 .await
1428 .unwrap();
1429 assert_eq!(m.id, "AAA");
1430 assert_eq!(m.subject, "Hello");
1431 assert_eq!(m.from.name, "Maria");
1432 assert_eq!(m.to.len(), 1);
1433 assert_eq!(m.to[0].address, "kristofer@mklab.se");
1434 assert!(matches!(
1435 m.body_content_type,
1436 pidge_core::BodyContentType::Html
1437 ));
1438 assert_eq!(m.body_content, "<p>Hi</p>");
1439 assert!(m.has_attachments);
1440 }
1441
1442 #[tokio::test]
1443 async fn list_attachments_filters_file_attachments() {
1444 let server = MockServer::start().await;
1445 Mock::given(method("GET"))
1446 .and(path_regex("/me/messages/[A-Za-z0-9]+/attachments"))
1447 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1448 "value": [
1449 {
1450 "@odata.type": "#microsoft.graph.fileAttachment",
1451 "id": "att-1",
1452 "name": "report.pdf",
1453 "contentType": "application/pdf",
1454 "size": 12345,
1455 "isInline": false
1456 },
1457 {
1458 "@odata.type": "#microsoft.graph.itemAttachment",
1459 "id": "att-2",
1460 "name": "an-email.eml",
1461 "contentType": "message/rfc822",
1462 "size": 7777,
1463 "isInline": false
1464 }
1465 ]
1466 })))
1467 .mount(&server)
1468 .await;
1469
1470 let http = reqwest::Client::new();
1471 let atts = list_attachments(&http, &server.uri(), "AT", "MSG")
1472 .await
1473 .unwrap();
1474 assert_eq!(atts.len(), 1);
1475 assert_eq!(atts[0].name, "report.pdf");
1476 assert_eq!(atts[0].size_bytes, 12345);
1477 }
1478
1479 #[tokio::test]
1480 async fn get_attachment_bytes_decodes_base64() {
1481 let server = MockServer::start().await;
1482 Mock::given(method("GET"))
1483 .and(path_regex(
1484 "/me/messages/[A-Za-z0-9]+/attachments/[A-Za-z0-9-]+",
1485 ))
1486 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1487 "id": "att-1",
1488 "name": "report.pdf",
1489 "contentType": "application/pdf",
1490 "size": 5,
1491 "isInline": false,
1492 "contentBytes": "aGVsbG8="
1493 })))
1494 .mount(&server)
1495 .await;
1496
1497 let http = reqwest::Client::new();
1498 let bytes = get_attachment_bytes(&http, &server.uri(), "AT", "MSG", "att-1")
1499 .await
1500 .unwrap();
1501 assert_eq!(bytes, b"hello");
1502 }
1503
1504 #[tokio::test]
1505 async fn mark_read_patches_isread_true() {
1506 let server = MockServer::start().await;
1507 Mock::given(method("PATCH"))
1508 .and(path_regex("/me/messages/[A-Za-z0-9]+"))
1509 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
1510 .mount(&server)
1511 .await;
1512
1513 let http = reqwest::Client::new();
1514 mark_read(&http, &server.uri(), "AT", "MSG").await.unwrap();
1515 }
1516
1517 #[tokio::test]
1518 async fn fetch_message_headers_parses_array() {
1519 let server = MockServer::start().await;
1520 Mock::given(method("GET"))
1521 .and(path_regex(r"^/me/messages/.+$"))
1522 .and(query_param("$select", "internetMessageHeaders"))
1523 .and(header("authorization", "Bearer AT"))
1524 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1525 "internetMessageHeaders": [
1526 { "name": "List-Unsubscribe", "value": "<mailto:u@x>, <https://x/u>" },
1527 { "name": "List-Unsubscribe-Post", "value": "List-Unsubscribe=One-Click" }
1528 ]
1529 })))
1530 .mount(&server)
1531 .await;
1532
1533 let http = reqwest::Client::new();
1534 let headers = fetch_message_headers(&http, &server.uri(), "AT", "MSGID")
1535 .await
1536 .unwrap();
1537 assert_eq!(headers.len(), 2);
1538 assert_eq!(headers[0].0, "List-Unsubscribe");
1539 assert_eq!(headers[0].1, "<mailto:u@x>, <https://x/u>");
1540 assert_eq!(headers[1].0, "List-Unsubscribe-Post");
1541 }
1542}