Skip to main content

mockforge_smtp/
spec_registry.rs

1//! SMTP SpecRegistry implementation
2
3use crate::fixtures::{SmtpFixture, StoredEmail};
4use mockforge_core::fixture_store::{
5    load_fixtures_from_dir, FixtureFileFormat, FixtureFileGranularity, FixtureLoadErrorMode,
6    FixtureLoadOptions,
7};
8use mockforge_core::protocol_abstraction::{
9    Protocol, ProtocolRequest, ProtocolResponse, ResponseStatus, SpecOperation, SpecRegistry,
10    ValidationError, ValidationResult,
11};
12use mockforge_core::Result;
13use regex::Regex;
14use std::collections::HashMap;
15use std::path::Path;
16use std::sync::RwLock;
17use tracing::{error, info, warn};
18
19/// Email search filters
20#[derive(Debug, Clone, Default)]
21pub struct EmailSearchFilters {
22    pub sender: Option<String>,
23    pub recipient: Option<String>,
24    pub subject: Option<String>,
25    pub body: Option<String>,
26    pub since: Option<chrono::DateTime<chrono::Utc>>,
27    pub until: Option<chrono::DateTime<chrono::Utc>>,
28    pub use_regex: bool,
29    pub case_sensitive: bool,
30}
31
32/// SMTP protocol registry implementing SpecRegistry trait
33pub struct SmtpSpecRegistry {
34    /// Loaded fixtures
35    fixtures: Vec<SmtpFixture>,
36    /// In-memory mailbox
37    mailbox: RwLock<Vec<StoredEmail>>,
38    /// Maximum mailbox size
39    max_mailbox_size: usize,
40}
41
42impl SmtpSpecRegistry {
43    /// Create a new SMTP registry
44    pub fn new() -> Self {
45        Self {
46            fixtures: Vec::new(),
47            mailbox: RwLock::new(Vec::new()),
48            max_mailbox_size: 1000,
49        }
50    }
51
52    /// Create a new registry with custom mailbox size
53    pub fn with_mailbox_size(max_size: usize) -> Self {
54        Self {
55            fixtures: Vec::new(),
56            mailbox: RwLock::new(Vec::new()),
57            max_mailbox_size: max_size,
58        }
59    }
60
61    /// Load fixtures from a directory
62    pub fn load_fixtures<P: AsRef<Path>>(&mut self, path: P) -> Result<()> {
63        let path = path.as_ref();
64
65        if !path.exists() {
66            warn!("Fixtures directory does not exist: {:?}", path);
67            return Ok(());
68        }
69
70        let options = FixtureLoadOptions {
71            formats: vec![FixtureFileFormat::Yaml, FixtureFileFormat::Json],
72            error_mode: FixtureLoadErrorMode::FailFast,
73            granularity: FixtureFileGranularity::Single,
74        };
75
76        let loaded: Vec<SmtpFixture> = load_fixtures_from_dir(path, &options)?;
77
78        for fixture in loaded {
79            self.fixtures.push(fixture);
80        }
81
82        info!("Loaded {} SMTP fixtures", self.fixtures.len());
83        Ok(())
84    }
85
86    /// Find a matching fixture for the given email
87    pub fn find_matching_fixture(
88        &self,
89        from: &str,
90        to: &str,
91        subject: &str,
92    ) -> Option<&SmtpFixture> {
93        // First, try to find a specific match
94        for fixture in &self.fixtures {
95            if !fixture.match_criteria.match_all && fixture.matches(from, to, subject) {
96                return Some(fixture);
97            }
98        }
99
100        // If no specific match, find a default (match_all) fixture
101        self.fixtures.iter().find(|f| f.match_criteria.match_all)
102    }
103
104    /// Store an email in the mailbox
105    pub fn store_email(&self, email: StoredEmail) -> Result<()> {
106        let mut mailbox = self.mailbox.write().map_err(|e| {
107            mockforge_core::Error::internal(format!("Failed to acquire mailbox lock: {}", e))
108        })?;
109
110        // Check mailbox size limit
111        if mailbox.len() >= self.max_mailbox_size {
112            warn!("Mailbox is full, removing oldest email");
113            mailbox.remove(0);
114        }
115
116        mailbox.push(email);
117        Ok(())
118    }
119
120    /// Get all emails from the mailbox
121    pub fn get_emails(&self) -> Result<Vec<StoredEmail>> {
122        let mailbox = self.mailbox.read().map_err(|e| {
123            mockforge_core::Error::internal(format!("Failed to acquire mailbox lock: {}", e))
124        })?;
125
126        Ok(mailbox.clone())
127    }
128
129    /// Get a specific email by ID
130    pub fn get_email_by_id(&self, id: &str) -> Result<Option<StoredEmail>> {
131        let mailbox = self.mailbox.read().map_err(|e| {
132            mockforge_core::Error::internal(format!("Failed to acquire mailbox lock: {}", e))
133        })?;
134
135        Ok(mailbox.iter().find(|e| e.id == id).cloned())
136    }
137
138    /// Clear all emails from the mailbox
139    pub fn clear_mailbox(&self) -> Result<()> {
140        let mut mailbox = self.mailbox.write().map_err(|e| {
141            mockforge_core::Error::internal(format!("Failed to acquire mailbox lock: {}", e))
142        })?;
143
144        mailbox.clear();
145        info!("Mailbox cleared");
146        Ok(())
147    }
148
149    /// Get mailbox statistics
150    pub fn get_mailbox_stats(&self) -> Result<MailboxStats> {
151        let mailbox = self.mailbox.read().map_err(|e| {
152            mockforge_core::Error::internal(format!("Failed to acquire mailbox lock: {}", e))
153        })?;
154
155        Ok(MailboxStats {
156            total_emails: mailbox.len(),
157            max_capacity: self.max_mailbox_size,
158        })
159    }
160
161    /// Search emails with filters
162    pub fn search_emails(&self, filters: EmailSearchFilters) -> Result<Vec<StoredEmail>> {
163        let mailbox = self.mailbox.read().map_err(|e| {
164            mockforge_core::Error::internal(format!("Failed to acquire mailbox lock: {}", e))
165        })?;
166
167        let mut results: Vec<StoredEmail> = mailbox
168            .iter()
169            .filter(|email| {
170                // Helper function to check if field matches filter
171                let matches_filter = |field: &str, filter: &Option<String>| -> bool {
172                    if let Some(ref f) = filter {
173                        let field_cmp = if filters.case_sensitive {
174                            field.to_string()
175                        } else {
176                            field.to_lowercase()
177                        };
178                        let filter_cmp = if filters.case_sensitive {
179                            f.clone()
180                        } else {
181                            f.to_lowercase()
182                        };
183
184                        if filters.use_regex {
185                            if let Ok(re) = Regex::new(&filter_cmp) {
186                                re.is_match(&field_cmp)
187                            } else {
188                                false // invalid regex, no match
189                            }
190                        } else {
191                            field_cmp.contains(&filter_cmp)
192                        }
193                    } else {
194                        true
195                    }
196                };
197
198                // Filter by sender
199                if !matches_filter(&email.from, &filters.sender) {
200                    return false;
201                }
202
203                // Filter by recipient
204                if let Some(ref recipient_filter) = filters.recipient {
205                    let has_recipient = email
206                        .to
207                        .iter()
208                        .any(|to| matches_filter(to, &Some(recipient_filter.clone())));
209                    if !has_recipient {
210                        return false;
211                    }
212                }
213
214                // Filter by subject
215                if !matches_filter(&email.subject, &filters.subject) {
216                    return false;
217                }
218
219                // Filter by body
220                if !matches_filter(&email.body, &filters.body) {
221                    return false;
222                }
223
224                // Filter by date range
225                if let Some(since) = filters.since {
226                    if email.received_at < since {
227                        return false;
228                    }
229                }
230
231                if let Some(until) = filters.until {
232                    if email.received_at > until {
233                        return false;
234                    }
235                }
236
237                true
238            })
239            .cloned()
240            .collect();
241
242        // Sort by received_at descending (newest first)
243        results.sort_by(|a, b| b.received_at.cmp(&a.received_at));
244
245        Ok(results)
246    }
247}
248
249/// Mailbox statistics
250#[derive(Debug, Clone)]
251pub struct MailboxStats {
252    pub total_emails: usize,
253    pub max_capacity: usize,
254}
255
256impl Default for SmtpSpecRegistry {
257    fn default() -> Self {
258        Self::new()
259    }
260}
261
262impl SpecRegistry for SmtpSpecRegistry {
263    fn protocol(&self) -> Protocol {
264        Protocol::Smtp
265    }
266
267    fn operations(&self) -> Vec<SpecOperation> {
268        self.fixtures
269            .iter()
270            .map(|fixture| SpecOperation {
271                name: fixture.name.clone(),
272                path: fixture.identifier.clone(),
273                operation_type: "SEND".to_string(),
274                input_schema: None,
275                output_schema: None,
276                metadata: HashMap::from([
277                    ("description".to_string(), fixture.description.clone()),
278                    ("status_code".to_string(), fixture.response.status_code.to_string()),
279                ]),
280            })
281            .collect()
282    }
283
284    fn find_operation(&self, operation: &str, path: &str) -> Option<SpecOperation> {
285        self.fixtures
286            .iter()
287            .find(|f| f.identifier == path)
288            .map(|fixture| SpecOperation {
289                name: fixture.name.clone(),
290                path: fixture.identifier.clone(),
291                operation_type: operation.to_string(),
292                input_schema: None,
293                output_schema: None,
294                metadata: HashMap::from([
295                    ("description".to_string(), fixture.description.clone()),
296                    ("status_code".to_string(), fixture.response.status_code.to_string()),
297                ]),
298            })
299    }
300
301    fn validate_request(&self, request: &ProtocolRequest) -> Result<ValidationResult> {
302        // Validate protocol
303        if request.protocol != Protocol::Smtp {
304            return Ok(ValidationResult::failure(vec![ValidationError {
305                message: "Invalid protocol for SMTP registry".to_string(),
306                path: None,
307                code: Some("INVALID_PROTOCOL".to_string()),
308            }]));
309        }
310
311        // Basic SMTP validation
312        let from = request.metadata.get("from");
313        let to = request.metadata.get("to");
314
315        if from.is_none() {
316            return Ok(ValidationResult::failure(vec![ValidationError {
317                message: "Missing 'from' address".to_string(),
318                path: Some("metadata.from".to_string()),
319                code: Some("MISSING_FROM".to_string()),
320            }]));
321        }
322
323        if to.is_none() {
324            return Ok(ValidationResult::failure(vec![ValidationError {
325                message: "Missing 'to' address".to_string(),
326                path: Some("metadata.to".to_string()),
327                code: Some("MISSING_TO".to_string()),
328            }]));
329        }
330
331        Ok(ValidationResult::success())
332    }
333
334    fn generate_mock_response(&self, request: &ProtocolRequest) -> Result<ProtocolResponse> {
335        let from = request.metadata.get("from").unwrap_or(&String::new()).clone();
336        let to = request.metadata.get("to").unwrap_or(&String::new()).clone();
337        let subject = request.metadata.get("subject").unwrap_or(&String::new()).clone();
338
339        // Find matching fixture
340        let fixture = self.find_matching_fixture(&from, &to, &subject).ok_or_else(|| {
341            mockforge_core::Error::internal("No matching fixture found for email")
342        })?;
343
344        // Store email if configured
345        if fixture.storage.save_to_mailbox {
346            let email = StoredEmail {
347                id: uuid::Uuid::new_v4().to_string(),
348                from: from.clone(),
349                to: to.split(',').map(|s| s.trim().to_string()).collect(),
350                subject: subject.clone(),
351                body: String::from_utf8_lossy(request.body.as_ref().unwrap_or(&Vec::new()))
352                    .to_string(),
353                headers: request.metadata.clone(),
354                received_at: chrono::Utc::now(),
355                raw: request.body.clone(),
356            };
357
358            if let Err(e) = self.store_email(email) {
359                error!("Failed to store email: {}", e);
360            }
361        }
362
363        // Generate response
364        let response_message =
365            format!("{} {}\r\n", fixture.response.status_code, fixture.response.message);
366
367        Ok(ProtocolResponse {
368            status: ResponseStatus::SmtpStatus(fixture.response.status_code),
369            metadata: HashMap::new(),
370            body: response_message.into_bytes(),
371            content_type: "text/plain".to_string(),
372        })
373    }
374}
375
376#[cfg(test)]
377mod tests {
378    use super::*;
379
380    #[test]
381    fn test_registry_creation() {
382        let registry = SmtpSpecRegistry::new();
383        assert_eq!(registry.protocol(), Protocol::Smtp);
384        assert_eq!(registry.fixtures.len(), 0);
385    }
386
387    #[test]
388    fn test_registry_default() {
389        let registry = SmtpSpecRegistry::default();
390        assert_eq!(registry.protocol(), Protocol::Smtp);
391        assert_eq!(registry.max_mailbox_size, 1000);
392    }
393
394    #[test]
395    fn test_mailbox_operations() {
396        let registry = SmtpSpecRegistry::new();
397
398        let email = StoredEmail {
399            id: "test-123".to_string(),
400            from: "sender@example.com".to_string(),
401            to: vec!["recipient@example.com".to_string()],
402            subject: "Test".to_string(),
403            body: "Test body".to_string(),
404            headers: HashMap::new(),
405            received_at: chrono::Utc::now(),
406            raw: None,
407        };
408
409        registry.store_email(email.clone()).unwrap();
410
411        let emails = registry.get_emails().unwrap();
412        assert_eq!(emails.len(), 1);
413        assert_eq!(emails[0].from, "sender@example.com");
414
415        registry.clear_mailbox().unwrap();
416        let emails = registry.get_emails().unwrap();
417        assert_eq!(emails.len(), 0);
418    }
419
420    #[test]
421    fn test_mailbox_size_limit() {
422        let registry = SmtpSpecRegistry::with_mailbox_size(2);
423
424        for i in 0..5 {
425            let email = StoredEmail {
426                id: format!("test-{}", i),
427                from: "sender@example.com".to_string(),
428                to: vec!["recipient@example.com".to_string()],
429                subject: format!("Test {}", i),
430                body: "Test body".to_string(),
431                headers: HashMap::new(),
432                received_at: chrono::Utc::now(),
433                raw: None,
434            };
435
436            registry.store_email(email).unwrap();
437        }
438
439        let emails = registry.get_emails().unwrap();
440        assert_eq!(emails.len(), 2); // Should only keep the last 2
441    }
442
443    #[test]
444    fn test_get_email_by_id() {
445        let registry = SmtpSpecRegistry::new();
446
447        let email = StoredEmail {
448            id: "unique-id-123".to_string(),
449            from: "sender@example.com".to_string(),
450            to: vec!["recipient@example.com".to_string()],
451            subject: "Test".to_string(),
452            body: "Test body".to_string(),
453            headers: HashMap::new(),
454            received_at: chrono::Utc::now(),
455            raw: None,
456        };
457
458        registry.store_email(email).unwrap();
459
460        let found = registry.get_email_by_id("unique-id-123").unwrap();
461        assert!(found.is_some());
462        assert_eq!(found.unwrap().id, "unique-id-123");
463
464        let not_found = registry.get_email_by_id("nonexistent").unwrap();
465        assert!(not_found.is_none());
466    }
467
468    #[test]
469    fn test_mailbox_stats() {
470        let registry = SmtpSpecRegistry::with_mailbox_size(100);
471
472        let stats = registry.get_mailbox_stats().unwrap();
473        assert_eq!(stats.total_emails, 0);
474        assert_eq!(stats.max_capacity, 100);
475
476        for i in 0..5 {
477            let email = StoredEmail {
478                id: format!("test-{}", i),
479                from: "sender@example.com".to_string(),
480                to: vec!["recipient@example.com".to_string()],
481                subject: format!("Test {}", i),
482                body: "Test body".to_string(),
483                headers: HashMap::new(),
484                received_at: chrono::Utc::now(),
485                raw: None,
486            };
487            registry.store_email(email).unwrap();
488        }
489
490        let stats = registry.get_mailbox_stats().unwrap();
491        assert_eq!(stats.total_emails, 5);
492    }
493
494    #[test]
495    fn test_email_search_filters_default() {
496        let filters = EmailSearchFilters::default();
497        assert!(filters.sender.is_none());
498        assert!(filters.recipient.is_none());
499        assert!(filters.subject.is_none());
500        assert!(filters.body.is_none());
501        assert!(!filters.use_regex);
502        assert!(!filters.case_sensitive);
503    }
504
505    #[test]
506    fn test_search_emails_by_sender() {
507        let registry = SmtpSpecRegistry::new();
508
509        // Add test emails
510        for i in 0..3 {
511            let email = StoredEmail {
512                id: format!("test-{}", i),
513                from: format!("sender{}@example.com", i),
514                to: vec!["recipient@example.com".to_string()],
515                subject: "Test".to_string(),
516                body: "Test body".to_string(),
517                headers: HashMap::new(),
518                received_at: chrono::Utc::now(),
519                raw: None,
520            };
521            registry.store_email(email).unwrap();
522        }
523
524        let filters = EmailSearchFilters {
525            sender: Some("sender1".to_string()),
526            ..Default::default()
527        };
528        let results = registry.search_emails(filters).unwrap();
529        assert_eq!(results.len(), 1);
530        assert!(results[0].from.contains("sender1"));
531    }
532
533    #[test]
534    fn test_search_emails_by_subject() {
535        let registry = SmtpSpecRegistry::new();
536
537        let email1 = StoredEmail {
538            id: "test-1".to_string(),
539            from: "sender@example.com".to_string(),
540            to: vec!["recipient@example.com".to_string()],
541            subject: "Important update".to_string(),
542            body: "Test body".to_string(),
543            headers: HashMap::new(),
544            received_at: chrono::Utc::now(),
545            raw: None,
546        };
547        let email2 = StoredEmail {
548            id: "test-2".to_string(),
549            from: "sender@example.com".to_string(),
550            to: vec!["recipient@example.com".to_string()],
551            subject: "Newsletter".to_string(),
552            body: "Test body".to_string(),
553            headers: HashMap::new(),
554            received_at: chrono::Utc::now(),
555            raw: None,
556        };
557
558        registry.store_email(email1).unwrap();
559        registry.store_email(email2).unwrap();
560
561        let filters = EmailSearchFilters {
562            subject: Some("Important".to_string()),
563            ..Default::default()
564        };
565        let results = registry.search_emails(filters).unwrap();
566        assert_eq!(results.len(), 1);
567        assert!(results[0].subject.contains("Important"));
568    }
569
570    #[test]
571    fn test_search_emails_with_regex() {
572        let registry = SmtpSpecRegistry::new();
573
574        let email = StoredEmail {
575            id: "test-1".to_string(),
576            from: "admin@example.com".to_string(),
577            to: vec!["recipient@example.com".to_string()],
578            subject: "Test".to_string(),
579            body: "Test body".to_string(),
580            headers: HashMap::new(),
581            received_at: chrono::Utc::now(),
582            raw: None,
583        };
584        registry.store_email(email).unwrap();
585
586        let filters = EmailSearchFilters {
587            sender: Some(r"^admin@.*\.com$".to_string()),
588            use_regex: true,
589            ..Default::default()
590        };
591        let results = registry.search_emails(filters).unwrap();
592        assert_eq!(results.len(), 1);
593    }
594
595    #[test]
596    fn test_operations_empty() {
597        let registry = SmtpSpecRegistry::new();
598        let ops = registry.operations();
599        assert!(ops.is_empty());
600    }
601
602    #[test]
603    fn test_find_operation_not_found() {
604        let registry = SmtpSpecRegistry::new();
605        let op = registry.find_operation("SEND", "/nonexistent");
606        assert!(op.is_none());
607    }
608
609    #[test]
610    fn test_validate_request_missing_from() {
611        let registry = SmtpSpecRegistry::new();
612        let request = ProtocolRequest {
613            protocol: Protocol::Smtp,
614            pattern: mockforge_core::protocol_abstraction::MessagePattern::OneWay,
615            operation: "SEND".to_string(),
616            path: "/".to_string(),
617            topic: None,
618            routing_key: None,
619            partition: None,
620            qos: None,
621            metadata: HashMap::from([("to".to_string(), "recipient@example.com".to_string())]),
622            body: None,
623            client_ip: None,
624        };
625
626        let result = registry.validate_request(&request).unwrap();
627        assert!(!result.valid);
628    }
629
630    #[test]
631    fn test_validate_request_missing_to() {
632        let registry = SmtpSpecRegistry::new();
633        let request = ProtocolRequest {
634            protocol: Protocol::Smtp,
635            pattern: mockforge_core::protocol_abstraction::MessagePattern::OneWay,
636            operation: "SEND".to_string(),
637            path: "/".to_string(),
638            topic: None,
639            routing_key: None,
640            partition: None,
641            qos: None,
642            metadata: HashMap::from([("from".to_string(), "sender@example.com".to_string())]),
643            body: None,
644            client_ip: None,
645        };
646
647        let result = registry.validate_request(&request).unwrap();
648        assert!(!result.valid);
649    }
650
651    #[test]
652    fn test_validate_request_valid() {
653        let registry = SmtpSpecRegistry::new();
654        let request = ProtocolRequest {
655            protocol: Protocol::Smtp,
656            pattern: mockforge_core::protocol_abstraction::MessagePattern::OneWay,
657            operation: "SEND".to_string(),
658            path: "/".to_string(),
659            topic: None,
660            routing_key: None,
661            partition: None,
662            qos: None,
663            metadata: HashMap::from([
664                ("from".to_string(), "sender@example.com".to_string()),
665                ("to".to_string(), "recipient@example.com".to_string()),
666            ]),
667            body: None,
668            client_ip: None,
669        };
670
671        let result = registry.validate_request(&request).unwrap();
672        assert!(result.valid);
673    }
674
675    #[test]
676    fn test_validate_request_wrong_protocol() {
677        let registry = SmtpSpecRegistry::new();
678        let request = ProtocolRequest {
679            protocol: Protocol::Http,
680            pattern: mockforge_core::protocol_abstraction::MessagePattern::OneWay,
681            operation: "SEND".to_string(),
682            path: "/".to_string(),
683            topic: None,
684            routing_key: None,
685            partition: None,
686            qos: None,
687            metadata: HashMap::new(),
688            body: None,
689            client_ip: None,
690        };
691
692        let result = registry.validate_request(&request).unwrap();
693        assert!(!result.valid);
694    }
695
696    #[test]
697    fn test_load_fixtures_nonexistent_dir() {
698        let mut registry = SmtpSpecRegistry::new();
699        let result = registry.load_fixtures("/nonexistent/path");
700        // Should succeed but not load any fixtures
701        assert!(result.is_ok());
702        assert_eq!(registry.fixtures.len(), 0);
703    }
704}