mockforge_smtp/
fixtures.rs

1//! SMTP fixture definitions and loading
2
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5
6/// An SMTP fixture defining how to handle emails
7#[derive(Debug, Clone, Serialize, Deserialize)]
8pub struct SmtpFixture {
9    /// Unique identifier for this fixture
10    pub identifier: String,
11
12    /// Human-readable name
13    pub name: String,
14
15    /// Description of what this fixture does
16    #[serde(default)]
17    pub description: String,
18
19    /// Matching criteria for emails
20    pub match_criteria: MatchCriteria,
21
22    /// Response configuration
23    pub response: SmtpResponse,
24
25    /// Auto-reply configuration
26    #[serde(default)]
27    pub auto_reply: Option<AutoReply>,
28
29    /// Storage configuration
30    #[serde(default)]
31    pub storage: StorageConfig,
32
33    /// Behavior simulation
34    #[serde(default)]
35    pub behavior: BehaviorConfig,
36}
37
38/// Criteria for matching incoming emails
39#[derive(Debug, Clone, Serialize, Deserialize, Default)]
40pub struct MatchCriteria {
41    /// Match by recipient pattern (regex)
42    #[serde(default)]
43    pub recipient_pattern: Option<String>,
44
45    /// Match by sender pattern (regex)
46    #[serde(default)]
47    pub sender_pattern: Option<String>,
48
49    /// Match by subject pattern (regex)
50    #[serde(default)]
51    pub subject_pattern: Option<String>,
52
53    /// Match all (default fixture)
54    #[serde(default)]
55    pub match_all: bool,
56}
57
58/// SMTP response configuration
59#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct SmtpResponse {
61    /// SMTP status code (250 = success, 550 = reject)
62    pub status_code: u16,
63
64    /// Status message
65    pub message: String,
66
67    /// Delay before responding (milliseconds)
68    #[serde(default)]
69    pub delay_ms: u64,
70}
71
72/// Auto-reply email configuration
73#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct AutoReply {
75    /// Enable auto-reply
76    pub enabled: bool,
77
78    /// From address
79    pub from: String,
80
81    /// To address (supports template: {{metadata.from}})
82    pub to: String,
83
84    /// Email subject
85    pub subject: String,
86
87    /// Email body (supports templates)
88    pub body: String,
89
90    /// Optional HTML body
91    #[serde(default)]
92    pub html_body: Option<String>,
93
94    /// Custom headers
95    #[serde(default)]
96    pub headers: HashMap<String, String>,
97}
98
99/// Email storage configuration
100#[derive(Debug, Clone, Serialize, Deserialize, Default)]
101pub struct StorageConfig {
102    /// Save emails to mailbox
103    #[serde(default)]
104    pub save_to_mailbox: bool,
105
106    /// Export to file
107    #[serde(default)]
108    pub export_to_file: Option<ExportConfig>,
109}
110
111/// File export configuration
112#[derive(Debug, Clone, Serialize, Deserialize)]
113pub struct ExportConfig {
114    /// Enable export
115    pub enabled: bool,
116
117    /// File path (supports templates)
118    pub path: String,
119}
120
121/// Behavior simulation configuration
122#[derive(Debug, Clone, Serialize, Deserialize, Default)]
123pub struct BehaviorConfig {
124    /// Failure rate (0.0 - 1.0)
125    #[serde(default)]
126    pub failure_rate: f64,
127
128    /// Latency range
129    #[serde(default)]
130    pub latency: Option<LatencyConfig>,
131}
132
133/// Latency configuration
134#[derive(Debug, Clone, Serialize, Deserialize)]
135pub struct LatencyConfig {
136    /// Minimum latency in milliseconds
137    pub min_ms: u64,
138
139    /// Maximum latency in milliseconds
140    pub max_ms: u64,
141}
142
143/// Stored email message
144#[derive(Debug, Clone, Serialize, Deserialize)]
145pub struct StoredEmail {
146    /// Unique ID
147    pub id: String,
148
149    /// From address
150    pub from: String,
151
152    /// To addresses
153    pub to: Vec<String>,
154
155    /// Subject
156    pub subject: String,
157
158    /// Body content
159    pub body: String,
160
161    /// Headers
162    pub headers: HashMap<String, String>,
163
164    /// Received timestamp
165    pub received_at: chrono::DateTime<chrono::Utc>,
166
167    /// Raw email data
168    #[serde(default)]
169    pub raw: Option<Vec<u8>>,
170}
171
172impl SmtpFixture {
173    /// Check if this fixture matches the given email criteria
174    pub fn matches(&self, from: &str, to: &str, subject: &str) -> bool {
175        use regex::Regex;
176
177        // If match_all is true, this fixture matches everything
178        if self.match_criteria.match_all {
179            return true;
180        }
181
182        // Check recipient pattern
183        if let Some(pattern) = &self.match_criteria.recipient_pattern {
184            if let Ok(re) = Regex::new(pattern) {
185                if !re.is_match(to) {
186                    return false;
187                }
188            }
189        }
190
191        // Check sender pattern
192        if let Some(pattern) = &self.match_criteria.sender_pattern {
193            if let Ok(re) = Regex::new(pattern) {
194                if !re.is_match(from) {
195                    return false;
196                }
197            }
198        }
199
200        // Check subject pattern
201        if let Some(pattern) = &self.match_criteria.subject_pattern {
202            if let Ok(re) = Regex::new(pattern) {
203                if !re.is_match(subject) {
204                    return false;
205                }
206            }
207        }
208
209        true
210    }
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216
217    #[test]
218    fn test_fixture_matching() {
219        let fixture = SmtpFixture {
220            identifier: "test".to_string(),
221            name: "Test Fixture".to_string(),
222            description: "".to_string(),
223            match_criteria: MatchCriteria {
224                recipient_pattern: Some(r"^user.*@example\.com$".to_string()),
225                sender_pattern: None,
226                subject_pattern: None,
227                match_all: false,
228            },
229            response: SmtpResponse {
230                status_code: 250,
231                message: "OK".to_string(),
232                delay_ms: 0,
233            },
234            auto_reply: None,
235            storage: StorageConfig::default(),
236            behavior: BehaviorConfig::default(),
237        };
238
239        assert!(fixture.matches("sender@test.com", "user123@example.com", "Test"));
240        assert!(!fixture.matches("sender@test.com", "admin@example.com", "Test"));
241    }
242
243    #[test]
244    fn test_match_all_fixture() {
245        let fixture = SmtpFixture {
246            identifier: "default".to_string(),
247            name: "Default Fixture".to_string(),
248            description: "".to_string(),
249            match_criteria: MatchCriteria {
250                recipient_pattern: None,
251                sender_pattern: None,
252                subject_pattern: None,
253                match_all: true,
254            },
255            response: SmtpResponse {
256                status_code: 250,
257                message: "OK".to_string(),
258                delay_ms: 0,
259            },
260            auto_reply: None,
261            storage: StorageConfig::default(),
262            behavior: BehaviorConfig::default(),
263        };
264
265        assert!(fixture.matches("any@sender.com", "any@recipient.com", "Any Subject"));
266    }
267
268    #[test]
269    fn test_fixture_sender_pattern() {
270        let fixture = SmtpFixture {
271            identifier: "test".to_string(),
272            name: "Test Fixture".to_string(),
273            description: "".to_string(),
274            match_criteria: MatchCriteria {
275                recipient_pattern: None,
276                sender_pattern: Some(r"^admin@.*$".to_string()),
277                subject_pattern: None,
278                match_all: false,
279            },
280            response: SmtpResponse {
281                status_code: 250,
282                message: "OK".to_string(),
283                delay_ms: 0,
284            },
285            auto_reply: None,
286            storage: StorageConfig::default(),
287            behavior: BehaviorConfig::default(),
288        };
289
290        assert!(fixture.matches("admin@example.com", "recipient@example.com", "Test"));
291        assert!(!fixture.matches("user@example.com", "recipient@example.com", "Test"));
292    }
293
294    #[test]
295    fn test_fixture_subject_pattern() {
296        let fixture = SmtpFixture {
297            identifier: "test".to_string(),
298            name: "Test Fixture".to_string(),
299            description: "".to_string(),
300            match_criteria: MatchCriteria {
301                recipient_pattern: None,
302                sender_pattern: None,
303                subject_pattern: Some(r"^Important:.*$".to_string()),
304                match_all: false,
305            },
306            response: SmtpResponse {
307                status_code: 250,
308                message: "OK".to_string(),
309                delay_ms: 0,
310            },
311            auto_reply: None,
312            storage: StorageConfig::default(),
313            behavior: BehaviorConfig::default(),
314        };
315
316        assert!(fixture.matches(
317            "sender@example.com",
318            "recipient@example.com",
319            "Important: Action required"
320        ));
321        assert!(!fixture.matches("sender@example.com", "recipient@example.com", "Regular subject"));
322    }
323
324    #[test]
325    fn test_match_criteria_default() {
326        let criteria = MatchCriteria::default();
327        assert!(criteria.recipient_pattern.is_none());
328        assert!(criteria.sender_pattern.is_none());
329        assert!(criteria.subject_pattern.is_none());
330        assert!(!criteria.match_all);
331    }
332
333    #[test]
334    fn test_storage_config_default() {
335        let config = StorageConfig::default();
336        assert!(!config.save_to_mailbox);
337        assert!(config.export_to_file.is_none());
338    }
339
340    #[test]
341    fn test_behavior_config_default() {
342        let config = BehaviorConfig::default();
343        assert_eq!(config.failure_rate, 0.0);
344        assert!(config.latency.is_none());
345    }
346
347    #[test]
348    fn test_stored_email_serialize() {
349        let email = StoredEmail {
350            id: "test-123".to_string(),
351            from: "sender@example.com".to_string(),
352            to: vec!["recipient@example.com".to_string()],
353            subject: "Test Subject".to_string(),
354            body: "Test body content".to_string(),
355            headers: HashMap::from([("Content-Type".to_string(), "text/plain".to_string())]),
356            received_at: chrono::Utc::now(),
357            raw: None,
358        };
359
360        let json = serde_json::to_string(&email).unwrap();
361        assert!(json.contains("test-123"));
362        assert!(json.contains("sender@example.com"));
363        assert!(json.contains("Test Subject"));
364    }
365
366    #[test]
367    fn test_stored_email_deserialize() {
368        let json = r#"{
369            "id": "email-456",
370            "from": "alice@example.com",
371            "to": ["bob@example.com", "carol@example.com"],
372            "subject": "Hello",
373            "body": "Hi there!",
374            "headers": {},
375            "received_at": "2024-01-15T12:00:00Z"
376        }"#;
377        let email: StoredEmail = serde_json::from_str(json).unwrap();
378        assert_eq!(email.id, "email-456");
379        assert_eq!(email.from, "alice@example.com");
380        assert_eq!(email.to.len(), 2);
381    }
382
383    #[test]
384    fn test_smtp_fixture_serialize() {
385        let fixture = SmtpFixture {
386            identifier: "test".to_string(),
387            name: "Test Fixture".to_string(),
388            description: "A test fixture".to_string(),
389            match_criteria: MatchCriteria::default(),
390            response: SmtpResponse {
391                status_code: 250,
392                message: "OK".to_string(),
393                delay_ms: 100,
394            },
395            auto_reply: None,
396            storage: StorageConfig::default(),
397            behavior: BehaviorConfig::default(),
398        };
399
400        let json = serde_json::to_string(&fixture).unwrap();
401        assert!(json.contains("Test Fixture"));
402        assert!(json.contains("250"));
403    }
404
405    #[test]
406    fn test_smtp_response_with_delay() {
407        let response = SmtpResponse {
408            status_code: 550,
409            message: "Mailbox unavailable".to_string(),
410            delay_ms: 500,
411        };
412        assert_eq!(response.status_code, 550);
413        assert_eq!(response.delay_ms, 500);
414    }
415
416    #[test]
417    fn test_auto_reply_config() {
418        let auto_reply = AutoReply {
419            enabled: true,
420            from: "noreply@example.com".to_string(),
421            to: "{{metadata.from}}".to_string(),
422            subject: "Auto Reply".to_string(),
423            body: "Thank you for your email.".to_string(),
424            html_body: Some("<p>Thank you for your email.</p>".to_string()),
425            headers: HashMap::from([("X-Auto-Reply".to_string(), "true".to_string())]),
426        };
427
428        assert!(auto_reply.enabled);
429        assert!(auto_reply.html_body.is_some());
430    }
431
432    #[test]
433    fn test_latency_config() {
434        let latency = LatencyConfig {
435            min_ms: 100,
436            max_ms: 500,
437        };
438        assert_eq!(latency.min_ms, 100);
439        assert_eq!(latency.max_ms, 500);
440    }
441
442    #[test]
443    fn test_export_config() {
444        let export = ExportConfig {
445            enabled: true,
446            path: "/tmp/emails/{{metadata.from}}/{{timestamp}}.eml".to_string(),
447        };
448        assert!(export.enabled);
449        assert!(export.path.contains("{{metadata.from}}"));
450    }
451
452    #[test]
453    fn test_fixture_combined_matching() {
454        let fixture = SmtpFixture {
455            identifier: "combined".to_string(),
456            name: "Combined Match".to_string(),
457            description: "".to_string(),
458            match_criteria: MatchCriteria {
459                recipient_pattern: Some(r".*@example\.com$".to_string()),
460                sender_pattern: Some(r"^admin@.*$".to_string()),
461                subject_pattern: Some(r"^Urgent:.*$".to_string()),
462                match_all: false,
463            },
464            response: SmtpResponse {
465                status_code: 250,
466                message: "OK".to_string(),
467                delay_ms: 0,
468            },
469            auto_reply: None,
470            storage: StorageConfig::default(),
471            behavior: BehaviorConfig::default(),
472        };
473
474        // All patterns match
475        assert!(fixture.matches("admin@test.com", "user@example.com", "Urgent: Review needed"));
476
477        // Recipient doesn't match
478        assert!(!fixture.matches("admin@test.com", "user@other.com", "Urgent: Review needed"));
479
480        // Sender doesn't match
481        assert!(!fixture.matches("user@test.com", "user@example.com", "Urgent: Review needed"));
482
483        // Subject doesn't match
484        assert!(!fixture.matches("admin@test.com", "user@example.com", "Regular subject"));
485    }
486
487    #[test]
488    fn test_stored_email_clone() {
489        let email = StoredEmail {
490            id: "test-clone".to_string(),
491            from: "sender@example.com".to_string(),
492            to: vec!["recipient@example.com".to_string()],
493            subject: "Clone Test".to_string(),
494            body: "Test body".to_string(),
495            headers: HashMap::new(),
496            received_at: chrono::Utc::now(),
497            raw: Some(vec![1, 2, 3]),
498        };
499
500        let cloned = email.clone();
501        assert_eq!(email.id, cloned.id);
502        assert_eq!(email.from, cloned.from);
503        assert_eq!(email.raw, cloned.raw);
504    }
505
506    #[test]
507    fn test_fixture_debug() {
508        let fixture = SmtpFixture {
509            identifier: "debug-test".to_string(),
510            name: "Debug Test".to_string(),
511            description: "".to_string(),
512            match_criteria: MatchCriteria::default(),
513            response: SmtpResponse {
514                status_code: 250,
515                message: "OK".to_string(),
516                delay_ms: 0,
517            },
518            auto_reply: None,
519            storage: StorageConfig::default(),
520            behavior: BehaviorConfig::default(),
521        };
522
523        let debug = format!("{:?}", fixture);
524        assert!(debug.contains("SmtpFixture"));
525        assert!(debug.contains("debug-test"));
526    }
527}