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