1use anyhow::{Context, Result};
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8
9use haystack_core::HaystackProvider;
10use terraphim_types::{Document, SearchQuery};
11
12#[derive(Debug, Serialize, Deserialize)]
14pub struct Session {
15 #[serde(rename = "primaryAccounts")]
17 pub primary_accounts: HashMap<String, String>,
18
19 #[serde(rename = "apiUrl")]
21 pub api_url: String,
22
23 pub capabilities: HashMap<String, serde_json::Value>,
25
26 #[serde(rename = "downloadUrl")]
28 pub download_url: String,
29
30 #[serde(rename = "uploadUrl")]
32 pub upload_url: String,
33
34 pub state: String,
36
37 pub username: String,
39}
40
41#[derive(Debug, Serialize, Deserialize)]
43struct JMAPRequest {
44 using: Vec<String>,
46
47 #[serde(rename = "methodCalls")]
49 method_calls: Vec<MethodCall>,
50}
51
52#[derive(Debug, Serialize, Deserialize)]
54struct MethodCall(
55 String,
57 HashMap<String, serde_json::Value>,
59 String,
61);
62
63#[derive(Debug, Serialize, Deserialize, Clone)]
65pub struct Email {
66 pub id: String,
68
69 #[serde(default)]
71 pub subject: Option<String>,
72
73 #[serde(default)]
75 pub from: Option<Vec<EmailAddress>>,
76
77 #[serde(default)]
79 pub to: Option<Vec<EmailAddress>>,
80
81 #[serde(rename = "bodyValues", default)]
83 pub body_values: HashMap<String, BodyValue>,
84
85 #[serde(rename = "textBody", default)]
87 pub text_body: Vec<BodyPart>,
88
89 #[serde(rename = "receivedAt")]
91 pub received_at: Option<String>,
92}
93
94#[derive(Debug, Serialize, Deserialize, Clone)]
96pub struct EmailAddress {
97 pub name: Option<String>,
99
100 pub email: String,
102}
103
104#[derive(Debug, Serialize, Deserialize, Clone)]
106pub struct BodyValue {
107 pub value: String,
109
110 #[serde(rename = "isTruncated")]
112 pub is_truncated: Option<bool>,
113}
114
115#[derive(Debug, Serialize, Deserialize, Clone)]
117pub struct BodyPart {
118 #[serde(rename = "partId")]
120 pub part_id: String,
121
122 #[serde(default)]
124 pub type_: Option<String>,
125}
126
127#[derive(Debug)]
129pub struct JMAPClient {
130 session: Session,
132
133 client: reqwest::Client,
135
136 access_token: String,
138}
139
140#[derive(Debug, Serialize, Deserialize, Clone)]
142struct JMAPResponse {
143 #[serde(rename = "methodResponses")]
145 method_responses: Vec<MethodResponse>,
146
147 #[serde(rename = "sessionState")]
149 session_state: String,
150}
151
152#[derive(Debug, Serialize, Deserialize, Clone)]
154struct MethodResponse(
155 String,
157 ResponseResult,
159 String,
161);
162
163#[derive(Debug, Serialize, Deserialize, Clone)]
165struct ResponseResult {
166 #[serde(default)]
168 ids: Vec<String>,
169
170 #[serde(default)]
172 list: Vec<Email>,
173
174 #[serde(default)]
176 total: u32,
177}
178
179impl JMAPClient {
180 pub async fn new(access_token: String, session_url: &str) -> Result<Self> {
182 let client = reqwest::Client::new();
183
184 log::info!("Connecting to JMAP session: {}", session_url);
185
186 let session_response = client
187 .get(session_url)
188 .header("Authorization", format!("Bearer {}", &access_token))
189 .send()
190 .await
191 .context("Failed to connect to JMAP server")?;
192
193 let status = session_response.status();
194 log::debug!("JMAP session status: {}", status);
195
196 let response_text = session_response.text().await?;
197
198 if !status.is_success() {
199 return Err(anyhow::anyhow!(
200 "Failed to authenticate: {} - {}",
201 status,
202 response_text
203 ));
204 }
205
206 log::debug!("JMAP session body length: {} bytes", response_text.len());
207
208 let session: Session =
209 serde_json::from_str(&response_text).context("Failed to parse session response")?;
210
211 log::info!("JMAP API URL: {}", session.api_url);
212
213 Ok(Self {
214 session,
215 client,
216 access_token,
217 })
218 }
219
220 pub async fn search_emails(&self, query: &str, limit: u32) -> Result<Vec<Email>> {
222 let account_id = self
223 .session
224 .primary_accounts
225 .get("urn:ietf:params:jmap:mail")
226 .context("No mail account found in primaryAccounts")?;
227
228 let mut method_params = HashMap::new();
229 method_params.insert("accountId".to_string(), serde_json::json!(account_id));
230 method_params.insert(
231 "filter".to_string(),
232 serde_json::json!({
233 "text": query
234 }),
235 );
236 method_params.insert("limit".to_string(), serde_json::json!(limit));
237
238 let request = JMAPRequest {
239 using: vec![
240 "urn:ietf:params:jmap:core".to_string(),
241 "urn:ietf:params:jmap:mail".to_string(),
242 ],
243 method_calls: vec![MethodCall(
244 "Email/query".to_string(),
245 method_params,
246 "s1".to_string(),
247 )],
248 };
249
250 log::debug!(
251 "JMAP search request: {}",
252 serde_json::to_string_pretty(&request)?
253 );
254
255 let response = self
256 .client
257 .post(&self.session.api_url)
258 .header("Authorization", format!("Bearer {}", self.access_token))
259 .header("Content-Type", "application/json")
260 .json(&request)
261 .send()
262 .await
263 .context("Failed to send search request")?;
264
265 let status = response.status();
266 log::debug!("JMAP search response status: {}", status);
267
268 let response_text = response.text().await?;
269
270 if !status.is_success() {
271 return Err(anyhow::anyhow!(
272 "Search request failed: {} - {}",
273 status,
274 response_text
275 ));
276 }
277
278 let jmap_response: JMAPResponse =
279 serde_json::from_str(&response_text).context("Failed to parse JMAP response")?;
280
281 if let Some(MethodResponse(_, result, _)) = jmap_response.method_responses.first() {
282 let email_ids = result.ids.clone();
283 self.get_emails(&email_ids).await
284 } else {
285 Ok(Vec::new())
286 }
287 }
288
289 async fn get_emails(&self, email_ids: &[String]) -> Result<Vec<Email>> {
291 if email_ids.is_empty() {
292 return Ok(Vec::new());
293 }
294
295 let account_id = self
296 .session
297 .primary_accounts
298 .get("urn:ietf:params:jmap:mail")
299 .context("No mail account found in primaryAccounts")?;
300
301 let mut method_params = HashMap::new();
302 method_params.insert("accountId".to_string(), serde_json::json!(account_id));
303 method_params.insert("ids".to_string(), serde_json::json!(email_ids));
304 method_params.insert(
305 "properties".to_string(),
306 serde_json::json!([
307 "id",
308 "subject",
309 "from",
310 "to",
311 "textBody",
312 "bodyValues",
313 "receivedAt",
314 "bodyStructure",
315 "bodyValues",
316 "textBody"
317 ]),
318 );
319 method_params.insert("fetchTextBodyValues".to_string(), serde_json::json!(true));
320
321 let request = JMAPRequest {
322 using: vec![
323 "urn:ietf:params:jmap:core".to_string(),
324 "urn:ietf:params:jmap:mail".to_string(),
325 ],
326 method_calls: vec![MethodCall(
327 "Email/get".to_string(),
328 method_params,
329 "s2".to_string(),
330 )],
331 };
332
333 let response = self
334 .client
335 .post(&self.session.api_url)
336 .header("Authorization", format!("Bearer {}", self.access_token))
337 .header("Content-Type", "application/json")
338 .json(&request)
339 .send()
340 .await
341 .context("Failed to fetch emails")?;
342
343 let status = response.status();
344 if !status.is_success() {
345 let error_text = response.text().await?;
346 return Err(anyhow::anyhow!(
347 "Email fetch failed: {} - {}",
348 status,
349 error_text
350 ));
351 }
352
353 let jmap_response: JMAPResponse = response
354 .json()
355 .await
356 .context("Failed to parse email response")?;
357
358 if let Some(MethodResponse(_, result, _)) = jmap_response.method_responses.first() {
359 Ok(result.list.clone())
360 } else {
361 Ok(Vec::new())
362 }
363 }
364}
365
366pub fn email_to_document(email: &Email) -> Document {
368 let sender = email
369 .from
370 .as_ref()
371 .and_then(|addrs| addrs.first())
372 .map(|a| a.email.clone())
373 .unwrap_or_default();
374
375 let recipient = email
376 .to
377 .as_ref()
378 .and_then(|addrs| addrs.first())
379 .map(|a| a.email.clone())
380 .unwrap_or_default();
381
382 let description = Some(format!("From: {} To: {}", sender, recipient));
383
384 let body_text = email
385 .body_values
386 .values()
387 .next()
388 .map(|bv| bv.value.clone())
389 .unwrap_or_default();
390
391 let stub = if body_text.is_empty() {
392 None
393 } else {
394 Some(body_text.chars().take(200).collect::<String>())
395 };
396
397 let mut tags = vec!["email".to_string()];
398 if !sender.is_empty() {
399 tags.push(format!("sender:{}", sender));
400 }
401 if let Some(ref date) = email.received_at {
402 if let Some(date_part) = date.split('T').next() {
403 tags.push(date_part.to_string());
404 }
405 }
406
407 let url = format!("jmap:///email/{}", email.id);
408
409 Document {
410 id: email.id.clone(),
411 title: email.subject.clone().unwrap_or_default(),
412 body: body_text,
413 url,
414 description,
415 stub,
416 tags: Some(tags),
417 summarization: None,
418 rank: None,
419 source_haystack: None,
420 doc_type: terraphim_types::DocumentType::KgEntry,
421 synonyms: None,
422 route: None,
423 priority: None,
424 quality_score: None,
425 }
426}
427
428impl HaystackProvider for JMAPClient {
429 type Error = anyhow::Error;
430
431 async fn search(&self, query: &SearchQuery) -> Result<Vec<Document>, Self::Error> {
432 let emails = self
433 .search_emails(&query.search_term.to_string(), 50)
434 .await?;
435 Ok(emails.iter().map(email_to_document).collect())
436 }
437}
438
439#[cfg(test)]
440mod tests {
441 use super::*;
442
443 fn sample_session_json() -> &'static str {
444 r#"{
445 "primaryAccounts": {
446 "urn:ietf:params:jmap:mail": "acc-001",
447 "urn:ietf:params:jmap:contacts": "acc-001"
448 },
449 "apiUrl": "https://jmap.example.com/api/",
450 "capabilities": {},
451 "downloadUrl": "https://jmap.example.com/download/",
452 "uploadUrl": "https://jmap.example.com/upload/",
453 "state": "abc123",
454 "username": "user@example.com"
455 }"#
456 }
457
458 fn sample_email() -> Email {
459 let mut body_values = HashMap::new();
460 body_values.insert(
461 "1".to_string(),
462 BodyValue {
463 value: "Hello, this is the email body content.".to_string(),
464 is_truncated: Some(false),
465 },
466 );
467 Email {
468 id: "email-001".to_string(),
469 subject: Some("Test Subject".to_string()),
470 from: Some(vec![EmailAddress {
471 name: Some("Alice".to_string()),
472 email: "alice@example.com".to_string(),
473 }]),
474 to: Some(vec![EmailAddress {
475 name: Some("Bob".to_string()),
476 email: "bob@example.com".to_string(),
477 }]),
478 body_values,
479 text_body: vec![BodyPart {
480 part_id: "1".to_string(),
481 type_: Some("text/plain".to_string()),
482 }],
483 received_at: Some("2025-01-15T10:30:00Z".to_string()),
484 }
485 }
486
487 #[test]
488 fn test_session_deserialization() {
489 let session: Session = serde_json::from_str(sample_session_json()).unwrap();
490 assert_eq!(session.api_url, "https://jmap.example.com/api/");
491 assert_eq!(session.username, "user@example.com");
492 assert_eq!(
493 session.primary_accounts.get("urn:ietf:params:jmap:mail"),
494 Some(&"acc-001".to_string())
495 );
496 }
497
498 #[test]
499 fn test_email_deserialization() {
500 let json = r#"{
501 "id": "e-123",
502 "subject": "Meeting Tomorrow",
503 "from": [{"name": "Alice", "email": "alice@test.com"}],
504 "to": [{"name": "Bob", "email": "bob@test.com"}],
505 "bodyValues": {
506 "1": {"value": "See you at 3pm", "isTruncated": false}
507 },
508 "textBody": [{"partId": "1"}],
509 "receivedAt": "2025-03-01T14:00:00Z"
510 }"#;
511 let email: Email = serde_json::from_str(json).unwrap();
512 assert_eq!(email.id, "e-123");
513 assert_eq!(email.subject, Some("Meeting Tomorrow".to_string()));
514 assert_eq!(email.from.as_ref().unwrap()[0].email, "alice@test.com");
515 assert_eq!(email.body_values.get("1").unwrap().value, "See you at 3pm");
516 assert_eq!(email.received_at, Some("2025-03-01T14:00:00Z".to_string()));
517 }
518
519 #[test]
520 fn test_email_to_document_mapping() {
521 let email = sample_email();
522 let doc = email_to_document(&email);
523
524 assert_eq!(doc.id, "email-001");
525 assert_eq!(doc.title, "Test Subject");
526 assert_eq!(doc.url, "jmap:///email/email-001");
527 assert_eq!(
528 doc.description,
529 Some("From: alice@example.com To: bob@example.com".to_string())
530 );
531 assert_eq!(doc.body, "Hello, this is the email body content.");
532 assert_eq!(
533 doc.stub,
534 Some("Hello, this is the email body content.".to_string())
535 );
536
537 let tags = doc.tags.unwrap();
538 assert!(tags.contains(&"email".to_string()));
539 assert!(tags.contains(&"sender:alice@example.com".to_string()));
540 assert!(tags.contains(&"2025-01-15".to_string()));
541 }
542
543 #[test]
544 fn test_email_to_document_empty_fields() {
545 let email = Email {
546 id: "empty-001".to_string(),
547 subject: None,
548 from: None,
549 to: None,
550 body_values: HashMap::new(),
551 text_body: vec![],
552 received_at: None,
553 };
554 let doc = email_to_document(&email);
555
556 assert_eq!(doc.id, "empty-001");
557 assert_eq!(doc.title, "");
558 assert_eq!(doc.description, Some("From: To: ".to_string()));
559 assert_eq!(doc.body, "");
560 assert!(doc.stub.is_none());
561
562 let tags = doc.tags.unwrap();
563 assert_eq!(tags, vec!["email".to_string()]);
564 }
565
566 #[test]
567 fn test_email_to_document_stub_truncation() {
568 let long_body = "A".repeat(500);
569 let mut body_values = HashMap::new();
570 body_values.insert(
571 "1".to_string(),
572 BodyValue {
573 value: long_body,
574 is_truncated: None,
575 },
576 );
577 let email = Email {
578 id: "long-001".to_string(),
579 subject: Some("Long Email".to_string()),
580 from: None,
581 to: None,
582 body_values,
583 text_body: vec![],
584 received_at: None,
585 };
586 let doc = email_to_document(&email);
587
588 let stub = doc.stub.unwrap();
589 assert_eq!(stub.len(), 200);
590 assert_eq!(stub, "A".repeat(200));
591 }
592
593 #[tokio::test]
594 async fn test_wiremock_full_search_flow() {
595 use wiremock::matchers::{body_string_contains, header, method, path};
596 use wiremock::{Mock, MockServer, ResponseTemplate};
597
598 let mock_server = MockServer::start().await;
599
600 let session_json = serde_json::json!({
601 "primaryAccounts": {
602 "urn:ietf:params:jmap:mail": "acc-001"
603 },
604 "apiUrl": format!("{}/api", mock_server.uri()),
605 "capabilities": {},
606 "downloadUrl": format!("{}/download/", mock_server.uri()),
607 "uploadUrl": format!("{}/upload/", mock_server.uri()),
608 "state": "s1",
609 "username": "test@example.com"
610 });
611
612 Mock::given(method("GET"))
613 .and(path("/session"))
614 .and(header("Authorization", "Bearer test-token"))
615 .respond_with(ResponseTemplate::new(200).set_body_json(&session_json))
616 .mount(&mock_server)
617 .await;
618
619 let query_response = serde_json::json!({
620 "methodResponses": [
621 ["Email/query", {"ids": ["e-1"], "total": 1}, "s1"]
622 ],
623 "sessionState": "s1"
624 });
625 Mock::given(method("POST"))
626 .and(path("/api"))
627 .and(body_string_contains("Email/query"))
628 .respond_with(ResponseTemplate::new(200).set_body_json(&query_response))
629 .mount(&mock_server)
630 .await;
631
632 let get_response = serde_json::json!({
633 "methodResponses": [
634 ["Email/get", {
635 "list": [{
636 "id": "e-1",
637 "subject": "Test Email",
638 "from": [{"name": "Sender", "email": "sender@test.com"}],
639 "to": [{"name": "Receiver", "email": "receiver@test.com"}],
640 "bodyValues": {"1": {"value": "Body content here"}},
641 "textBody": [{"partId": "1"}],
642 "receivedAt": "2025-06-01T12:00:00Z"
643 }]
644 }, "s2"]
645 ],
646 "sessionState": "s1"
647 });
648 Mock::given(method("POST"))
649 .and(path("/api"))
650 .and(body_string_contains("Email/get"))
651 .respond_with(ResponseTemplate::new(200).set_body_json(&get_response))
652 .mount(&mock_server)
653 .await;
654
655 let session_url = format!("{}/session", mock_server.uri());
656 let client = JMAPClient::new("test-token".to_string(), &session_url)
657 .await
658 .unwrap();
659
660 let emails = client.search_emails("test", 10).await.unwrap();
661 assert_eq!(emails.len(), 1);
662 assert_eq!(emails[0].id, "e-1");
663 assert_eq!(emails[0].subject, Some("Test Email".to_string()));
664 }
665
666 #[tokio::test]
667 async fn test_wiremock_auth_failure() {
668 use wiremock::matchers::{method, path};
669 use wiremock::{Mock, MockServer, ResponseTemplate};
670
671 let mock_server = MockServer::start().await;
672
673 Mock::given(method("GET"))
674 .and(path("/session"))
675 .respond_with(ResponseTemplate::new(401).set_body_string("Unauthorized"))
676 .mount(&mock_server)
677 .await;
678
679 let session_url = format!("{}/session", mock_server.uri());
680 let result = JMAPClient::new("bad-token".to_string(), &session_url).await;
681
682 assert!(result.is_err());
683 let err = result.unwrap_err().to_string();
684 assert!(err.contains("authenticate"), "Error was: {}", err);
685 }
686
687 #[tokio::test]
688 async fn test_wiremock_empty_search_results() {
689 use wiremock::matchers::{body_string_contains, method, path};
690 use wiremock::{Mock, MockServer, ResponseTemplate};
691
692 let mock_server = MockServer::start().await;
693
694 let session_json = serde_json::json!({
695 "primaryAccounts": {"urn:ietf:params:jmap:mail": "acc-001"},
696 "apiUrl": format!("{}/api", mock_server.uri()),
697 "capabilities": {},
698 "downloadUrl": "",
699 "uploadUrl": "",
700 "state": "s1",
701 "username": "test@example.com"
702 });
703
704 Mock::given(method("GET"))
705 .and(path("/session"))
706 .respond_with(ResponseTemplate::new(200).set_body_json(&session_json))
707 .mount(&mock_server)
708 .await;
709
710 let query_response = serde_json::json!({
711 "methodResponses": [
712 ["Email/query", {"ids": [], "total": 0}, "s1"]
713 ],
714 "sessionState": "s1"
715 });
716 Mock::given(method("POST"))
717 .and(path("/api"))
718 .and(body_string_contains("Email/query"))
719 .respond_with(ResponseTemplate::new(200).set_body_json(&query_response))
720 .mount(&mock_server)
721 .await;
722
723 let session_url = format!("{}/session", mock_server.uri());
724 let client = JMAPClient::new("token".to_string(), &session_url)
725 .await
726 .unwrap();
727
728 let emails = client.search_emails("nothing", 10).await.unwrap();
729 assert!(emails.is_empty());
730 }
731}