Skip to main content

rusmes_jmap/methods/
thread.rs

1//! Thread method implementations for JMAP
2//!
3//! Implements:
4//! - Thread/get - conversation threading
5//! - Thread/changes - detect thread changes
6
7use crate::methods::ensure_account_ownership;
8use crate::types::Principal;
9use rusmes_storage::MessageStore;
10use serde::{Deserialize, Serialize};
11
12/// Thread object
13#[derive(Debug, Clone, Serialize, Deserialize)]
14#[serde(rename_all = "camelCase")]
15pub struct Thread {
16    /// Unique identifier
17    pub id: String,
18    /// Email IDs in the thread, in order
19    pub email_ids: Vec<String>,
20}
21
22/// Thread/get request
23#[derive(Debug, Clone, Deserialize)]
24#[serde(rename_all = "camelCase")]
25pub struct ThreadGetRequest {
26    pub account_id: String,
27    #[serde(skip_serializing_if = "Option::is_none")]
28    pub ids: Option<Vec<String>>,
29    #[serde(skip_serializing_if = "Option::is_none")]
30    pub properties: Option<Vec<String>>,
31}
32
33/// Thread/get response
34#[derive(Debug, Clone, Serialize)]
35#[serde(rename_all = "camelCase")]
36pub struct ThreadGetResponse {
37    pub account_id: String,
38    pub state: String,
39    pub list: Vec<Thread>,
40    pub not_found: Vec<String>,
41}
42
43/// Thread/changes request
44#[derive(Debug, Clone, Deserialize)]
45#[serde(rename_all = "camelCase")]
46pub struct ThreadChangesRequest {
47    pub account_id: String,
48    pub since_state: String,
49    #[serde(skip_serializing_if = "Option::is_none")]
50    pub max_changes: Option<u64>,
51}
52
53/// Thread/changes response
54#[derive(Debug, Clone, Serialize)]
55#[serde(rename_all = "camelCase")]
56pub struct ThreadChangesResponse {
57    pub account_id: String,
58    pub old_state: String,
59    pub new_state: String,
60    pub has_more_changes: bool,
61    pub created: Vec<String>,
62    pub updated: Vec<String>,
63    pub destroyed: Vec<String>,
64}
65
66/// Handle Thread/get method
67pub async fn thread_get(
68    request: ThreadGetRequest,
69    _message_store: &dyn MessageStore,
70    principal: &Principal,
71) -> anyhow::Result<ThreadGetResponse> {
72    ensure_account_ownership(&request.account_id, principal)?;
73    let list = Vec::new();
74    let mut not_found = Vec::new();
75
76    // If no IDs specified, return empty list
77    let ids = request.ids.unwrap_or_default();
78
79    for id in ids {
80        // In production, would:
81        // 1. Query message store for emails with this thread ID
82        // 2. Build Thread object with email IDs
83        // 3. Apply proper threading algorithm (based on References/In-Reply-To headers)
84
85        not_found.push(id);
86    }
87
88    Ok(ThreadGetResponse {
89        account_id: request.account_id,
90        state: "1".to_string(),
91        list,
92        not_found,
93    })
94}
95
96/// Handle Thread/changes method
97pub async fn thread_changes(
98    request: ThreadChangesRequest,
99    _message_store: &dyn MessageStore,
100    principal: &Principal,
101) -> anyhow::Result<ThreadChangesResponse> {
102    ensure_account_ownership(&request.account_id, principal)?;
103    let since_state: u64 = request.since_state.parse().unwrap_or(0);
104    let new_state = (since_state + 1).to_string();
105
106    // In production, would query change log for thread changes
107    let created = Vec::new();
108    let updated = Vec::new();
109    let destroyed = Vec::new();
110
111    Ok(ThreadChangesResponse {
112        account_id: request.account_id,
113        old_state: request.since_state,
114        new_state,
115        has_more_changes: false,
116        created,
117        updated,
118        destroyed,
119    })
120}
121
122/// Calculate thread ID from email headers
123///
124/// Threading algorithm based on RFC 5256 (THREAD=REFERENCES)
125#[allow(dead_code)]
126fn calculate_thread_id(
127    message_id: Option<&str>,
128    in_reply_to: Option<&[String]>,
129    references: Option<&[String]>,
130) -> String {
131    // In production, would implement proper threading algorithm:
132    // 1. Use References header to find thread ancestry
133    // 2. Fall back to In-Reply-To if References not present
134    // 3. Use Message-ID as thread ID if no references
135    // 4. Hash the thread root message ID for consistent thread IDs
136
137    use sha2::{Digest, Sha256};
138
139    // Get the root message ID (first in References, or In-Reply-To, or Message-ID)
140    let root_id = references
141        .and_then(|refs| refs.first())
142        .or_else(|| in_reply_to.and_then(|irt| irt.first()))
143        .map(|s| s.as_str())
144        .or(message_id)
145        .unwrap_or("unknown");
146
147    // Hash it to create a consistent thread ID
148    let mut hasher = Sha256::new();
149    hasher.update(root_id.as_bytes());
150    let result = hasher.finalize();
151    format!("T{:x}", result).chars().take(32).collect()
152}
153
154/// Generate search snippet with highlighted terms
155#[allow(dead_code)]
156fn generate_snippet(text: &str, search_terms: &[String], max_length: usize) -> String {
157    if search_terms.is_empty() {
158        // No search terms, just return truncated text
159        if text.len() <= max_length {
160            return text.to_string();
161        }
162        return format!("{}...", &text[..max_length.saturating_sub(3)]);
163    }
164
165    // Find first occurrence of any search term
166    let mut best_pos = None;
167    let text_lower = text.to_lowercase();
168
169    for term in search_terms {
170        let term_lower = term.to_lowercase();
171        if let Some(pos) = text_lower.find(&term_lower) {
172            if best_pos.map_or(true, |best| pos < best) {
173                best_pos = Some(pos);
174            }
175        }
176    }
177
178    match best_pos {
179        Some(pos) => {
180            // Calculate context window around the match
181            let context_before = 50;
182            let context_after = max_length.saturating_sub(context_before).saturating_sub(6); // 6 for potential "..." on both sides
183
184            let start = pos.saturating_sub(context_before);
185            let end = (start + context_before + context_after).min(text.len());
186
187            let mut snippet = String::new();
188            if start > 0 {
189                snippet.push_str("...");
190            }
191            snippet.push_str(&text[start..end]);
192            if end < text.len() {
193                snippet.push_str("...");
194            }
195
196            snippet
197        }
198        None => {
199            // No match found, return beginning of text
200            if text.len() <= max_length {
201                text.to_string()
202            } else {
203                format!("{}...", &text[..max_length.saturating_sub(3)])
204            }
205        }
206    }
207}
208
209/// Highlight search terms in text with HTML mark tags
210#[allow(dead_code)]
211fn highlight_snippet(text: &str, search_terms: &[String]) -> String {
212    if search_terms.is_empty() {
213        return text.to_string();
214    }
215
216    let mut result = text.to_string();
217    let text_lower = text.to_lowercase();
218
219    // Collect all match positions
220    let mut matches: Vec<(usize, usize, String)> = Vec::new();
221
222    for term in search_terms {
223        let term_lower = term.to_lowercase();
224        let mut pos = 0;
225        while let Some(found_pos) = text_lower[pos..].find(&term_lower) {
226            let actual_pos = pos + found_pos;
227            let end_pos = actual_pos + term.len();
228
229            // Get the actual text (preserve case)
230            let matched_text = text[actual_pos..end_pos].to_string();
231            matches.push((actual_pos, end_pos, matched_text));
232
233            pos = end_pos;
234        }
235    }
236
237    // Sort by position (reverse order for replacement)
238    matches.sort_by_key(|b| std::cmp::Reverse(b.0));
239
240    // Remove overlapping matches
241    let mut non_overlapping: Vec<(usize, usize, String)> = Vec::new();
242    for m in matches {
243        let overlaps = non_overlapping.iter().any(|existing| {
244            (m.0 >= existing.0 && m.0 < existing.1) || (m.1 > existing.0 && m.1 <= existing.1)
245        });
246        if !overlaps {
247            non_overlapping.push(m);
248        }
249    }
250
251    // Apply highlights in reverse order to preserve positions
252    for (start, end, matched_text) in non_overlapping {
253        let highlighted = format!("<mark>{}</mark>", matched_text);
254        result.replace_range(start..end, &highlighted);
255    }
256
257    result
258}
259
260#[cfg(test)]
261mod tests {
262
263    fn test_principal() -> crate::types::Principal {
264        crate::types::admin_principal_for_tests()
265    }
266
267    use super::*;
268    use rusmes_storage::backends::filesystem::FilesystemBackend;
269    use rusmes_storage::StorageBackend;
270    use std::path::PathBuf;
271
272    fn create_test_store() -> std::sync::Arc<dyn MessageStore> {
273        let backend = FilesystemBackend::new(PathBuf::from("/tmp/rusmes-test-storage"));
274        backend.message_store()
275    }
276
277    #[tokio::test]
278    async fn test_thread_get() {
279        let store = create_test_store();
280        let request = ThreadGetRequest {
281            account_id: "acc1".to_string(),
282            ids: Some(vec!["thread1".to_string()]),
283            properties: None,
284        };
285
286        let response = thread_get(request, store.as_ref(), &test_principal())
287            .await
288            .unwrap();
289        assert_eq!(response.account_id, "acc1");
290        assert_eq!(response.not_found.len(), 1);
291    }
292
293    #[tokio::test]
294    async fn test_thread_get_all() {
295        let store = create_test_store();
296        let request = ThreadGetRequest {
297            account_id: "acc1".to_string(),
298            ids: None,
299            properties: None,
300        };
301
302        let response = thread_get(request, store.as_ref(), &test_principal())
303            .await
304            .unwrap();
305        assert_eq!(response.list.len(), 0);
306    }
307
308    #[tokio::test]
309    async fn test_thread_changes() {
310        let store = create_test_store();
311        let request = ThreadChangesRequest {
312            account_id: "acc1".to_string(),
313            since_state: "1".to_string(),
314            max_changes: Some(50),
315        };
316
317        let response = thread_changes(request, store.as_ref(), &test_principal())
318            .await
319            .unwrap();
320        assert_eq!(response.old_state, "1");
321        assert_eq!(response.new_state, "2");
322        assert!(!response.has_more_changes);
323    }
324
325    #[tokio::test]
326    async fn test_calculate_thread_id_with_references() {
327        let references = vec![
328            "<root@example.com>".to_string(),
329            "<reply1@example.com>".to_string(),
330        ];
331        let thread_id = calculate_thread_id(Some("<reply2@example.com>"), None, Some(&references));
332
333        // Should use first reference as root
334        assert!(thread_id.starts_with('T'));
335        assert_eq!(thread_id.len(), 32);
336    }
337
338    #[tokio::test]
339    async fn test_calculate_thread_id_with_in_reply_to() {
340        let in_reply_to = vec!["<original@example.com>".to_string()];
341        let thread_id = calculate_thread_id(Some("<reply@example.com>"), Some(&in_reply_to), None);
342
343        assert!(thread_id.starts_with('T'));
344    }
345
346    #[tokio::test]
347    async fn test_calculate_thread_id_standalone() {
348        let thread_id = calculate_thread_id(Some("<standalone@example.com>"), None, None);
349
350        assert!(thread_id.starts_with('T'));
351    }
352
353    #[tokio::test]
354    async fn test_generate_snippet_no_search_terms() {
355        let text = "This is a test message with some content.";
356        let snippet = generate_snippet(text, &[], 100);
357        assert_eq!(snippet, text);
358    }
359
360    #[tokio::test]
361    async fn test_generate_snippet_with_match() {
362        let text = "This is a test message with important information that we need to find.";
363        let terms = vec!["important".to_string()];
364        let snippet = generate_snippet(text, &terms, 100);
365
366        assert!(snippet.contains("important"));
367    }
368
369    #[tokio::test]
370    async fn test_generate_snippet_truncate_long_text() {
371        let text = "A".repeat(200);
372        let snippet = generate_snippet(&text, &[], 50);
373
374        assert_eq!(snippet.len(), 50);
375        assert!(snippet.ends_with("..."));
376    }
377
378    #[tokio::test]
379    async fn test_thread_get_with_properties() {
380        let store = create_test_store();
381        let properties = vec!["id".to_string(), "emailIds".to_string()];
382
383        let request = ThreadGetRequest {
384            account_id: "acc1".to_string(),
385            ids: Some(vec!["thread1".to_string()]),
386            properties: Some(properties),
387        };
388
389        let response = thread_get(request, store.as_ref(), &test_principal())
390            .await
391            .unwrap();
392        assert_eq!(response.list.len(), 0);
393    }
394
395    #[tokio::test]
396    async fn test_thread_changes_max_changes() {
397        let store = create_test_store();
398        let request = ThreadChangesRequest {
399            account_id: "acc1".to_string(),
400            since_state: "100".to_string(),
401            max_changes: Some(10),
402        };
403
404        let response = thread_changes(request, store.as_ref(), &test_principal())
405            .await
406            .unwrap();
407        assert_eq!(response.old_state, "100");
408        assert_eq!(response.new_state, "101");
409    }
410
411    #[tokio::test]
412    async fn test_generate_snippet_match_at_start() {
413        let text = "Important information at the beginning of the message.";
414        let terms = vec!["Important".to_string()];
415        let snippet = generate_snippet(text, &terms, 100);
416
417        assert!(snippet.starts_with("Important"));
418    }
419
420    #[tokio::test]
421    async fn test_generate_snippet_match_at_end() {
422        let text = "The message ends with important information.";
423        let terms = vec!["important".to_string()];
424        let snippet = generate_snippet(text, &terms, 100);
425
426        assert!(snippet.contains("important"));
427    }
428
429    #[tokio::test]
430    async fn test_generate_snippet_multiple_terms() {
431        let text = "This message contains both urgent and important information.";
432        let terms = vec!["urgent".to_string(), "important".to_string()];
433        let snippet = generate_snippet(text, &terms, 100);
434
435        // Should find first matching term
436        assert!(snippet.contains("urgent") || snippet.contains("important"));
437    }
438
439    #[tokio::test]
440    async fn test_thread_id_consistency() {
441        let message_id = "<msg@example.com>";
442        let thread_id1 = calculate_thread_id(Some(message_id), None, None);
443        let thread_id2 = calculate_thread_id(Some(message_id), None, None);
444
445        // Same input should produce same thread ID
446        assert_eq!(thread_id1, thread_id2);
447    }
448
449    #[tokio::test]
450    async fn test_thread_changes_state_progression() {
451        let store = create_test_store();
452
453        let request1 = ThreadChangesRequest {
454            account_id: "acc1".to_string(),
455            since_state: "5".to_string(),
456            max_changes: None,
457        };
458        let response1 = thread_changes(request1, store.as_ref(), &test_principal())
459            .await
460            .unwrap();
461
462        let request2 = ThreadChangesRequest {
463            account_id: "acc1".to_string(),
464            since_state: response1.new_state.clone(),
465            max_changes: None,
466        };
467        let response2 = thread_changes(request2, store.as_ref(), &test_principal())
468            .await
469            .unwrap();
470
471        assert!(response1.new_state < response2.new_state);
472    }
473
474    #[tokio::test]
475    async fn test_generate_snippet_case_insensitive() {
476        let text = "This message contains IMPORTANT information.";
477        let terms = vec!["important".to_string()];
478        let snippet = generate_snippet(text, &terms, 100);
479
480        assert!(snippet.to_lowercase().contains("important"));
481    }
482
483    #[tokio::test]
484    async fn test_thread_get_multiple_ids() {
485        let store = create_test_store();
486        let request = ThreadGetRequest {
487            account_id: "acc1".to_string(),
488            ids: Some(vec![
489                "thread1".to_string(),
490                "thread2".to_string(),
491                "thread3".to_string(),
492            ]),
493            properties: None,
494        };
495
496        let response = thread_get(request, store.as_ref(), &test_principal())
497            .await
498            .unwrap();
499        assert_eq!(response.not_found.len(), 3);
500    }
501
502    #[tokio::test]
503    async fn test_calculate_thread_id_empty_references() {
504        let thread_id = calculate_thread_id(Some("<msg@example.com>"), Some(&[]), Some(&[]));
505
506        assert!(thread_id.starts_with('T'));
507    }
508
509    #[tokio::test]
510    async fn test_generate_snippet_exact_max_length() {
511        let text = "Exactly fifty characters for testing purposes!";
512        let snippet = generate_snippet(text, &[], 47);
513
514        assert_eq!(snippet, text);
515    }
516
517    #[tokio::test]
518    async fn test_generate_snippet_context_window() {
519        let text = "A".repeat(50) + "IMPORTANT" + &"Z".repeat(50);
520        let terms = vec!["IMPORTANT".to_string()];
521        let snippet = generate_snippet(&text, &terms, 80);
522
523        assert!(snippet.contains("IMPORTANT"));
524        assert!(snippet.contains("..."));
525    }
526
527    #[tokio::test]
528    async fn test_thread_changes_empty_state() {
529        let store = create_test_store();
530        let request = ThreadChangesRequest {
531            account_id: "acc1".to_string(),
532            since_state: "0".to_string(),
533            max_changes: None,
534        };
535
536        let response = thread_changes(request, store.as_ref(), &test_principal())
537            .await
538            .unwrap();
539        assert_eq!(response.new_state, "1");
540        assert!(response.created.is_empty());
541        assert!(response.updated.is_empty());
542        assert!(response.destroyed.is_empty());
543    }
544
545    #[tokio::test]
546    async fn test_thread_object_structure() {
547        let thread = Thread {
548            id: "T123".to_string(),
549            email_ids: vec!["email1".to_string(), "email2".to_string()],
550        };
551
552        let json = serde_json::to_string(&thread).unwrap();
553        assert!(json.contains("T123"));
554        assert!(json.contains("email1"));
555    }
556
557    #[tokio::test]
558    async fn test_highlight_snippet_basic() {
559        let text = "This is an important message";
560        let terms = vec!["important".to_string()];
561        let highlighted = highlight_snippet(text, &terms);
562
563        assert!(highlighted.contains("<mark>important</mark>"));
564    }
565
566    #[tokio::test]
567    async fn test_highlight_snippet_multiple_occurrences() {
568        let text = "test message with test data";
569        let terms = vec!["test".to_string()];
570        let highlighted = highlight_snippet(text, &terms);
571
572        // Should highlight both occurrences
573        let count = highlighted.matches("<mark>test</mark>").count();
574        assert_eq!(count, 2);
575    }
576
577    #[tokio::test]
578    async fn test_highlight_snippet_case_preservation() {
579        let text = "IMPORTANT message is Important";
580        let terms = vec!["important".to_string()];
581        let highlighted = highlight_snippet(text, &terms);
582
583        assert!(highlighted.contains("<mark>IMPORTANT</mark>"));
584        assert!(highlighted.contains("<mark>Important</mark>"));
585    }
586
587    #[tokio::test]
588    async fn test_highlight_snippet_no_terms() {
589        let text = "No highlighting needed";
590        let highlighted = highlight_snippet(text, &[]);
591
592        assert_eq!(highlighted, text);
593        assert!(!highlighted.contains("<mark>"));
594    }
595
596    #[tokio::test]
597    async fn test_highlight_snippet_overlapping_terms() {
598        let text = "important information";
599        let terms = vec!["important".to_string(), "info".to_string()];
600        let highlighted = highlight_snippet(text, &terms);
601
602        // Should highlight both without breaking
603        assert!(highlighted.contains("<mark>"));
604    }
605
606    #[tokio::test]
607    async fn test_calculate_thread_id_nested_conversation() {
608        let references = vec![
609            "<root@example.com>".to_string(),
610            "<reply1@example.com>".to_string(),
611            "<reply2@example.com>".to_string(),
612        ];
613        let thread_id = calculate_thread_id(Some("<reply3@example.com>"), None, Some(&references));
614
615        // Should consistently use root
616        let thread_id2 = calculate_thread_id(Some("<reply4@example.com>"), None, Some(&references));
617        assert_eq!(thread_id, thread_id2);
618    }
619
620    #[tokio::test]
621    async fn test_calculate_thread_id_multi_branch() {
622        let references1 = vec!["<root@example.com>".to_string()];
623        let references2 = vec![
624            "<root@example.com>".to_string(),
625            "<branch1@example.com>".to_string(),
626        ];
627
628        let thread_id1 =
629            calculate_thread_id(Some("<reply1@example.com>"), None, Some(&references1));
630        let thread_id2 =
631            calculate_thread_id(Some("<reply2@example.com>"), None, Some(&references2));
632
633        // Both should map to same thread (same root)
634        assert_eq!(thread_id1, thread_id2);
635    }
636
637    #[tokio::test]
638    async fn test_generate_snippet_very_short_text() {
639        let text = "Hi";
640        let snippet = generate_snippet(text, &[], 100);
641        assert_eq!(snippet, "Hi");
642    }
643
644    #[tokio::test]
645    async fn test_generate_snippet_no_match_no_terms() {
646        let text = "This is a longer message that should be truncated";
647        let snippet = generate_snippet(text, &[], 20);
648
649        assert!(snippet.len() <= 20);
650        assert!(snippet.ends_with("..."));
651    }
652}