Skip to main content

rusmes_jmap/methods/
vacation.rs

1//! VacationResponse method implementations for JMAP
2//!
3//! Implements:
4//! - VacationResponse/get, VacationResponse/set
5//! - Vacation message generation
6//! - Date-based vacation activation
7//! - Recipient tracking (7-day cache for duplicate prevention)
8//! - Integration with Sieve vacation extension
9
10use crate::types::JmapSetError;
11use chrono::{DateTime, Duration, Utc};
12use rusmes_storage::MessageStore;
13use serde::{Deserialize, Serialize};
14use std::collections::HashMap;
15use std::sync::{Arc, Mutex};
16
17/// VacationResponse object
18#[derive(Debug, Clone, Serialize, Deserialize)]
19#[serde(rename_all = "camelCase")]
20pub struct VacationResponse {
21    /// Unique identifier (singleton, always "singleton")
22    pub id: String,
23    /// Is enabled
24    pub is_enabled: bool,
25    /// Start date (UTC)
26    #[serde(skip_serializing_if = "Option::is_none")]
27    pub from_date: Option<DateTime<Utc>>,
28    /// End date (UTC)
29    #[serde(skip_serializing_if = "Option::is_none")]
30    pub to_date: Option<DateTime<Utc>>,
31    /// Subject of vacation message
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub subject: Option<String>,
34    /// Text body of vacation message
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub text_body: Option<String>,
37    /// HTML body of vacation message
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub html_body: Option<String>,
40}
41
42/// VacationResponse/get request
43#[derive(Debug, Clone, Deserialize)]
44#[serde(rename_all = "camelCase")]
45pub struct VacationResponseGetRequest {
46    pub account_id: String,
47    #[serde(skip_serializing_if = "Option::is_none")]
48    pub ids: Option<Vec<String>>,
49    #[serde(skip_serializing_if = "Option::is_none")]
50    pub properties: Option<Vec<String>>,
51}
52
53/// VacationResponse/get response
54#[derive(Debug, Clone, Serialize)]
55#[serde(rename_all = "camelCase")]
56pub struct VacationResponseGetResponse {
57    pub account_id: String,
58    pub state: String,
59    pub list: Vec<VacationResponse>,
60    pub not_found: Vec<String>,
61}
62
63/// VacationResponse/set request
64#[derive(Debug, Clone, Deserialize)]
65#[serde(rename_all = "camelCase")]
66pub struct VacationResponseSetRequest {
67    pub account_id: String,
68    #[serde(skip_serializing_if = "Option::is_none")]
69    pub if_in_state: Option<String>,
70    #[serde(skip_serializing_if = "Option::is_none")]
71    pub update: Option<HashMap<String, serde_json::Value>>,
72}
73
74/// VacationResponse/set response
75#[derive(Debug, Clone, Serialize)]
76#[serde(rename_all = "camelCase")]
77pub struct VacationResponseSetResponse {
78    pub account_id: String,
79    pub old_state: String,
80    pub new_state: String,
81    #[serde(skip_serializing_if = "Option::is_none")]
82    pub updated: Option<HashMap<String, Option<VacationResponse>>>,
83    #[serde(skip_serializing_if = "Option::is_none")]
84    pub not_updated: Option<HashMap<String, JmapSetError>>,
85}
86
87/// Recipient tracking entry
88#[derive(Debug, Clone)]
89struct RecipientEntry {
90    _email: String,
91    last_sent: DateTime<Utc>,
92}
93
94/// Vacation response tracker for duplicate prevention
95#[derive(Debug, Clone)]
96pub struct VacationTracker {
97    recipients: Arc<Mutex<HashMap<String, RecipientEntry>>>,
98}
99
100impl VacationTracker {
101    /// Create a new vacation tracker
102    pub fn new() -> Self {
103        Self {
104            recipients: Arc::new(Mutex::new(HashMap::new())),
105        }
106    }
107
108    /// Check if we should send vacation response to this recipient
109    /// Returns true if we haven't sent to them in the last 7 days
110    pub fn should_send_to(&self, email: &str) -> bool {
111        let mut recipients = match self.recipients.lock() {
112            Ok(guard) => guard,
113            Err(poisoned) => poisoned.into_inner(),
114        };
115
116        // Clean up old entries (>7 days)
117        let cutoff = Utc::now() - Duration::days(7);
118        recipients.retain(|_, entry| entry.last_sent > cutoff);
119
120        // Check if we've sent recently
121        if let Some(entry) = recipients.get(email) {
122            let since_last = Utc::now() - entry.last_sent;
123            since_last > Duration::days(7)
124        } else {
125            true
126        }
127    }
128
129    /// Record that we sent a vacation response to this recipient
130    pub fn record_sent(&self, email: String) {
131        let mut recipients = match self.recipients.lock() {
132            Ok(guard) => guard,
133            Err(poisoned) => poisoned.into_inner(),
134        };
135        recipients.insert(
136            email.clone(),
137            RecipientEntry {
138                _email: email.clone(),
139                last_sent: Utc::now(),
140            },
141        );
142    }
143
144    /// Get count of tracked recipients
145    pub fn recipient_count(&self) -> usize {
146        match self.recipients.lock() {
147            Ok(guard) => guard.len(),
148            Err(poisoned) => poisoned.into_inner().len(),
149        }
150    }
151}
152
153impl Default for VacationTracker {
154    fn default() -> Self {
155        Self::new()
156    }
157}
158
159/// Vacation message content
160#[derive(Debug, Clone)]
161pub struct VacationMessage {
162    pub subject: String,
163    pub text_body: Option<String>,
164    pub html_body: Option<String>,
165}
166
167/// Handle VacationResponse/get method
168pub async fn vacation_response_get(
169    request: VacationResponseGetRequest,
170    _message_store: &dyn MessageStore,
171) -> anyhow::Result<VacationResponseGetResponse> {
172    let mut list = Vec::new();
173    let mut not_found = Vec::new();
174
175    // VacationResponse is a singleton
176    let ids = request.ids.unwrap_or_else(|| vec!["singleton".to_string()]);
177
178    for id in ids {
179        if id == "singleton" {
180            // Return default vacation response (disabled)
181            list.push(VacationResponse {
182                id: "singleton".to_string(),
183                is_enabled: false,
184                from_date: None,
185                to_date: None,
186                subject: None,
187                text_body: None,
188                html_body: None,
189            });
190        } else {
191            not_found.push(id);
192        }
193    }
194
195    Ok(VacationResponseGetResponse {
196        account_id: request.account_id,
197        state: "1".to_string(),
198        list,
199        not_found,
200    })
201}
202
203/// Handle VacationResponse/set method
204pub async fn vacation_response_set(
205    request: VacationResponseSetRequest,
206    _message_store: &dyn MessageStore,
207) -> anyhow::Result<VacationResponseSetResponse> {
208    let updated = HashMap::new();
209    let mut not_updated = HashMap::new();
210
211    // VacationResponse only supports update (singleton object)
212    if let Some(update_map) = request.update {
213        for (id, _patch) in update_map {
214            if id != "singleton" {
215                not_updated.insert(
216                    id,
217                    JmapSetError {
218                        error_type: "notFound".to_string(),
219                        description: Some("VacationResponse ID must be 'singleton'".to_string()),
220                    },
221                );
222            } else {
223                not_updated.insert(
224                    id,
225                    JmapSetError {
226                        error_type: "notImplemented".to_string(),
227                        description: Some("Vacation response not yet implemented".to_string()),
228                    },
229                );
230            }
231        }
232    }
233
234    Ok(VacationResponseSetResponse {
235        account_id: request.account_id,
236        old_state: "1".to_string(),
237        new_state: "2".to_string(),
238        updated: if updated.is_empty() {
239            None
240        } else {
241            Some(updated)
242        },
243        not_updated: if not_updated.is_empty() {
244            None
245        } else {
246            Some(not_updated)
247        },
248    })
249}
250
251/// Check if vacation response should be active
252pub fn is_vacation_active(vacation: &VacationResponse) -> bool {
253    if !vacation.is_enabled {
254        return false;
255    }
256
257    let now = Utc::now();
258
259    // Check from_date
260    if let Some(from_date) = vacation.from_date {
261        if now < from_date {
262            return false;
263        }
264    }
265
266    // Check to_date
267    if let Some(to_date) = vacation.to_date {
268        if now > to_date {
269            return false;
270        }
271    }
272
273    true
274}
275
276/// Generate vacation response message
277pub fn generate_vacation_message(
278    vacation: &VacationResponse,
279    original_subject: Option<&str>,
280) -> Option<VacationMessage> {
281    if !is_vacation_active(vacation) {
282        return None;
283    }
284
285    // Generate subject
286    let subject = if let Some(custom_subject) = &vacation.subject {
287        custom_subject.clone()
288    } else if let Some(orig_subj) = original_subject {
289        format!("Re: {}", orig_subj)
290    } else {
291        "Automatic reply".to_string()
292    };
293
294    Some(VacationMessage {
295        subject,
296        text_body: vacation.text_body.clone(),
297        html_body: vacation.html_body.clone(),
298    })
299}
300
301/// Generate vacation response headers
302pub fn generate_vacation_headers() -> Vec<(String, String)> {
303    vec![
304        ("Auto-Submitted".to_string(), "auto-replied".to_string()),
305        ("Precedence".to_string(), "bulk".to_string()),
306    ]
307}
308
309/// Extract email addresses that should receive vacation responses
310/// Filters out mailing lists, bulk mail, and auto-submitted messages
311pub fn extract_vacation_recipients(from: &str, headers: &[(String, String)]) -> Vec<String> {
312    let mut recipients = Vec::new();
313
314    // Check for auto-submitted header (don't reply to auto-generated messages)
315    for (key, value) in headers {
316        if key.to_lowercase() == "auto-submitted" && value != "no" {
317            return recipients; // Don't send vacation response
318        }
319        if key.to_lowercase() == "precedence"
320            && (value == "bulk" || value == "list" || value == "junk")
321        {
322            return recipients; // Don't send vacation response to bulk/list mail
323        }
324        if key.to_lowercase() == "list-id" || key.to_lowercase() == "list-post" {
325            return recipients; // Don't send vacation response to mailing lists
326        }
327    }
328
329    // Add the from address if valid
330    if !from.is_empty() && from.contains('@') {
331        recipients.push(from.to_string());
332    }
333
334    recipients
335}
336
337#[cfg(test)]
338mod tests {
339    use super::*;
340    use rusmes_storage::backends::filesystem::FilesystemBackend;
341    use rusmes_storage::StorageBackend;
342    use std::path::PathBuf;
343
344    fn create_test_store() -> std::sync::Arc<dyn MessageStore> {
345        let backend = FilesystemBackend::new(PathBuf::from("/tmp/rusmes-test-storage"));
346        backend.message_store()
347    }
348
349    #[tokio::test]
350    async fn test_vacation_response_get() {
351        let store = create_test_store();
352        let request = VacationResponseGetRequest {
353            account_id: "acc1".to_string(),
354            ids: Some(vec!["singleton".to_string()]),
355            properties: None,
356        };
357
358        let response = vacation_response_get(request, store.as_ref())
359            .await
360            .unwrap();
361        assert_eq!(response.list.len(), 1);
362        assert_eq!(response.list[0].id, "singleton");
363        assert!(!response.list[0].is_enabled);
364    }
365
366    #[tokio::test]
367    async fn test_vacation_response_set() {
368        let store = create_test_store();
369        let mut update_map = HashMap::new();
370        update_map.insert(
371            "singleton".to_string(),
372            serde_json::json!({
373                "isEnabled": true,
374                "subject": "Out of Office",
375                "textBody": "I'm currently out of office."
376            }),
377        );
378
379        let request = VacationResponseSetRequest {
380            account_id: "acc1".to_string(),
381            if_in_state: None,
382            update: Some(update_map),
383        };
384
385        let response = vacation_response_set(request, store.as_ref())
386            .await
387            .unwrap();
388        assert!(response.not_updated.is_some());
389    }
390
391    #[tokio::test]
392    async fn test_is_vacation_active() {
393        let vacation = VacationResponse {
394            id: "singleton".to_string(),
395            is_enabled: true,
396            from_date: None,
397            to_date: None,
398            subject: None,
399            text_body: None,
400            html_body: None,
401        };
402
403        assert!(is_vacation_active(&vacation));
404    }
405
406    #[tokio::test]
407    async fn test_is_vacation_inactive_disabled() {
408        let vacation = VacationResponse {
409            id: "singleton".to_string(),
410            is_enabled: false,
411            from_date: None,
412            to_date: None,
413            subject: None,
414            text_body: None,
415            html_body: None,
416        };
417
418        assert!(!is_vacation_active(&vacation));
419    }
420
421    #[tokio::test]
422    async fn test_is_vacation_active_with_dates() {
423        let now = Utc::now();
424        let vacation = VacationResponse {
425            id: "singleton".to_string(),
426            is_enabled: true,
427            from_date: Some(now - Duration::days(1)),
428            to_date: Some(now + Duration::days(7)),
429            subject: None,
430            text_body: None,
431            html_body: None,
432        };
433
434        assert!(is_vacation_active(&vacation));
435    }
436
437    #[tokio::test]
438    async fn test_is_vacation_inactive_before_start() {
439        let now = Utc::now();
440        let vacation = VacationResponse {
441            id: "singleton".to_string(),
442            is_enabled: true,
443            from_date: Some(now + Duration::days(1)),
444            to_date: Some(now + Duration::days(7)),
445            subject: None,
446            text_body: None,
447            html_body: None,
448        };
449
450        assert!(!is_vacation_active(&vacation));
451    }
452
453    #[tokio::test]
454    async fn test_is_vacation_inactive_after_end() {
455        let now = Utc::now();
456        let vacation = VacationResponse {
457            id: "singleton".to_string(),
458            is_enabled: true,
459            from_date: Some(now - Duration::days(7)),
460            to_date: Some(now - Duration::days(1)),
461            subject: None,
462            text_body: None,
463            html_body: None,
464        };
465
466        assert!(!is_vacation_active(&vacation));
467    }
468
469    #[tokio::test]
470    async fn test_vacation_response_invalid_id() {
471        let store = create_test_store();
472        let mut update_map = HashMap::new();
473        update_map.insert(
474            "invalid".to_string(),
475            serde_json::json!({"isEnabled": true}),
476        );
477
478        let request = VacationResponseSetRequest {
479            account_id: "acc1".to_string(),
480            if_in_state: None,
481            update: Some(update_map),
482        };
483
484        let response = vacation_response_set(request, store.as_ref())
485            .await
486            .unwrap();
487        assert!(response.not_updated.is_some());
488        let errors = response.not_updated.unwrap();
489        assert_eq!(errors.get("invalid").unwrap().error_type, "notFound");
490    }
491
492    #[tokio::test]
493    async fn test_vacation_response_with_html() {
494        let store = create_test_store();
495        let mut update_map = HashMap::new();
496        update_map.insert(
497            "singleton".to_string(),
498            serde_json::json!({
499                "isEnabled": true,
500                "subject": "Out of Office",
501                "textBody": "I'm out of office.",
502                "htmlBody": "<p>I'm out of office.</p>"
503            }),
504        );
505
506        let request = VacationResponseSetRequest {
507            account_id: "acc1".to_string(),
508            if_in_state: None,
509            update: Some(update_map),
510        };
511
512        let response = vacation_response_set(request, store.as_ref())
513            .await
514            .unwrap();
515        assert!(response.not_updated.is_some());
516    }
517
518    #[tokio::test]
519    async fn test_vacation_response_get_all() {
520        let store = create_test_store();
521        let request = VacationResponseGetRequest {
522            account_id: "acc1".to_string(),
523            ids: None,
524            properties: None,
525        };
526
527        let response = vacation_response_get(request, store.as_ref())
528            .await
529            .unwrap();
530        assert_eq!(response.list.len(), 1);
531    }
532
533    #[tokio::test]
534    async fn test_vacation_response_date_range() {
535        let now = Utc::now();
536        let vacation = VacationResponse {
537            id: "singleton".to_string(),
538            is_enabled: true,
539            from_date: Some(now + Duration::days(1)),
540            to_date: Some(now + Duration::days(14)),
541            subject: Some("Vacation".to_string()),
542            text_body: Some("On vacation".to_string()),
543            html_body: None,
544        };
545
546        // Not yet active
547        assert!(!is_vacation_active(&vacation));
548    }
549
550    #[tokio::test]
551    async fn test_vacation_response_only_from_date() {
552        let now = Utc::now();
553        let vacation = VacationResponse {
554            id: "singleton".to_string(),
555            is_enabled: true,
556            from_date: Some(now - Duration::days(1)),
557            to_date: None,
558            subject: None,
559            text_body: None,
560            html_body: None,
561        };
562
563        assert!(is_vacation_active(&vacation));
564    }
565
566    #[tokio::test]
567    async fn test_vacation_response_only_to_date() {
568        let now = Utc::now();
569        let vacation = VacationResponse {
570            id: "singleton".to_string(),
571            is_enabled: true,
572            from_date: None,
573            to_date: Some(now + Duration::days(1)),
574            subject: None,
575            text_body: None,
576            html_body: None,
577        };
578
579        assert!(is_vacation_active(&vacation));
580    }
581
582    #[test]
583    fn test_vacation_tracker_new() {
584        let tracker = VacationTracker::new();
585        assert_eq!(tracker.recipient_count(), 0);
586    }
587
588    #[test]
589    fn test_vacation_tracker_should_send_new_recipient() {
590        let tracker = VacationTracker::new();
591        assert!(tracker.should_send_to("test@example.com"));
592    }
593
594    #[test]
595    fn test_vacation_tracker_record_sent() {
596        let tracker = VacationTracker::new();
597        tracker.record_sent("test@example.com".to_string());
598        assert_eq!(tracker.recipient_count(), 1);
599        assert!(!tracker.should_send_to("test@example.com"));
600    }
601
602    #[test]
603    fn test_vacation_tracker_multiple_recipients() {
604        let tracker = VacationTracker::new();
605        tracker.record_sent("user1@example.com".to_string());
606        tracker.record_sent("user2@example.com".to_string());
607
608        assert_eq!(tracker.recipient_count(), 2);
609        assert!(!tracker.should_send_to("user1@example.com"));
610        assert!(!tracker.should_send_to("user2@example.com"));
611        assert!(tracker.should_send_to("user3@example.com"));
612    }
613
614    #[test]
615    fn test_generate_vacation_message_inactive() {
616        let vacation = VacationResponse {
617            id: "singleton".to_string(),
618            is_enabled: false,
619            from_date: None,
620            to_date: None,
621            subject: None,
622            text_body: Some("Away".to_string()),
623            html_body: None,
624        };
625
626        let message = generate_vacation_message(&vacation, Some("Hello"));
627        assert!(message.is_none());
628    }
629
630    #[test]
631    fn test_generate_vacation_message_active() {
632        let vacation = VacationResponse {
633            id: "singleton".to_string(),
634            is_enabled: true,
635            from_date: None,
636            to_date: None,
637            subject: Some("Out of Office".to_string()),
638            text_body: Some("I'm away".to_string()),
639            html_body: None,
640        };
641
642        let message = generate_vacation_message(&vacation, Some("Hello"));
643        assert!(message.is_some());
644        let msg = message.unwrap();
645        assert_eq!(msg.subject, "Out of Office");
646        assert_eq!(msg.text_body, Some("I'm away".to_string()));
647    }
648
649    #[test]
650    fn test_generate_vacation_message_default_subject() {
651        let vacation = VacationResponse {
652            id: "singleton".to_string(),
653            is_enabled: true,
654            from_date: None,
655            to_date: None,
656            subject: None,
657            text_body: Some("Away".to_string()),
658            html_body: None,
659        };
660
661        let message = generate_vacation_message(&vacation, Some("Meeting tomorrow"));
662        assert!(message.is_some());
663        let msg = message.unwrap();
664        assert_eq!(msg.subject, "Re: Meeting tomorrow");
665    }
666
667    #[test]
668    fn test_generate_vacation_message_no_original_subject() {
669        let vacation = VacationResponse {
670            id: "singleton".to_string(),
671            is_enabled: true,
672            from_date: None,
673            to_date: None,
674            subject: None,
675            text_body: Some("Away".to_string()),
676            html_body: None,
677        };
678
679        let message = generate_vacation_message(&vacation, None);
680        assert!(message.is_some());
681        let msg = message.unwrap();
682        assert_eq!(msg.subject, "Automatic reply");
683    }
684
685    #[test]
686    fn test_generate_vacation_headers() {
687        let headers = generate_vacation_headers();
688        assert_eq!(headers.len(), 2);
689
690        assert!(headers
691            .iter()
692            .any(|(k, v)| k == "Auto-Submitted" && v == "auto-replied"));
693        assert!(headers
694            .iter()
695            .any(|(k, v)| k == "Precedence" && v == "bulk"));
696    }
697
698    #[test]
699    fn test_extract_vacation_recipients_valid() {
700        let recipients = extract_vacation_recipients("user@example.com", &[]);
701        assert_eq!(recipients.len(), 1);
702        assert_eq!(recipients[0], "user@example.com");
703    }
704
705    #[test]
706    fn test_extract_vacation_recipients_auto_submitted() {
707        let recipients = extract_vacation_recipients(
708            "user@example.com",
709            &[("Auto-Submitted".to_string(), "auto-replied".to_string())],
710        );
711        assert_eq!(recipients.len(), 0);
712    }
713
714    #[test]
715    fn test_extract_vacation_recipients_bulk() {
716        let recipients = extract_vacation_recipients(
717            "user@example.com",
718            &[("Precedence".to_string(), "bulk".to_string())],
719        );
720        assert_eq!(recipients.len(), 0);
721    }
722
723    #[test]
724    fn test_extract_vacation_recipients_list() {
725        let recipients = extract_vacation_recipients(
726            "user@example.com",
727            &[("List-Id".to_string(), "list@example.com".to_string())],
728        );
729        assert_eq!(recipients.len(), 0);
730    }
731
732    #[test]
733    fn test_extract_vacation_recipients_invalid_email() {
734        let recipients = extract_vacation_recipients("invalid-email", &[]);
735        assert_eq!(recipients.len(), 0);
736    }
737
738    #[test]
739    fn test_extract_vacation_recipients_empty() {
740        let recipients = extract_vacation_recipients("", &[]);
741        assert_eq!(recipients.len(), 0);
742    }
743
744    #[test]
745    fn test_vacation_message_with_html() {
746        let vacation = VacationResponse {
747            id: "singleton".to_string(),
748            is_enabled: true,
749            from_date: None,
750            to_date: None,
751            subject: Some("Away".to_string()),
752            text_body: Some("I'm away".to_string()),
753            html_body: Some("<p>I'm away</p>".to_string()),
754        };
755
756        let message = generate_vacation_message(&vacation, None);
757        assert!(message.is_some());
758        let msg = message.unwrap();
759        assert_eq!(msg.html_body, Some("<p>I'm away</p>".to_string()));
760    }
761
762    #[test]
763    fn test_vacation_tracker_default() {
764        let tracker = VacationTracker::default();
765        assert_eq!(tracker.recipient_count(), 0);
766    }
767}