1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
21#[serde(rename_all = "camelCase")]
22pub struct VacationResponse {
23 pub id: String,
25 pub is_enabled: bool,
27 #[serde(skip_serializing_if = "Option::is_none")]
29 pub from_date: Option<DateTime<Utc>>,
30 #[serde(skip_serializing_if = "Option::is_none")]
32 pub to_date: Option<DateTime<Utc>>,
33 #[serde(skip_serializing_if = "Option::is_none")]
35 pub subject: Option<String>,
36 #[serde(skip_serializing_if = "Option::is_none")]
38 pub text_body: Option<String>,
39 #[serde(skip_serializing_if = "Option::is_none")]
41 pub html_body: Option<String>,
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize)]
46struct VacationStateFile {
47 vacation: VacationResponse,
48 state: u64,
49}
50
51pub trait VacationStore: Send + Sync {
53 fn get_vacation(&self, account_id: &str) -> anyhow::Result<Option<VacationResponse>>;
56
57 fn set_vacation(&self, account_id: &str, vacation: VacationResponse) -> anyhow::Result<()>;
59
60 fn state_token(&self, account_id: &str) -> anyhow::Result<String>;
63}
64
65pub struct FileVacationStore {
69 base_dir: PathBuf,
70}
71
72impl FileVacationStore {
73 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 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
142fn 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#[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#[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#[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#[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#[derive(Debug, Clone)]
202struct RecipientEntry {
203 _email: String,
204 last_sent: DateTime<Utc>,
205}
206
207#[derive(Debug, Clone)]
209pub struct VacationTracker {
210 recipients: Arc<Mutex<HashMap<String, RecipientEntry>>>,
211}
212
213impl VacationTracker {
214 pub fn new() -> Self {
216 Self {
217 recipients: Arc::new(Mutex::new(HashMap::new())),
218 }
219 }
220
221 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 let cutoff = Utc::now() - Duration::days(7);
231 recipients.retain(|_, entry| entry.last_sent > cutoff);
232
233 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 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 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#[derive(Debug, Clone)]
274pub struct VacationMessage {
275 pub subject: String,
276 pub text_body: Option<String>,
277 pub html_body: Option<String>,
278}
279
280fn 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 _ => {}
364 }
365 }
366
367 Ok(vacation)
368}
369
370pub 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 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
405pub 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 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 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 let current = vacation_store
444 .get_vacation(&request.account_id)?
445 .unwrap_or_else(default_vacation_response);
446
447 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 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 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
505pub fn is_vacation_active(vacation: &VacationResponse) -> bool {
507 if !vacation.is_enabled {
508 return false;
509 }
510
511 let now = Utc::now();
512
513 if let Some(from_date) = vacation.from_date {
515 if now < from_date {
516 return false;
517 }
518 }
519
520 if let Some(to_date) = vacation.to_date {
522 if now > to_date {
523 return false;
524 }
525 }
526
527 true
528}
529
530pub 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 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
555pub 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
563pub fn extract_vacation_recipients(from: &str, headers: &[(String, String)]) -> Vec<String> {
566 let mut recipients = Vec::new();
567
568 for (key, value) in headers {
570 if key.to_lowercase() == "auto-submitted" && value != "no" {
571 return recipients; }
573 if key.to_lowercase() == "precedence"
574 && (value == "bulk" || value == "list" || value == "junk")
575 {
576 return recipients; }
578 if key.to_lowercase() == "list-id" || key.to_lowercase() == "list-post" {
579 return recipients; }
581 }
582
583 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 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 #[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 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 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 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 #[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 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 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 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 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 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()), 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 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 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 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 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 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 let _ = std::fs::remove_dir_all(&temp_dir);
1272 }
1273}