1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
19#[serde(rename_all = "camelCase")]
20pub struct VacationResponse {
21 pub id: String,
23 pub is_enabled: bool,
25 #[serde(skip_serializing_if = "Option::is_none")]
27 pub from_date: Option<DateTime<Utc>>,
28 #[serde(skip_serializing_if = "Option::is_none")]
30 pub to_date: Option<DateTime<Utc>>,
31 #[serde(skip_serializing_if = "Option::is_none")]
33 pub subject: Option<String>,
34 #[serde(skip_serializing_if = "Option::is_none")]
36 pub text_body: Option<String>,
37 #[serde(skip_serializing_if = "Option::is_none")]
39 pub html_body: Option<String>,
40}
41
42#[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#[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#[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#[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#[derive(Debug, Clone)]
89struct RecipientEntry {
90 _email: String,
91 last_sent: DateTime<Utc>,
92}
93
94#[derive(Debug, Clone)]
96pub struct VacationTracker {
97 recipients: Arc<Mutex<HashMap<String, RecipientEntry>>>,
98}
99
100impl VacationTracker {
101 pub fn new() -> Self {
103 Self {
104 recipients: Arc::new(Mutex::new(HashMap::new())),
105 }
106 }
107
108 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 let cutoff = Utc::now() - Duration::days(7);
118 recipients.retain(|_, entry| entry.last_sent > cutoff);
119
120 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 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 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#[derive(Debug, Clone)]
161pub struct VacationMessage {
162 pub subject: String,
163 pub text_body: Option<String>,
164 pub html_body: Option<String>,
165}
166
167pub 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 let ids = request.ids.unwrap_or_else(|| vec!["singleton".to_string()]);
177
178 for id in ids {
179 if id == "singleton" {
180 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
203pub 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 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
251pub fn is_vacation_active(vacation: &VacationResponse) -> bool {
253 if !vacation.is_enabled {
254 return false;
255 }
256
257 let now = Utc::now();
258
259 if let Some(from_date) = vacation.from_date {
261 if now < from_date {
262 return false;
263 }
264 }
265
266 if let Some(to_date) = vacation.to_date {
268 if now > to_date {
269 return false;
270 }
271 }
272
273 true
274}
275
276pub 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 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
301pub 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
309pub fn extract_vacation_recipients(from: &str, headers: &[(String, String)]) -> Vec<String> {
312 let mut recipients = Vec::new();
313
314 for (key, value) in headers {
316 if key.to_lowercase() == "auto-submitted" && value != "no" {
317 return recipients; }
319 if key.to_lowercase() == "precedence"
320 && (value == "bulk" || value == "list" || value == "junk")
321 {
322 return recipients; }
324 if key.to_lowercase() == "list-id" || key.to_lowercase() == "list-post" {
325 return recipients; }
327 }
328
329 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 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}