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::methods::ensure_account_ownership;
11use crate::types::{JmapSetError, Principal};
12use chrono::{DateTime, Duration, Utc};
13use rusmes_storage::MessageStore;
14use serde::{Deserialize, Serialize};
15use std::collections::HashMap;
16use std::path::PathBuf;
17use std::sync::{Arc, Mutex};
18
19/// VacationResponse object
20#[derive(Debug, Clone, Serialize, Deserialize)]
21#[serde(rename_all = "camelCase")]
22pub struct VacationResponse {
23    /// Unique identifier (singleton, always "singleton")
24    pub id: String,
25    /// Is enabled
26    pub is_enabled: bool,
27    /// Start date (UTC)
28    #[serde(skip_serializing_if = "Option::is_none")]
29    pub from_date: Option<DateTime<Utc>>,
30    /// End date (UTC)
31    #[serde(skip_serializing_if = "Option::is_none")]
32    pub to_date: Option<DateTime<Utc>>,
33    /// Subject of vacation message
34    #[serde(skip_serializing_if = "Option::is_none")]
35    pub subject: Option<String>,
36    /// Text body of vacation message
37    #[serde(skip_serializing_if = "Option::is_none")]
38    pub text_body: Option<String>,
39    /// HTML body of vacation message
40    #[serde(skip_serializing_if = "Option::is_none")]
41    pub html_body: Option<String>,
42}
43
44/// Persisted state file for a single account's vacation setting
45#[derive(Debug, Clone, Serialize, Deserialize)]
46struct VacationStateFile {
47    vacation: VacationResponse,
48    state: u64,
49}
50
51/// Trait for persisting VacationResponse data per account
52pub trait VacationStore: Send + Sync {
53    /// Retrieve the vacation response for the given account.
54    /// Returns `None` when no record has been stored yet.
55    fn get_vacation(&self, account_id: &str) -> anyhow::Result<Option<VacationResponse>>;
56
57    /// Persist a new vacation response for the given account.
58    fn set_vacation(&self, account_id: &str, vacation: VacationResponse) -> anyhow::Result<()>;
59
60    /// Return the current state token (opaque string) for the given account.
61    /// Returns `"0"` when no record exists yet.
62    fn state_token(&self, account_id: &str) -> anyhow::Result<String>;
63}
64
65/// File-system backed vacation store.
66///
67/// Stores one JSON file per account at `{base_dir}/vacations/{account_id}.json`.
68pub struct FileVacationStore {
69    base_dir: PathBuf,
70}
71
72impl FileVacationStore {
73    /// Create a new `FileVacationStore` rooted at `base_dir`.
74    pub fn new(base_dir: impl Into<PathBuf>) -> Self {
75        Self {
76            base_dir: base_dir.into(),
77        }
78    }
79
80    fn vacations_dir(&self) -> PathBuf {
81        self.base_dir.join("vacations")
82    }
83
84    fn account_file(&self, account_id: &str) -> PathBuf {
85        // Sanitise the account id to avoid path traversal
86        let safe_id = account_id.replace(['/', '\\', '.'], "_");
87        self.vacations_dir().join(format!("{}.json", safe_id))
88    }
89
90    fn load_state_file(&self, account_id: &str) -> anyhow::Result<Option<VacationStateFile>> {
91        let path = self.account_file(account_id);
92        if !path.exists() {
93            return Ok(None);
94        }
95        let bytes = std::fs::read(&path)?;
96        let state_file: VacationStateFile = serde_json::from_slice(&bytes)?;
97        Ok(Some(state_file))
98    }
99
100    fn save_state_file(
101        &self,
102        account_id: &str,
103        state_file: &VacationStateFile,
104    ) -> anyhow::Result<()> {
105        let dir = self.vacations_dir();
106        std::fs::create_dir_all(&dir)?;
107        let path = self.account_file(account_id);
108        let bytes = serde_json::to_vec_pretty(state_file)?;
109        std::fs::write(&path, &bytes)?;
110        Ok(())
111    }
112}
113
114impl VacationStore for FileVacationStore {
115    fn get_vacation(&self, account_id: &str) -> anyhow::Result<Option<VacationResponse>> {
116        let state_file = self.load_state_file(account_id)?;
117        Ok(state_file.map(|sf| sf.vacation))
118    }
119
120    fn set_vacation(&self, account_id: &str, vacation: VacationResponse) -> anyhow::Result<()> {
121        let current_state = self
122            .load_state_file(account_id)?
123            .map(|sf| sf.state)
124            .unwrap_or(0);
125        let new_state = current_state.saturating_add(1);
126        let state_file = VacationStateFile {
127            vacation,
128            state: new_state,
129        };
130        self.save_state_file(account_id, &state_file)
131    }
132
133    fn state_token(&self, account_id: &str) -> anyhow::Result<String> {
134        let state = self
135            .load_state_file(account_id)?
136            .map(|sf| sf.state)
137            .unwrap_or(0);
138        Ok(state.to_string())
139    }
140}
141
142/// Default disabled vacation response
143fn default_vacation_response() -> VacationResponse {
144    VacationResponse {
145        id: "singleton".to_string(),
146        is_enabled: false,
147        from_date: None,
148        to_date: None,
149        subject: None,
150        text_body: None,
151        html_body: None,
152    }
153}
154
155/// VacationResponse/get request
156#[derive(Debug, Clone, Deserialize)]
157#[serde(rename_all = "camelCase")]
158pub struct VacationResponseGetRequest {
159    pub account_id: String,
160    #[serde(skip_serializing_if = "Option::is_none")]
161    pub ids: Option<Vec<String>>,
162    #[serde(skip_serializing_if = "Option::is_none")]
163    pub properties: Option<Vec<String>>,
164}
165
166/// VacationResponse/get response
167#[derive(Debug, Clone, Serialize)]
168#[serde(rename_all = "camelCase")]
169pub struct VacationResponseGetResponse {
170    pub account_id: String,
171    pub state: String,
172    pub list: Vec<VacationResponse>,
173    pub not_found: Vec<String>,
174}
175
176/// VacationResponse/set request
177#[derive(Debug, Clone, Deserialize)]
178#[serde(rename_all = "camelCase")]
179pub struct VacationResponseSetRequest {
180    pub account_id: String,
181    #[serde(skip_serializing_if = "Option::is_none")]
182    pub if_in_state: Option<String>,
183    #[serde(skip_serializing_if = "Option::is_none")]
184    pub update: Option<HashMap<String, serde_json::Value>>,
185}
186
187/// VacationResponse/set response
188#[derive(Debug, Clone, Serialize)]
189#[serde(rename_all = "camelCase")]
190pub struct VacationResponseSetResponse {
191    pub account_id: String,
192    pub old_state: String,
193    pub new_state: String,
194    #[serde(skip_serializing_if = "Option::is_none")]
195    pub updated: Option<HashMap<String, Option<VacationResponse>>>,
196    #[serde(skip_serializing_if = "Option::is_none")]
197    pub not_updated: Option<HashMap<String, JmapSetError>>,
198}
199
200/// Recipient tracking entry
201#[derive(Debug, Clone)]
202struct RecipientEntry {
203    _email: String,
204    last_sent: DateTime<Utc>,
205}
206
207/// Vacation response tracker for duplicate prevention
208#[derive(Debug, Clone)]
209pub struct VacationTracker {
210    recipients: Arc<Mutex<HashMap<String, RecipientEntry>>>,
211}
212
213impl VacationTracker {
214    /// Create a new vacation tracker
215    pub fn new() -> Self {
216        Self {
217            recipients: Arc::new(Mutex::new(HashMap::new())),
218        }
219    }
220
221    /// Check if we should send vacation response to this recipient
222    /// Returns true if we haven't sent to them in the last 7 days
223    pub fn should_send_to(&self, email: &str) -> bool {
224        let mut recipients = match self.recipients.lock() {
225            Ok(guard) => guard,
226            Err(poisoned) => poisoned.into_inner(),
227        };
228
229        // Clean up old entries (>7 days)
230        let cutoff = Utc::now() - Duration::days(7);
231        recipients.retain(|_, entry| entry.last_sent > cutoff);
232
233        // Check if we've sent recently
234        if let Some(entry) = recipients.get(email) {
235            let since_last = Utc::now() - entry.last_sent;
236            since_last > Duration::days(7)
237        } else {
238            true
239        }
240    }
241
242    /// Record that we sent a vacation response to this recipient
243    pub fn record_sent(&self, email: String) {
244        let mut recipients = match self.recipients.lock() {
245            Ok(guard) => guard,
246            Err(poisoned) => poisoned.into_inner(),
247        };
248        recipients.insert(
249            email.clone(),
250            RecipientEntry {
251                _email: email.clone(),
252                last_sent: Utc::now(),
253            },
254        );
255    }
256
257    /// Get count of tracked recipients
258    pub fn recipient_count(&self) -> usize {
259        match self.recipients.lock() {
260            Ok(guard) => guard.len(),
261            Err(poisoned) => poisoned.into_inner().len(),
262        }
263    }
264}
265
266impl Default for VacationTracker {
267    fn default() -> Self {
268        Self::new()
269    }
270}
271
272/// Vacation message content
273#[derive(Debug, Clone)]
274pub struct VacationMessage {
275    pub subject: String,
276    pub text_body: Option<String>,
277    pub html_body: Option<String>,
278}
279
280/// Apply a JMAP patch object to an existing `VacationResponse`.
281///
282/// Only keys present in `patch` are modified.  Keys not present in the patch
283/// leave the current value unchanged.  The `id` field is immutable and
284/// silently ignored if present in the patch.
285fn apply_patch(
286    mut vacation: VacationResponse,
287    patch: &serde_json::Value,
288) -> anyhow::Result<VacationResponse> {
289    let patch_obj = patch
290        .as_object()
291        .ok_or_else(|| anyhow::anyhow!("Patch must be a JSON object"))?;
292
293    for (key, value) in patch_obj {
294        match key.as_str() {
295            "isEnabled" => {
296                vacation.is_enabled = value
297                    .as_bool()
298                    .ok_or_else(|| anyhow::anyhow!("isEnabled must be a boolean"))?;
299            }
300            "fromDate" => {
301                if value.is_null() {
302                    vacation.from_date = None;
303                } else {
304                    let s = value
305                        .as_str()
306                        .ok_or_else(|| anyhow::anyhow!("fromDate must be a string or null"))?;
307                    vacation.from_date = Some(
308                        s.parse::<DateTime<Utc>>()
309                            .map_err(|e| anyhow::anyhow!("Invalid fromDate: {}", e))?,
310                    );
311                }
312            }
313            "toDate" => {
314                if value.is_null() {
315                    vacation.to_date = None;
316                } else {
317                    let s = value
318                        .as_str()
319                        .ok_or_else(|| anyhow::anyhow!("toDate must be a string or null"))?;
320                    vacation.to_date = Some(
321                        s.parse::<DateTime<Utc>>()
322                            .map_err(|e| anyhow::anyhow!("Invalid toDate: {}", e))?,
323                    );
324                }
325            }
326            "subject" => {
327                if value.is_null() {
328                    vacation.subject = None;
329                } else {
330                    vacation.subject = Some(
331                        value
332                            .as_str()
333                            .ok_or_else(|| anyhow::anyhow!("subject must be a string or null"))?
334                            .to_owned(),
335                    );
336                }
337            }
338            "textBody" => {
339                if value.is_null() {
340                    vacation.text_body = None;
341                } else {
342                    vacation.text_body = Some(
343                        value
344                            .as_str()
345                            .ok_or_else(|| anyhow::anyhow!("textBody must be a string or null"))?
346                            .to_owned(),
347                    );
348                }
349            }
350            "htmlBody" => {
351                if value.is_null() {
352                    vacation.html_body = None;
353                } else {
354                    vacation.html_body = Some(
355                        value
356                            .as_str()
357                            .ok_or_else(|| anyhow::anyhow!("htmlBody must be a string or null"))?
358                            .to_owned(),
359                    );
360                }
361            }
362            // id is immutable; unknown keys are silently ignored per JMAP patch semantics
363            _ => {}
364        }
365    }
366
367    Ok(vacation)
368}
369
370/// Handle VacationResponse/get method
371pub async fn vacation_response_get(
372    request: VacationResponseGetRequest,
373    _message_store: &dyn MessageStore,
374    principal: &Principal,
375    vacation_store: &dyn VacationStore,
376) -> anyhow::Result<VacationResponseGetResponse> {
377    ensure_account_ownership(&request.account_id, principal)?;
378    let mut list = Vec::new();
379    let mut not_found = Vec::new();
380
381    let current_state = vacation_store.state_token(&request.account_id)?;
382
383    // VacationResponse is a singleton
384    let ids = request.ids.unwrap_or_else(|| vec!["singleton".to_string()]);
385
386    for id in ids {
387        if id == "singleton" {
388            let vacation = vacation_store
389                .get_vacation(&request.account_id)?
390                .unwrap_or_else(default_vacation_response);
391            list.push(vacation);
392        } else {
393            not_found.push(id);
394        }
395    }
396
397    Ok(VacationResponseGetResponse {
398        account_id: request.account_id,
399        state: current_state,
400        list,
401        not_found,
402    })
403}
404
405/// Handle VacationResponse/set method
406pub async fn vacation_response_set(
407    request: VacationResponseSetRequest,
408    _message_store: &dyn MessageStore,
409    principal: &Principal,
410    vacation_store: &dyn VacationStore,
411) -> anyhow::Result<VacationResponseSetResponse> {
412    ensure_account_ownership(&request.account_id, principal)?;
413
414    let old_state = vacation_store.state_token(&request.account_id)?;
415
416    // Check if_in_state guard (RFC 8620 §5.3 stateMismatch)
417    if let Some(ref expected) = request.if_in_state {
418        if expected != &old_state {
419            return Err(anyhow::anyhow!(
420                "stateMismatch: expected state '{}', current state '{}'",
421                expected,
422                old_state
423            ));
424        }
425    }
426
427    let mut updated: HashMap<String, Option<VacationResponse>> = HashMap::new();
428    let mut not_updated: HashMap<String, JmapSetError> = HashMap::new();
429
430    // VacationResponse only supports update (singleton object)
431    if let Some(update_map) = request.update {
432        for (id, patch) in update_map {
433            if id != "singleton" {
434                not_updated.insert(
435                    id,
436                    JmapSetError {
437                        error_type: "notFound".to_string(),
438                        description: Some("VacationResponse ID must be 'singleton'".to_string()),
439                    },
440                );
441            } else {
442                // Load current vacation (or create default)
443                let current = vacation_store
444                    .get_vacation(&request.account_id)?
445                    .unwrap_or_else(default_vacation_response);
446
447                // Apply patch
448                let patched = match apply_patch(current, &patch) {
449                    Ok(v) => v,
450                    Err(e) => {
451                        not_updated.insert(
452                            id,
453                            JmapSetError {
454                                error_type: "invalidProperties".to_string(),
455                                description: Some(format!("Patch error: {}", e)),
456                            },
457                        );
458                        continue;
459                    }
460                };
461
462                // Validate date ordering
463                if let (Some(from), Some(to)) = (patched.from_date, patched.to_date) {
464                    if from > to {
465                        not_updated.insert(
466                            id,
467                            JmapSetError {
468                                error_type: "invalidProperties".to_string(),
469                                description: Some(
470                                    "fromDate must be before or equal to toDate".to_string(),
471                                ),
472                            },
473                        );
474                        continue;
475                    }
476                }
477
478                // Persist
479                vacation_store.set_vacation(&request.account_id, patched.clone())?;
480
481                updated.insert(id, Some(patched));
482            }
483        }
484    }
485
486    let new_state = vacation_store.state_token(&request.account_id)?;
487
488    Ok(VacationResponseSetResponse {
489        account_id: request.account_id,
490        old_state,
491        new_state,
492        updated: if updated.is_empty() {
493            None
494        } else {
495            Some(updated)
496        },
497        not_updated: if not_updated.is_empty() {
498            None
499        } else {
500            Some(not_updated)
501        },
502    })
503}
504
505/// Check if vacation response should be active
506pub fn is_vacation_active(vacation: &VacationResponse) -> bool {
507    if !vacation.is_enabled {
508        return false;
509    }
510
511    let now = Utc::now();
512
513    // Check from_date
514    if let Some(from_date) = vacation.from_date {
515        if now < from_date {
516            return false;
517        }
518    }
519
520    // Check to_date
521    if let Some(to_date) = vacation.to_date {
522        if now > to_date {
523            return false;
524        }
525    }
526
527    true
528}
529
530/// Generate vacation response message
531pub fn generate_vacation_message(
532    vacation: &VacationResponse,
533    original_subject: Option<&str>,
534) -> Option<VacationMessage> {
535    if !is_vacation_active(vacation) {
536        return None;
537    }
538
539    // Generate subject
540    let subject = if let Some(custom_subject) = &vacation.subject {
541        custom_subject.clone()
542    } else if let Some(orig_subj) = original_subject {
543        format!("Re: {}", orig_subj)
544    } else {
545        "Automatic reply".to_string()
546    };
547
548    Some(VacationMessage {
549        subject,
550        text_body: vacation.text_body.clone(),
551        html_body: vacation.html_body.clone(),
552    })
553}
554
555/// Generate vacation response headers
556pub fn generate_vacation_headers() -> Vec<(String, String)> {
557    vec![
558        ("Auto-Submitted".to_string(), "auto-replied".to_string()),
559        ("Precedence".to_string(), "bulk".to_string()),
560    ]
561}
562
563/// Extract email addresses that should receive vacation responses
564/// Filters out mailing lists, bulk mail, and auto-submitted messages
565pub fn extract_vacation_recipients(from: &str, headers: &[(String, String)]) -> Vec<String> {
566    let mut recipients = Vec::new();
567
568    // Check for auto-submitted header (don't reply to auto-generated messages)
569    for (key, value) in headers {
570        if key.to_lowercase() == "auto-submitted" && value != "no" {
571            return recipients; // Don't send vacation response
572        }
573        if key.to_lowercase() == "precedence"
574            && (value == "bulk" || value == "list" || value == "junk")
575        {
576            return recipients; // Don't send vacation response to bulk/list mail
577        }
578        if key.to_lowercase() == "list-id" || key.to_lowercase() == "list-post" {
579            return recipients; // Don't send vacation response to mailing lists
580        }
581    }
582
583    // Add the from address if valid
584    if !from.is_empty() && from.contains('@') {
585        recipients.push(from.to_string());
586    }
587
588    recipients
589}
590
591#[cfg(test)]
592mod tests {
593    use super::*;
594    use rusmes_storage::backends::filesystem::FilesystemBackend;
595    use rusmes_storage::StorageBackend;
596    use std::path::PathBuf;
597
598    fn test_principal() -> crate::types::Principal {
599        crate::types::admin_principal_for_tests()
600    }
601
602    fn create_test_store() -> std::sync::Arc<dyn MessageStore> {
603        let backend = FilesystemBackend::new(PathBuf::from("/tmp/rusmes-test-storage"));
604        backend.message_store()
605    }
606
607    /// Create a `FileVacationStore` rooted in a unique temp directory so that
608    /// concurrent nextest workers do not share state.
609    fn make_vacation_store(test_name: &str) -> FileVacationStore {
610        let dir = std::env::temp_dir().join(format!("rusmes-vacation-test-{}", test_name));
611        FileVacationStore::new(dir)
612    }
613
614    // -----------------------------------------------------------------------
615    // Legacy tests (updated to match new signatures and success semantics)
616    // -----------------------------------------------------------------------
617
618    #[tokio::test]
619    async fn test_vacation_response_get() {
620        let store = create_test_store();
621        let vstore = make_vacation_store("get_basic");
622        let request = VacationResponseGetRequest {
623            account_id: "acc1".to_string(),
624            ids: Some(vec!["singleton".to_string()]),
625            properties: None,
626        };
627
628        let response = vacation_response_get(request, store.as_ref(), &test_principal(), &vstore)
629            .await
630            .unwrap();
631        assert_eq!(response.list.len(), 1);
632        assert_eq!(response.list[0].id, "singleton");
633        assert!(!response.list[0].is_enabled);
634    }
635
636    #[tokio::test]
637    async fn test_vacation_response_set() {
638        let store = create_test_store();
639        let vstore = make_vacation_store("set_basic");
640        let mut update_map = HashMap::new();
641        update_map.insert(
642            "singleton".to_string(),
643            serde_json::json!({
644                "isEnabled": true,
645                "subject": "Out of Office",
646                "textBody": "I'm currently out of office."
647            }),
648        );
649
650        let request = VacationResponseSetRequest {
651            account_id: "acc1".to_string(),
652            if_in_state: None,
653            update: Some(update_map),
654        };
655
656        let response = vacation_response_set(request, store.as_ref(), &test_principal(), &vstore)
657            .await
658            .unwrap();
659        // Successful update goes into `updated`, not `not_updated`
660        assert!(response.updated.is_some());
661        assert!(response.not_updated.is_none());
662        let updated = response.updated.unwrap();
663        let vacation = updated.get("singleton").unwrap().as_ref().unwrap();
664        assert!(vacation.is_enabled);
665        assert_eq!(vacation.subject.as_deref(), Some("Out of Office"));
666    }
667
668    #[tokio::test]
669    async fn test_is_vacation_active() {
670        let vacation = VacationResponse {
671            id: "singleton".to_string(),
672            is_enabled: true,
673            from_date: None,
674            to_date: None,
675            subject: None,
676            text_body: None,
677            html_body: None,
678        };
679
680        assert!(is_vacation_active(&vacation));
681    }
682
683    #[tokio::test]
684    async fn test_is_vacation_inactive_disabled() {
685        let vacation = VacationResponse {
686            id: "singleton".to_string(),
687            is_enabled: false,
688            from_date: None,
689            to_date: None,
690            subject: None,
691            text_body: None,
692            html_body: None,
693        };
694
695        assert!(!is_vacation_active(&vacation));
696    }
697
698    #[tokio::test]
699    async fn test_is_vacation_active_with_dates() {
700        let now = Utc::now();
701        let vacation = VacationResponse {
702            id: "singleton".to_string(),
703            is_enabled: true,
704            from_date: Some(now - Duration::days(1)),
705            to_date: Some(now + Duration::days(7)),
706            subject: None,
707            text_body: None,
708            html_body: None,
709        };
710
711        assert!(is_vacation_active(&vacation));
712    }
713
714    #[tokio::test]
715    async fn test_is_vacation_inactive_before_start() {
716        let now = Utc::now();
717        let vacation = VacationResponse {
718            id: "singleton".to_string(),
719            is_enabled: true,
720            from_date: Some(now + Duration::days(1)),
721            to_date: Some(now + Duration::days(7)),
722            subject: None,
723            text_body: None,
724            html_body: None,
725        };
726
727        assert!(!is_vacation_active(&vacation));
728    }
729
730    #[tokio::test]
731    async fn test_is_vacation_inactive_after_end() {
732        let now = Utc::now();
733        let vacation = VacationResponse {
734            id: "singleton".to_string(),
735            is_enabled: true,
736            from_date: Some(now - Duration::days(7)),
737            to_date: Some(now - Duration::days(1)),
738            subject: None,
739            text_body: None,
740            html_body: None,
741        };
742
743        assert!(!is_vacation_active(&vacation));
744    }
745
746    #[tokio::test]
747    async fn test_vacation_response_invalid_id() {
748        let store = create_test_store();
749        let vstore = make_vacation_store("invalid_id");
750        let mut update_map = HashMap::new();
751        update_map.insert(
752            "invalid".to_string(),
753            serde_json::json!({"isEnabled": true}),
754        );
755
756        let request = VacationResponseSetRequest {
757            account_id: "acc1".to_string(),
758            if_in_state: None,
759            update: Some(update_map),
760        };
761
762        let response = vacation_response_set(request, store.as_ref(), &test_principal(), &vstore)
763            .await
764            .unwrap();
765        assert!(response.not_updated.is_some());
766        let errors = response.not_updated.unwrap();
767        assert_eq!(errors.get("invalid").unwrap().error_type, "notFound");
768    }
769
770    #[tokio::test]
771    async fn test_vacation_response_with_html() {
772        let store = create_test_store();
773        let vstore = make_vacation_store("with_html");
774        let mut update_map = HashMap::new();
775        update_map.insert(
776            "singleton".to_string(),
777            serde_json::json!({
778                "isEnabled": true,
779                "subject": "Out of Office",
780                "textBody": "I'm out of office.",
781                "htmlBody": "<p>I'm out of office.</p>"
782            }),
783        );
784
785        let request = VacationResponseSetRequest {
786            account_id: "acc1".to_string(),
787            if_in_state: None,
788            update: Some(update_map),
789        };
790
791        let response = vacation_response_set(request, store.as_ref(), &test_principal(), &vstore)
792            .await
793            .unwrap();
794        // Successful update goes into `updated`
795        assert!(response.updated.is_some());
796        let updated = response.updated.unwrap();
797        let vacation = updated.get("singleton").unwrap().as_ref().unwrap();
798        assert_eq!(
799            vacation.html_body.as_deref(),
800            Some("<p>I'm out of office.</p>")
801        );
802    }
803
804    #[tokio::test]
805    async fn test_vacation_response_get_all() {
806        let store = create_test_store();
807        let vstore = make_vacation_store("get_all");
808        let request = VacationResponseGetRequest {
809            account_id: "acc1".to_string(),
810            ids: None,
811            properties: None,
812        };
813
814        let response = vacation_response_get(request, store.as_ref(), &test_principal(), &vstore)
815            .await
816            .unwrap();
817        assert_eq!(response.list.len(), 1);
818    }
819
820    #[tokio::test]
821    async fn test_vacation_response_date_range() {
822        let now = Utc::now();
823        let vacation = VacationResponse {
824            id: "singleton".to_string(),
825            is_enabled: true,
826            from_date: Some(now + Duration::days(1)),
827            to_date: Some(now + Duration::days(14)),
828            subject: Some("Vacation".to_string()),
829            text_body: Some("On vacation".to_string()),
830            html_body: None,
831        };
832
833        // Not yet active
834        assert!(!is_vacation_active(&vacation));
835    }
836
837    #[tokio::test]
838    async fn test_vacation_response_only_from_date() {
839        let now = Utc::now();
840        let vacation = VacationResponse {
841            id: "singleton".to_string(),
842            is_enabled: true,
843            from_date: Some(now - Duration::days(1)),
844            to_date: None,
845            subject: None,
846            text_body: None,
847            html_body: None,
848        };
849
850        assert!(is_vacation_active(&vacation));
851    }
852
853    #[tokio::test]
854    async fn test_vacation_response_only_to_date() {
855        let now = Utc::now();
856        let vacation = VacationResponse {
857            id: "singleton".to_string(),
858            is_enabled: true,
859            from_date: None,
860            to_date: Some(now + Duration::days(1)),
861            subject: None,
862            text_body: None,
863            html_body: None,
864        };
865
866        assert!(is_vacation_active(&vacation));
867    }
868
869    #[test]
870    fn test_vacation_tracker_new() {
871        let tracker = VacationTracker::new();
872        assert_eq!(tracker.recipient_count(), 0);
873    }
874
875    #[test]
876    fn test_vacation_tracker_should_send_new_recipient() {
877        let tracker = VacationTracker::new();
878        assert!(tracker.should_send_to("test@example.com"));
879    }
880
881    #[test]
882    fn test_vacation_tracker_record_sent() {
883        let tracker = VacationTracker::new();
884        tracker.record_sent("test@example.com".to_string());
885        assert_eq!(tracker.recipient_count(), 1);
886        assert!(!tracker.should_send_to("test@example.com"));
887    }
888
889    #[test]
890    fn test_vacation_tracker_multiple_recipients() {
891        let tracker = VacationTracker::new();
892        tracker.record_sent("user1@example.com".to_string());
893        tracker.record_sent("user2@example.com".to_string());
894
895        assert_eq!(tracker.recipient_count(), 2);
896        assert!(!tracker.should_send_to("user1@example.com"));
897        assert!(!tracker.should_send_to("user2@example.com"));
898        assert!(tracker.should_send_to("user3@example.com"));
899    }
900
901    #[test]
902    fn test_generate_vacation_message_inactive() {
903        let vacation = VacationResponse {
904            id: "singleton".to_string(),
905            is_enabled: false,
906            from_date: None,
907            to_date: None,
908            subject: None,
909            text_body: Some("Away".to_string()),
910            html_body: None,
911        };
912
913        let message = generate_vacation_message(&vacation, Some("Hello"));
914        assert!(message.is_none());
915    }
916
917    #[test]
918    fn test_generate_vacation_message_active() {
919        let vacation = VacationResponse {
920            id: "singleton".to_string(),
921            is_enabled: true,
922            from_date: None,
923            to_date: None,
924            subject: Some("Out of Office".to_string()),
925            text_body: Some("I'm away".to_string()),
926            html_body: None,
927        };
928
929        let message = generate_vacation_message(&vacation, Some("Hello"));
930        assert!(message.is_some());
931        let msg = message.unwrap();
932        assert_eq!(msg.subject, "Out of Office");
933        assert_eq!(msg.text_body, Some("I'm away".to_string()));
934    }
935
936    #[test]
937    fn test_generate_vacation_message_default_subject() {
938        let vacation = VacationResponse {
939            id: "singleton".to_string(),
940            is_enabled: true,
941            from_date: None,
942            to_date: None,
943            subject: None,
944            text_body: Some("Away".to_string()),
945            html_body: None,
946        };
947
948        let message = generate_vacation_message(&vacation, Some("Meeting tomorrow"));
949        assert!(message.is_some());
950        let msg = message.unwrap();
951        assert_eq!(msg.subject, "Re: Meeting tomorrow");
952    }
953
954    #[test]
955    fn test_generate_vacation_message_no_original_subject() {
956        let vacation = VacationResponse {
957            id: "singleton".to_string(),
958            is_enabled: true,
959            from_date: None,
960            to_date: None,
961            subject: None,
962            text_body: Some("Away".to_string()),
963            html_body: None,
964        };
965
966        let message = generate_vacation_message(&vacation, None);
967        assert!(message.is_some());
968        let msg = message.unwrap();
969        assert_eq!(msg.subject, "Automatic reply");
970    }
971
972    #[test]
973    fn test_generate_vacation_headers() {
974        let headers = generate_vacation_headers();
975        assert_eq!(headers.len(), 2);
976
977        assert!(headers
978            .iter()
979            .any(|(k, v)| k == "Auto-Submitted" && v == "auto-replied"));
980        assert!(headers
981            .iter()
982            .any(|(k, v)| k == "Precedence" && v == "bulk"));
983    }
984
985    #[test]
986    fn test_extract_vacation_recipients_valid() {
987        let recipients = extract_vacation_recipients("user@example.com", &[]);
988        assert_eq!(recipients.len(), 1);
989        assert_eq!(recipients[0], "user@example.com");
990    }
991
992    #[test]
993    fn test_extract_vacation_recipients_auto_submitted() {
994        let recipients = extract_vacation_recipients(
995            "user@example.com",
996            &[("Auto-Submitted".to_string(), "auto-replied".to_string())],
997        );
998        assert_eq!(recipients.len(), 0);
999    }
1000
1001    #[test]
1002    fn test_extract_vacation_recipients_bulk() {
1003        let recipients = extract_vacation_recipients(
1004            "user@example.com",
1005            &[("Precedence".to_string(), "bulk".to_string())],
1006        );
1007        assert_eq!(recipients.len(), 0);
1008    }
1009
1010    #[test]
1011    fn test_extract_vacation_recipients_list() {
1012        let recipients = extract_vacation_recipients(
1013            "user@example.com",
1014            &[("List-Id".to_string(), "list@example.com".to_string())],
1015        );
1016        assert_eq!(recipients.len(), 0);
1017    }
1018
1019    #[test]
1020    fn test_extract_vacation_recipients_invalid_email() {
1021        let recipients = extract_vacation_recipients("invalid-email", &[]);
1022        assert_eq!(recipients.len(), 0);
1023    }
1024
1025    #[test]
1026    fn test_extract_vacation_recipients_empty() {
1027        let recipients = extract_vacation_recipients("", &[]);
1028        assert_eq!(recipients.len(), 0);
1029    }
1030
1031    #[test]
1032    fn test_vacation_message_with_html() {
1033        let vacation = VacationResponse {
1034            id: "singleton".to_string(),
1035            is_enabled: true,
1036            from_date: None,
1037            to_date: None,
1038            subject: Some("Away".to_string()),
1039            text_body: Some("I'm away".to_string()),
1040            html_body: Some("<p>I'm away</p>".to_string()),
1041        };
1042
1043        let message = generate_vacation_message(&vacation, None);
1044        assert!(message.is_some());
1045        let msg = message.unwrap();
1046        assert_eq!(msg.html_body, Some("<p>I'm away</p>".to_string()));
1047    }
1048
1049    #[test]
1050    fn test_vacation_tracker_default() {
1051        let tracker = VacationTracker::default();
1052        assert_eq!(tracker.recipient_count(), 0);
1053    }
1054
1055    // -----------------------------------------------------------------------
1056    // New tests: store-backed set/get and error paths
1057    // -----------------------------------------------------------------------
1058
1059    #[tokio::test]
1060    async fn test_vacation_set_enabled() {
1061        let store = create_test_store();
1062        let vstore = make_vacation_store("set_enabled");
1063
1064        // Set vacation to enabled
1065        let mut update_map = HashMap::new();
1066        update_map.insert(
1067            "singleton".to_string(),
1068            serde_json::json!({"isEnabled": true, "subject": "Away"}),
1069        );
1070        let set_req = VacationResponseSetRequest {
1071            account_id: "user1".to_string(),
1072            if_in_state: None,
1073            update: Some(update_map),
1074        };
1075        let set_resp = vacation_response_set(set_req, store.as_ref(), &test_principal(), &vstore)
1076            .await
1077            .unwrap();
1078        assert!(set_resp.updated.is_some());
1079
1080        // Get should now return enabled
1081        let get_req = VacationResponseGetRequest {
1082            account_id: "user1".to_string(),
1083            ids: Some(vec!["singleton".to_string()]),
1084            properties: None,
1085        };
1086        let get_resp = vacation_response_get(get_req, store.as_ref(), &test_principal(), &vstore)
1087            .await
1088            .unwrap();
1089        assert_eq!(get_resp.list.len(), 1);
1090        assert!(get_resp.list[0].is_enabled);
1091        assert_eq!(get_resp.list[0].subject.as_deref(), Some("Away"));
1092    }
1093
1094    #[tokio::test]
1095    async fn test_vacation_set_date_range() {
1096        let store = create_test_store();
1097        let vstore = make_vacation_store("set_date_range");
1098        let now = Utc::now();
1099        let from = now - Duration::days(1);
1100        let to = now + Duration::days(7);
1101
1102        let mut update_map = HashMap::new();
1103        update_map.insert(
1104            "singleton".to_string(),
1105            serde_json::json!({
1106                "isEnabled": true,
1107                "fromDate": from.to_rfc3339(),
1108                "toDate": to.to_rfc3339()
1109            }),
1110        );
1111        let set_req = VacationResponseSetRequest {
1112            account_id: "user2".to_string(),
1113            if_in_state: None,
1114            update: Some(update_map),
1115        };
1116        vacation_response_set(set_req, store.as_ref(), &test_principal(), &vstore)
1117            .await
1118            .unwrap();
1119
1120        // Verify retrieved dates are within 1 second of originals
1121        let get_req = VacationResponseGetRequest {
1122            account_id: "user2".to_string(),
1123            ids: None,
1124            properties: None,
1125        };
1126        let get_resp = vacation_response_get(get_req, store.as_ref(), &test_principal(), &vstore)
1127            .await
1128            .unwrap();
1129        let v = &get_resp.list[0];
1130        let stored_from = v.from_date.expect("from_date should be set");
1131        let stored_to = v.to_date.expect("to_date should be set");
1132        assert!((stored_from - from).num_seconds().abs() <= 1);
1133        assert!((stored_to - to).num_seconds().abs() <= 1);
1134    }
1135
1136    #[tokio::test]
1137    async fn test_vacation_invalid_date_order() {
1138        let store = create_test_store();
1139        let vstore = make_vacation_store("invalid_date_order");
1140        let now = Utc::now();
1141        // fromDate is AFTER toDate — should produce an invalidProperties error
1142        let from = now + Duration::days(5);
1143        let to = now + Duration::days(2);
1144
1145        let mut update_map = HashMap::new();
1146        update_map.insert(
1147            "singleton".to_string(),
1148            serde_json::json!({
1149                "isEnabled": true,
1150                "fromDate": from.to_rfc3339(),
1151                "toDate": to.to_rfc3339()
1152            }),
1153        );
1154        let set_req = VacationResponseSetRequest {
1155            account_id: "user3".to_string(),
1156            if_in_state: None,
1157            update: Some(update_map),
1158        };
1159        let resp = vacation_response_set(set_req, store.as_ref(), &test_principal(), &vstore)
1160            .await
1161            .unwrap();
1162        assert!(resp.not_updated.is_some());
1163        let errors = resp.not_updated.unwrap();
1164        assert_eq!(
1165            errors.get("singleton").unwrap().error_type,
1166            "invalidProperties"
1167        );
1168    }
1169
1170    #[tokio::test]
1171    async fn test_vacation_state_mismatch() {
1172        let store = create_test_store();
1173        let vstore = make_vacation_store("state_mismatch");
1174
1175        // Initial state for new account is "0"
1176        let mut update_map = HashMap::new();
1177        update_map.insert(
1178            "singleton".to_string(),
1179            serde_json::json!({"isEnabled": false}),
1180        );
1181        let set_req = VacationResponseSetRequest {
1182            account_id: "user4".to_string(),
1183            if_in_state: Some("999".to_string()), // wrong state
1184            update: Some(update_map),
1185        };
1186        let result =
1187            vacation_response_set(set_req, store.as_ref(), &test_principal(), &vstore).await;
1188        assert!(result.is_err());
1189        let msg = result.unwrap_err().to_string();
1190        assert!(msg.contains("stateMismatch"));
1191    }
1192
1193    #[tokio::test]
1194    async fn test_vacation_full_roundtrip() {
1195        // Integration test using a real temp directory.
1196        // Clean up before construction so stale state from a previous interrupted
1197        // run does not cause the initial-state assertion to fail.
1198        let temp_dir = std::env::temp_dir().join("rusmes-vacation-roundtrip-test");
1199        let _ = std::fs::remove_dir_all(&temp_dir);
1200        let vstore = FileVacationStore::new(&temp_dir);
1201        let store = create_test_store();
1202        let account_id = "roundtrip_user";
1203
1204        // 1. Initial get — should return disabled default
1205        let get_req = VacationResponseGetRequest {
1206            account_id: account_id.to_string(),
1207            ids: None,
1208            properties: None,
1209        };
1210        let get_resp = vacation_response_get(get_req, store.as_ref(), &test_principal(), &vstore)
1211            .await
1212            .unwrap();
1213        assert!(!get_resp.list[0].is_enabled);
1214        assert_eq!(get_resp.state, "0");
1215
1216        // 2. Set vacation
1217        let mut update_map = HashMap::new();
1218        update_map.insert(
1219            "singleton".to_string(),
1220            serde_json::json!({
1221                "isEnabled": true,
1222                "subject": "Roundtrip test",
1223                "textBody": "Gone fishing"
1224            }),
1225        );
1226        let set_req = VacationResponseSetRequest {
1227            account_id: account_id.to_string(),
1228            if_in_state: Some("0".to_string()),
1229            update: Some(update_map),
1230        };
1231        let set_resp = vacation_response_set(set_req, store.as_ref(), &test_principal(), &vstore)
1232            .await
1233            .unwrap();
1234        assert!(set_resp.updated.is_some());
1235        assert_eq!(set_resp.old_state, "0");
1236        assert_eq!(set_resp.new_state, "1");
1237
1238        // 3. Get again — should reflect changes
1239        let get_req2 = VacationResponseGetRequest {
1240            account_id: account_id.to_string(),
1241            ids: None,
1242            properties: None,
1243        };
1244        let get_resp2 = vacation_response_get(get_req2, store.as_ref(), &test_principal(), &vstore)
1245            .await
1246            .unwrap();
1247        let v = &get_resp2.list[0];
1248        assert!(v.is_enabled);
1249        assert_eq!(v.subject.as_deref(), Some("Roundtrip test"));
1250        assert_eq!(v.text_body.as_deref(), Some("Gone fishing"));
1251        assert_eq!(get_resp2.state, "1");
1252
1253        // 4. Second set using correct state token
1254        let mut update_map2 = HashMap::new();
1255        update_map2.insert(
1256            "singleton".to_string(),
1257            serde_json::json!({"isEnabled": false}),
1258        );
1259        let set_req2 = VacationResponseSetRequest {
1260            account_id: account_id.to_string(),
1261            if_in_state: Some("1".to_string()),
1262            update: Some(update_map2),
1263        };
1264        let set_resp2 = vacation_response_set(set_req2, store.as_ref(), &test_principal(), &vstore)
1265            .await
1266            .unwrap();
1267        assert!(set_resp2.updated.is_some());
1268        assert_eq!(set_resp2.new_state, "2");
1269
1270        // Cleanup
1271        let _ = std::fs::remove_dir_all(&temp_dir);
1272    }
1273}