Skip to main content

max / goingson

23.8 KB · 724 lines History Blame Raw
1 //! JMAP Email operations.
2
3 use chrono::{DateTime, Utc};
4 use super::client::JmapClient;
5 use super::types::{EmailFilter, JmapEmail, JmapRequest, SortCondition};
6 use serde_json::json;
7 use tracing::instrument;
8
9 /// Parsed email for storage (similar to IMAP ParsedEmail).
10 #[derive(Debug, Clone)]
11 pub struct JmapParsedEmail {
12 /// JMAP email ID
13 pub jmap_id: String,
14 /// Message-ID header
15 pub message_id: Option<String>,
16 /// In-Reply-To header
17 pub in_reply_to: Option<String>,
18 /// First entry from the References header (thread root).
19 pub references_root: Option<String>,
20 /// Source mailbox name
21 pub source_folder: String,
22 /// From address
23 pub from: String,
24 /// To address
25 pub to: String,
26 /// Subject
27 pub subject: String,
28 /// Body text
29 pub body: String,
30 /// Whether the body was truncated by the 100KB sync cap.
31 pub body_truncated: bool,
32 /// Received date
33 pub date: DateTime<Utc>,
34 /// Whether email has been read
35 pub is_read: bool,
36 }
37
38 impl JmapClient {
39 /// Fetches emails from a mailbox.
40 ///
41 /// # Arguments
42 /// * `mailbox_id` - The mailbox ID (use `inbox().await?.id` for inbox)
43 /// * `since` - Optional date filter for incremental sync
44 /// * `limit` - Maximum number of emails to fetch
45 #[instrument(skip_all, fields(limit = limit))]
46 pub async fn fetch_emails(
47 &mut self,
48 mailbox_id: &str,
49 since: Option<DateTime<Utc>>,
50 limit: u32,
51 ) -> Result<Vec<JmapParsedEmail>, String> {
52 let account_id = self.account_id().await?;
53
54 // Build filter
55 let mut filter = EmailFilter {
56 in_mailbox: Some(mailbox_id.to_string()),
57 ..Default::default()
58 };
59 if let Some(after) = since {
60 filter.after = Some(after);
61 }
62
63 // Query for email IDs
64 let query_args = json!({
65 "accountId": account_id,
66 "filter": filter,
67 "sort": [SortCondition::received_desc()],
68 "position": 0,
69 "limit": limit,
70 "calculateTotal": false
71 });
72
73 let query_response = self.call("Email/query", query_args).await?;
74 let email_ids: Vec<String> =
75 serde_json::from_value(query_response.data["ids"].clone())
76 .map_err(|e| format!("Failed to parse email IDs: {}", e))?;
77
78 if email_ids.is_empty() {
79 return Ok(Vec::new());
80 }
81
82 // Fetch email details
83 let get_args = json!({
84 "accountId": account_id,
85 "ids": email_ids,
86 "properties": [
87 "id", "threadId", "mailboxIds", "keywords",
88 "receivedAt", "messageId", "inReplyTo", "references",
89 "from", "to", "subject", "preview",
90 "bodyValues", "textBody"
91 ],
92 "fetchTextBodyValues": true,
93 "maxBodyValueBytes": 100000
94 });
95
96 let get_response = self.call("Email/get", get_args).await?;
97 let emails: Vec<JmapEmail> =
98 serde_json::from_value(get_response.data["list"].clone())
99 .map_err(|e| format!("Failed to parse emails: {}", e))?;
100
101 // Get mailbox name
102 let mailbox = self.list_mailboxes().await?
103 .into_iter()
104 .find(|m| m.id == mailbox_id)
105 .map(|m| m.name)
106 .unwrap_or_else(|| "Unknown".to_string());
107
108 // Convert to parsed emails
109 let mut parsed = Vec::new();
110 for email in emails {
111 let from = email
112 .from
113 .as_ref()
114 .and_then(|addrs| addrs.first())
115 .map(|a| a.to_string())
116 .unwrap_or_default();
117
118 let to = email
119 .to
120 .as_ref()
121 .and_then(|addrs| addrs.first())
122 .map(|a| a.to_string())
123 .unwrap_or_default();
124
125 let (body, body_truncated) = Self::extract_body(&email);
126
127 let is_read = email
128 .keywords
129 .as_ref()
130 .map(|k| k.contains_key("$seen"))
131 .unwrap_or(false);
132
133 let message_id = email
134 .message_id
135 .as_ref()
136 .and_then(|ids| ids.first())
137 .cloned();
138
139 let in_reply_to = email
140 .in_reply_to
141 .as_ref()
142 .and_then(|ids| ids.first())
143 .cloned();
144
145 let references_root = email
146 .references
147 .as_ref()
148 .and_then(|refs| refs.first())
149 .cloned();
150
151 parsed.push(JmapParsedEmail {
152 jmap_id: email.id,
153 message_id,
154 in_reply_to,
155 references_root,
156 source_folder: mailbox.clone(),
157 from,
158 to,
159 subject: email.subject.unwrap_or_default(),
160 body,
161 body_truncated,
162 // Fall back to the epoch (matching the IMAP path) rather than
163 // "now": a JMAP server omitting receivedAt must not stamp every
164 // such message with the sync time and corrupt mailbox ordering.
165 date: email.received_at.unwrap_or(DateTime::UNIX_EPOCH),
166 is_read,
167 });
168 }
169
170 Ok(parsed)
171 }
172
173 /// Fetches emails from inbox.
174 #[instrument(skip_all, fields(limit = limit))]
175 pub async fn fetch_inbox(
176 &mut self,
177 since: Option<DateTime<Utc>>,
178 limit: u32,
179 ) -> Result<Vec<JmapParsedEmail>, String> {
180 let inbox = self.inbox().await?;
181 self.fetch_emails(&inbox.id, since, limit).await
182 }
183
184 /// Fetches emails from archive.
185 #[instrument(skip_all, fields(limit = limit))]
186 pub async fn fetch_archive(
187 &mut self,
188 since: Option<DateTime<Utc>>,
189 limit: u32,
190 ) -> Result<Vec<JmapParsedEmail>, String> {
191 let archive = self.archive_mailbox().await?;
192 self.fetch_emails(&archive.id, since, limit).await
193 }
194
195 /// Marks an email as read.
196 #[instrument(skip_all, fields(email_id = %email_id))]
197 pub async fn mark_read(&mut self, email_id: &str) -> Result<(), String> {
198 self.set_keyword(email_id, "$seen", true).await
199 }
200
201 /// Marks an email as unread.
202 #[instrument(skip_all, fields(email_id = %email_id))]
203 pub async fn mark_unread(&mut self, email_id: &str) -> Result<(), String> {
204 self.set_keyword(email_id, "$seen", false).await
205 }
206
207 /// Sets or removes a keyword on an email.
208 #[instrument(skip_all, fields(email_id = %email_id, keyword = %keyword, set = set))]
209 async fn set_keyword(&mut self, email_id: &str, keyword: &str, set: bool) -> Result<(), String> {
210 let account_id = self.account_id().await?;
211
212 let keyword_path = format!("keywords/{}", keyword);
213 let mut email_patch = serde_json::Map::new();
214 email_patch.insert(keyword_path, if set { json!(true) } else { json!(null) });
215 let mut update_map = serde_json::Map::new();
216 update_map.insert(email_id.to_string(), json!(email_patch));
217 let update_args = json!({
218 "accountId": account_id,
219 "update": update_map
220 });
221
222 let response = self.call("Email/set", update_args).await?;
223
224 if let Some(not_updated) = response.data["notUpdated"].as_object()
225 && let Some(error) = not_updated.get(email_id) {
226 let error_type = error["type"].as_str().unwrap_or("unknown");
227 let description = error["description"].as_str().unwrap_or("Unknown error");
228 return Err(format!("Failed to update email ({}): {}", error_type, description));
229 }
230
231 Ok(())
232 }
233
234 /// Sends an email via JMAP Submission.
235 #[instrument(skip_all)]
236 pub async fn send_email(
237 &mut self,
238 to: &str,
239 subject: &str,
240 body: &str,
241 ) -> Result<String, String> {
242 let account_id = self.account_id().await?;
243 let username = self.username().await?;
244
245 // Get identity ID (usually matches the account)
246 let identity_response = self.call("Identity/get", json!({
247 "accountId": account_id,
248 "ids": null
249 })).await?;
250
251 let identities: Vec<serde_json::Value> =
252 serde_json::from_value(identity_response.data["list"].clone())
253 .map_err(|e| format!("Failed to parse identities: {}", e))?;
254
255 let identity_id = identities
256 .first()
257 .and_then(|i| i["id"].as_str())
258 .ok_or_else(|| "No identity found".to_string())?
259 .to_string();
260
261 // Create the email
262 let sent_mailbox = self.sent_mailbox().await?;
263 let email_create_id = "email_create";
264
265 let mut request = JmapRequest::new();
266
267 // Email/set to create the email
268 let mut mailbox_ids = serde_json::Map::new();
269 mailbox_ids.insert(sent_mailbox.id.clone(), json!(true));
270 let mut create_map = serde_json::Map::new();
271 create_map.insert(email_create_id.to_string(), json!({
272 "mailboxIds": mailbox_ids,
273 "from": [{ "email": username }],
274 "to": [{ "email": to }],
275 "subject": subject,
276 "textBody": [{
277 "partId": "body",
278 "type": "text/plain"
279 }],
280 "bodyValues": {
281 "body": {
282 "value": body
283 }
284 }
285 }));
286 request.add_call(
287 "Email/set",
288 json!({
289 "accountId": account_id,
290 "create": create_map
291 }),
292 "0",
293 );
294
295 // EmailSubmission/set to send it
296 let email_ref = format!("#{}", email_create_id);
297 let mut on_success = serde_json::Map::new();
298 on_success.insert(email_ref.clone(), json!({ "keywords/$draft": null }));
299 request.add_call(
300 "EmailSubmission/set",
301 json!({
302 "accountId": account_id,
303 "create": {
304 "send": {
305 "identityId": identity_id,
306 "emailId": email_ref
307 }
308 },
309 "onSuccessUpdateEmail": on_success
310 }),
311 "1",
312 );
313
314 let response = self.execute(request).await?;
315
316 // Extract the created email ID
317 for method_response in &response.method_responses {
318 if method_response.method == "Email/set" {
319 if let Some(created) = method_response.data["created"].as_object()
320 && let Some(email) = created.get(email_create_id)
321 && let Some(id) = email["id"].as_str() {
322 return Ok(id.to_string());
323 }
324 if let Some(not_created) = method_response.data["notCreated"].as_object()
325 && let Some(error) = not_created.get(email_create_id) {
326 let error_type = error["type"].as_str().unwrap_or("unknown");
327 let description = error["description"].as_str().unwrap_or("Unknown error");
328 return Err(format!("Failed to create email ({}): {}", error_type, description));
329 }
330 }
331 }
332
333 Err("Failed to send email: no response".to_string())
334 }
335
336 /// Extracts the text body from a JMAP email, plus whether it was truncated
337 /// by the server's `maxBodyValueBytes` cap.
338 fn extract_body(email: &JmapEmail) -> (String, bool) {
339 // First try to get from bodyValues using textBody part IDs
340 if let (Some(body_values), Some(text_body)) = (&email.body_values, &email.text_body) {
341 for part in text_body {
342 if let Some(part_id) = &part.part_id
343 && let Some(body_value) = body_values.get(part_id) {
344 return (body_value.value.clone(), body_value.is_truncated.unwrap_or(false));
345 }
346 }
347 }
348
349 // Fall back to preview (always a short, non-truncated summary)
350 (email.preview.clone().unwrap_or_default(), false)
351 }
352
353 /// Re-fetches a single email's full text body by JMAP id, without the 100KB
354 /// sync cap. Used to lazily load a body that was truncated during sync.
355 #[instrument(skip_all, fields(jmap_id = %jmap_id))]
356 pub async fn fetch_email_full_body(&mut self, jmap_id: &str) -> Result<String, String> {
357 let account_id = self.account_id().await?;
358
359 let get_args = json!({
360 "accountId": account_id,
361 "ids": [jmap_id],
362 "properties": ["id", "bodyValues", "textBody", "preview"],
363 "fetchTextBodyValues": true,
364 // 25 MB ceiling — large enough for any realistic message body.
365 "maxBodyValueBytes": 25_000_000
366 });
367
368 let get_response = self.call("Email/get", get_args).await?;
369 let emails: Vec<JmapEmail> = serde_json::from_value(get_response.data["list"].clone())
370 .map_err(|e| format!("Failed to parse email: {}", e))?;
371
372 let email = emails
373 .into_iter()
374 .next()
375 .ok_or_else(|| "Email not found".to_string())?;
376
377 let (body, _truncated) = Self::extract_body(&email);
378 Ok(body)
379 }
380 }
381
382 /// Tests JMAP connection by fetching session info.
383 #[instrument(skip_all)]
384 pub async fn test_connection(session_url: &str, access_token: &str) -> Result<String, String> {
385 let mut client = JmapClient::new(session_url, access_token)?;
386 let session = client.session().await?;
387 Ok(format!(
388 "Connected as: {} (account: {})",
389 session.username,
390 session.primary_email_account().unwrap_or("unknown")
391 ))
392 }
393
394 #[cfg(test)]
395 mod tests {
396 use super::*;
397 use crate::jmap::types::*;
398 use serde_json::json;
399 // Helper to build a JmapEmail from JSON (leveraging serde)
400 fn email_from_json(value: serde_json::Value) -> JmapEmail {
401 serde_json::from_value(value).unwrap()
402 }
403
404 // ---- extract_body tests ----
405
406 #[test]
407 fn extract_body_from_body_values_and_text_body() {
408 let email = email_from_json(json!({
409 "id": "e1",
410 "bodyValues": {
411 "1": {"value": "Hello, this is the full body text."}
412 },
413 "textBody": [{"partId": "1", "type": "text/plain"}]
414 }));
415 let (body, _truncated) = JmapClient::extract_body(&email);
416 assert_eq!(body, "Hello, this is the full body text.");
417 }
418
419 #[test]
420 fn extract_body_multiple_parts_uses_first_match() {
421 let email = email_from_json(json!({
422 "id": "e2",
423 "bodyValues": {
424 "1": {"value": "Part 1 text"},
425 "2": {"value": "Part 2 text"}
426 },
427 "textBody": [
428 {"partId": "1", "type": "text/plain"},
429 {"partId": "2", "type": "text/plain"}
430 ]
431 }));
432 let (body, _truncated) = JmapClient::extract_body(&email);
433 assert_eq!(body, "Part 1 text");
434 }
435
436 #[test]
437 fn extract_body_falls_back_to_preview() {
438 let email = email_from_json(json!({
439 "id": "e3",
440 "preview": "This is the preview text..."
441 }));
442 let (body, _truncated) = JmapClient::extract_body(&email);
443 assert_eq!(body, "This is the preview text...");
444 }
445
446 #[test]
447 fn extract_body_empty_when_no_body_and_no_preview() {
448 let email = email_from_json(json!({
449 "id": "e4"
450 }));
451 let (body, _truncated) = JmapClient::extract_body(&email);
452 assert_eq!(body, "");
453 }
454
455 #[test]
456 fn extract_body_with_body_values_but_no_text_body() {
457 // bodyValues exist but textBody is missing -- should fall back to preview
458 let email = email_from_json(json!({
459 "id": "e5",
460 "bodyValues": {
461 "1": {"value": "Orphaned body value"}
462 },
463 "preview": "Fallback preview"
464 }));
465 let (body, _truncated) = JmapClient::extract_body(&email);
466 assert_eq!(body, "Fallback preview");
467 }
468
469 #[test]
470 fn extract_body_with_text_body_but_no_body_values() {
471 // textBody exists but bodyValues is missing -- should fall back to preview
472 let email = email_from_json(json!({
473 "id": "e6",
474 "textBody": [{"partId": "1", "type": "text/plain"}],
475 "preview": "Fallback preview"
476 }));
477 let (body, _truncated) = JmapClient::extract_body(&email);
478 assert_eq!(body, "Fallback preview");
479 }
480
481 #[test]
482 fn extract_body_part_id_not_in_body_values() {
483 // textBody references a part ID that doesn't exist in bodyValues
484 let email = email_from_json(json!({
485 "id": "e7",
486 "bodyValues": {
487 "99": {"value": "Wrong part"}
488 },
489 "textBody": [{"partId": "1", "type": "text/plain"}],
490 "preview": "Fallback"
491 }));
492 let (body, _truncated) = JmapClient::extract_body(&email);
493 assert_eq!(body, "Fallback");
494 }
495
496 #[test]
497 fn extract_body_text_body_without_part_id() {
498 let email = email_from_json(json!({
499 "id": "e8",
500 "bodyValues": {
501 "1": {"value": "Body text"}
502 },
503 "textBody": [{"type": "text/plain"}],
504 "preview": "Preview text"
505 }));
506 let (body, _truncated) = JmapClient::extract_body(&email);
507 // No partId on the text body part, so can't look up in bodyValues
508 assert_eq!(body, "Preview text");
509 }
510
511 #[test]
512 fn extract_body_empty_body_value() {
513 let email = email_from_json(json!({
514 "id": "e9",
515 "bodyValues": {
516 "1": {"value": ""}
517 },
518 "textBody": [{"partId": "1", "type": "text/plain"}]
519 }));
520 let (body, _truncated) = JmapClient::extract_body(&email);
521 // Returns empty string from bodyValues (not falling through to preview)
522 assert_eq!(body, "");
523 }
524
525 #[test]
526 fn extract_body_multipart_first_has_no_part_id_second_does() {
527 let email = email_from_json(json!({
528 "id": "e10",
529 "bodyValues": {
530 "2": {"value": "Second part body"}
531 },
532 "textBody": [
533 {"type": "text/plain"},
534 {"partId": "2", "type": "text/plain"}
535 ]
536 }));
537 let (body, _truncated) = JmapClient::extract_body(&email);
538 // First part has no partId, skipped; second part matches
539 assert_eq!(body, "Second part body");
540 }
541
542 // ---- JmapParsedEmail construction ----
543
544 #[test]
545 fn jmap_parsed_email_fields() {
546 let parsed = JmapParsedEmail {
547 jmap_id: "jmap_1".to_string(),
548 message_id: Some("<msg@example.com>".to_string()),
549 in_reply_to: None,
550 references_root: None,
551 source_folder: "Inbox".to_string(),
552 from: "Alice <alice@example.com>".to_string(),
553 to: "bob@example.com".to_string(),
554 subject: "Test Subject".to_string(),
555 body: "Test body".to_string(),
556 body_truncated: false,
557 date: chrono::Utc::now(),
558 is_read: false,
559 };
560 assert_eq!(parsed.jmap_id, "jmap_1");
561 assert_eq!(parsed.source_folder, "Inbox");
562 assert!(!parsed.is_read);
563 }
564
565 #[test]
566 fn extract_body_reports_truncation_flag() {
567 // A truncated bodyValue must surface as truncated=true so the reader can
568 // offer a lazy full-body fetch.
569 let email = email_from_json(json!({
570 "id": "e_trunc",
571 "bodyValues": {
572 "1": {"value": "partial body", "isTruncated": true}
573 },
574 "textBody": [{"partId": "1", "type": "text/plain"}]
575 }));
576 let (body, truncated) = JmapClient::extract_body(&email);
577 assert_eq!(body, "partial body");
578 assert!(truncated, "isTruncated=true must be reported");
579
580 // A normal (untruncated) body reports false.
581 let email2 = email_from_json(json!({
582 "id": "e_ok",
583 "bodyValues": {"1": {"value": "complete"}},
584 "textBody": [{"partId": "1", "type": "text/plain"}]
585 }));
586 let (_b, truncated2) = JmapClient::extract_body(&email2);
587 assert!(!truncated2);
588 }
589
590 // ---- Email deserialization for read/unread detection ----
591
592 #[test]
593 fn email_is_read_when_seen_keyword_present() {
594 let email = email_from_json(json!({
595 "id": "e_read",
596 "keywords": {"$seen": true}
597 }));
598 let is_read = email
599 .keywords
600 .as_ref()
601 .map(|k| k.contains_key("$seen"))
602 .unwrap_or(false);
603 assert!(is_read);
604 }
605
606 #[test]
607 fn email_is_unread_when_keywords_empty() {
608 let email = email_from_json(json!({
609 "id": "e_unread",
610 "keywords": {}
611 }));
612 let is_read = email
613 .keywords
614 .as_ref()
615 .map(|k| k.contains_key("$seen"))
616 .unwrap_or(false);
617 assert!(!is_read);
618 }
619
620 #[test]
621 fn email_is_unread_when_keywords_missing() {
622 let email = email_from_json(json!({
623 "id": "e_no_kw"
624 }));
625 let is_read = email
626 .keywords
627 .as_ref()
628 .map(|k| k.contains_key("$seen"))
629 .unwrap_or(false);
630 assert!(!is_read);
631 }
632
633 // ---- Address extraction patterns ----
634
635 #[test]
636 fn from_address_extraction_with_name() {
637 let email = email_from_json(json!({
638 "id": "e_addr1",
639 "from": [{"name": "Alice Smith", "email": "alice@example.com"}]
640 }));
641 let from = email
642 .from
643 .as_ref()
644 .and_then(|addrs| addrs.first())
645 .map(|a| a.to_string())
646 .unwrap_or_default();
647 assert_eq!(from, "Alice Smith <alice@example.com>");
648 }
649
650 #[test]
651 fn from_address_extraction_without_name() {
652 let email = email_from_json(json!({
653 "id": "e_addr2",
654 "from": [{"email": "noreply@example.com"}]
655 }));
656 let from = email
657 .from
658 .as_ref()
659 .and_then(|addrs| addrs.first())
660 .map(|a| a.to_string())
661 .unwrap_or_default();
662 assert_eq!(from, "noreply@example.com");
663 }
664
665 #[test]
666 fn from_address_extraction_empty_list() {
667 let email = email_from_json(json!({
668 "id": "e_addr3",
669 "from": []
670 }));
671 let from = email
672 .from
673 .as_ref()
674 .and_then(|addrs| addrs.first())
675 .map(|a| a.to_string())
676 .unwrap_or_default();
677 assert_eq!(from, "");
678 }
679
680 #[test]
681 fn from_address_extraction_missing() {
682 let email = email_from_json(json!({
683 "id": "e_addr4"
684 }));
685 let from = email
686 .from
687 .as_ref()
688 .and_then(|addrs| addrs.first())
689 .map(|a| a.to_string())
690 .unwrap_or_default();
691 assert_eq!(from, "");
692 }
693
694 // ---- Message-ID extraction ----
695
696 #[test]
697 fn message_id_extraction() {
698 let email = email_from_json(json!({
699 "id": "e_mid",
700 "messageId": ["<abc123@mail.example.com>", "<def456@mail.example.com>"]
701 }));
702 let message_id = email
703 .message_id
704 .as_ref()
705 .and_then(|ids| ids.first())
706 .cloned();
707 assert_eq!(message_id, Some("<abc123@mail.example.com>".to_string()));
708 }
709
710 #[test]
711 fn in_reply_to_extraction() {
712 let email = email_from_json(json!({
713 "id": "e_irt",
714 "inReplyTo": ["<parent@mail.example.com>"]
715 }));
716 let in_reply_to = email
717 .in_reply_to
718 .as_ref()
719 .and_then(|ids| ids.first())
720 .cloned();
721 assert_eq!(in_reply_to, Some("<parent@mail.example.com>".to_string()));
722 }
723 }
724