Skip to main content

max / goingson

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