Skip to main content

haystack_jmap/
lib.rs

1//! Haystack integration for email via JMAP.
2//!
3//! Implements [`HaystackProvider`] over a JMAP mail server, allowing email
4//! messages and threads to be searched as Terraphim haystack documents.
5use anyhow::{Context, Result};
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8
9use haystack_core::HaystackProvider;
10use terraphim_types::{Document, SearchQuery};
11
12/// Represents a JMAP session.
13#[derive(Debug, Serialize, Deserialize)]
14pub struct Session {
15    /// The primary accounts associated with the session.
16    #[serde(rename = "primaryAccounts")]
17    pub primary_accounts: HashMap<String, String>,
18
19    /// The URL of the JMAP API.
20    #[serde(rename = "apiUrl")]
21    pub api_url: String,
22
23    /// The capabilities of the JMAP server.
24    pub capabilities: HashMap<String, serde_json::Value>,
25
26    /// The URL for downloading attachments.
27    #[serde(rename = "downloadUrl")]
28    pub download_url: String,
29
30    /// The URL for uploading attachments.
31    #[serde(rename = "uploadUrl")]
32    pub upload_url: String,
33
34    /// The current state of the session.
35    pub state: String,
36
37    /// The username associated with the session.
38    pub username: String,
39}
40
41/// Represents a JMAP request.
42#[derive(Debug, Serialize, Deserialize)]
43struct JMAPRequest {
44    /// The set of capabilities being used in the request.
45    using: Vec<String>,
46
47    /// The method calls included in the request.
48    #[serde(rename = "methodCalls")]
49    method_calls: Vec<MethodCall>,
50}
51
52/// Represents a JMAP method call.
53#[derive(Debug, Serialize, Deserialize)]
54struct MethodCall(
55    /// The name of the method being called.
56    String,
57    /// The arguments for the method call.
58    HashMap<String, serde_json::Value>,
59    /// The client-specified method call ID.
60    String,
61);
62
63/// Represents an email.
64#[derive(Debug, Serialize, Deserialize, Clone)]
65pub struct Email {
66    /// The ID of the email.
67    pub id: String,
68
69    /// The subject of the email.
70    #[serde(default)]
71    pub subject: Option<String>,
72
73    /// The sender(s) of the email.
74    #[serde(default)]
75    pub from: Option<Vec<EmailAddress>>,
76
77    /// The recipient(s) of the email.
78    #[serde(default)]
79    pub to: Option<Vec<EmailAddress>>,
80
81    /// The body values of the email, keyed by part ID.
82    #[serde(rename = "bodyValues", default)]
83    pub body_values: HashMap<String, BodyValue>,
84
85    /// The text body parts of the email.
86    #[serde(rename = "textBody", default)]
87    pub text_body: Vec<BodyPart>,
88
89    /// The date and time the email was received.
90    #[serde(rename = "receivedAt")]
91    pub received_at: Option<String>,
92}
93
94/// Represents an email address.
95#[derive(Debug, Serialize, Deserialize, Clone)]
96pub struct EmailAddress {
97    /// The name associated with the email address.
98    pub name: Option<String>,
99
100    /// The email address itself.
101    pub email: String,
102}
103
104/// Represents the value of an email body part.
105#[derive(Debug, Serialize, Deserialize, Clone)]
106pub struct BodyValue {
107    /// The content of the body part.
108    pub value: String,
109
110    /// Whether the content is truncated.
111    #[serde(rename = "isTruncated")]
112    pub is_truncated: Option<bool>,
113}
114
115/// Represents an email body part.
116#[derive(Debug, Serialize, Deserialize, Clone)]
117pub struct BodyPart {
118    /// The ID of the body part.
119    #[serde(rename = "partId")]
120    pub part_id: String,
121
122    /// The content type of the body part.
123    #[serde(default)]
124    pub type_: Option<String>,
125}
126
127/// A client for interacting with a JMAP server.
128#[derive(Debug)]
129pub struct JMAPClient {
130    /// The JMAP session associated with the client.
131    session: Session,
132
133    /// The HTTP client used for making requests.
134    client: reqwest::Client,
135
136    /// The access token used for authentication.
137    access_token: String,
138}
139
140/// Represents a JMAP response.
141#[derive(Debug, Serialize, Deserialize, Clone)]
142struct JMAPResponse {
143    /// The method responses included in the response.
144    #[serde(rename = "methodResponses")]
145    method_responses: Vec<MethodResponse>,
146
147    /// The updated state string for the session.
148    #[serde(rename = "sessionState")]
149    session_state: String,
150}
151
152/// Represents a JMAP method response.
153#[derive(Debug, Serialize, Deserialize, Clone)]
154struct MethodResponse(
155    /// The name of the method that was called.
156    String,
157    /// The result of the method call.
158    ResponseResult,
159    /// The client-specified method call ID.
160    String,
161);
162
163/// Represents the result of a JMAP method call.
164#[derive(Debug, Serialize, Deserialize, Clone)]
165struct ResponseResult {
166    /// The IDs of the relevant records.
167    #[serde(default)]
168    ids: Vec<String>,
169
170    /// The list of records returned by the method.
171    #[serde(default)]
172    list: Vec<Email>,
173
174    /// The total number of records matching the method criteria.
175    #[serde(default)]
176    total: u32,
177}
178
179impl JMAPClient {
180    /// Creates a new `JMAPClient` with the given access token and session URL.
181    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    /// Searches for emails matching the given query with configurable result limit.
221    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    /// Fetches the full email data for the given email IDs.
290    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
366/// Converts an Email into a Document with enriched metadata.
367pub 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}