mockforge_smtp/
spec_registry.rs

1//! SMTP SpecRegistry implementation
2
3use crate::fixtures::{SmtpFixture, StoredEmail};
4use mockforge_core::protocol_abstraction::{
5    Protocol, ProtocolRequest, ProtocolResponse, ResponseStatus, SpecOperation, SpecRegistry,
6    ValidationError, ValidationResult,
7};
8use mockforge_core::Result;
9use regex::Regex;
10use std::collections::HashMap;
11use std::path::Path;
12use std::sync::RwLock;
13use tracing::{debug, error, info, warn};
14
15/// Email search filters
16#[derive(Debug, Clone, Default)]
17pub struct EmailSearchFilters {
18    pub sender: Option<String>,
19    pub recipient: Option<String>,
20    pub subject: Option<String>,
21    pub body: Option<String>,
22    pub since: Option<chrono::DateTime<chrono::Utc>>,
23    pub until: Option<chrono::DateTime<chrono::Utc>>,
24    pub use_regex: bool,
25    pub case_sensitive: bool,
26}
27
28/// SMTP protocol registry implementing SpecRegistry trait
29pub struct SmtpSpecRegistry {
30    /// Loaded fixtures
31    fixtures: Vec<SmtpFixture>,
32    /// In-memory mailbox
33    mailbox: RwLock<Vec<StoredEmail>>,
34    /// Maximum mailbox size
35    max_mailbox_size: usize,
36}
37
38impl SmtpSpecRegistry {
39    /// Create a new SMTP registry
40    pub fn new() -> Self {
41        Self {
42            fixtures: Vec::new(),
43            mailbox: RwLock::new(Vec::new()),
44            max_mailbox_size: 1000,
45        }
46    }
47
48    /// Create a new registry with custom mailbox size
49    pub fn with_mailbox_size(max_size: usize) -> Self {
50        Self {
51            fixtures: Vec::new(),
52            mailbox: RwLock::new(Vec::new()),
53            max_mailbox_size: max_size,
54        }
55    }
56
57    /// Load fixtures from a directory
58    pub fn load_fixtures<P: AsRef<Path>>(&mut self, path: P) -> Result<()> {
59        let path = path.as_ref();
60
61        if !path.exists() {
62            warn!("Fixtures directory does not exist: {:?}", path);
63            return Ok(());
64        }
65
66        let entries = std::fs::read_dir(path)?;
67
68        for entry in entries {
69            let entry = entry?;
70            let path = entry.path();
71
72            if path.is_file() {
73                let extension = path.extension().and_then(|s| s.to_str());
74
75                match extension {
76                    Some("yaml") | Some("yml") => {
77                        self.load_fixture_file(&path)?;
78                    }
79                    Some("json") => {
80                        self.load_fixture_file_json(&path)?;
81                    }
82                    _ => {
83                        debug!("Skipping non-fixture file: {:?}", path);
84                    }
85                }
86            }
87        }
88
89        info!("Loaded {} SMTP fixtures", self.fixtures.len());
90        Ok(())
91    }
92
93    /// Load a single YAML fixture file
94    fn load_fixture_file(&mut self, path: &Path) -> Result<()> {
95        let content = std::fs::read_to_string(path)?;
96        let fixture: SmtpFixture = serde_yaml::from_str(&content).map_err(|e| {
97            mockforge_core::Error::generic(format!(
98                "Failed to parse fixture file {:?}: {}",
99                path, e
100            ))
101        })?;
102
103        debug!("Loaded fixture: {} from {:?}", fixture.name, path);
104        self.fixtures.push(fixture);
105
106        Ok(())
107    }
108
109    /// Load a single JSON fixture file
110    fn load_fixture_file_json(&mut self, path: &Path) -> Result<()> {
111        let content = std::fs::read_to_string(path)?;
112        let fixture: SmtpFixture = serde_json::from_str(&content).map_err(|e| {
113            mockforge_core::Error::generic(format!(
114                "Failed to parse JSON fixture file {:?}: {}",
115                path, e
116            ))
117        })?;
118
119        debug!("Loaded fixture: {} from {:?}", fixture.name, path);
120        self.fixtures.push(fixture);
121
122        Ok(())
123    }
124
125    /// Find a matching fixture for the given email
126    pub fn find_matching_fixture(
127        &self,
128        from: &str,
129        to: &str,
130        subject: &str,
131    ) -> Option<&SmtpFixture> {
132        // First, try to find a specific match
133        for fixture in &self.fixtures {
134            if !fixture.match_criteria.match_all && fixture.matches(from, to, subject) {
135                return Some(fixture);
136            }
137        }
138
139        // If no specific match, find a default (match_all) fixture
140        self.fixtures.iter().find(|f| f.match_criteria.match_all)
141    }
142
143    /// Store an email in the mailbox
144    pub fn store_email(&self, email: StoredEmail) -> Result<()> {
145        let mut mailbox = self.mailbox.write().map_err(|e| {
146            mockforge_core::Error::generic(format!("Failed to acquire mailbox lock: {}", e))
147        })?;
148
149        // Check mailbox size limit
150        if mailbox.len() >= self.max_mailbox_size {
151            warn!("Mailbox is full, removing oldest email");
152            mailbox.remove(0);
153        }
154
155        mailbox.push(email);
156        Ok(())
157    }
158
159    /// Get all emails from the mailbox
160    pub fn get_emails(&self) -> Result<Vec<StoredEmail>> {
161        let mailbox = self.mailbox.read().map_err(|e| {
162            mockforge_core::Error::generic(format!("Failed to acquire mailbox lock: {}", e))
163        })?;
164
165        Ok(mailbox.clone())
166    }
167
168    /// Get a specific email by ID
169    pub fn get_email_by_id(&self, id: &str) -> Result<Option<StoredEmail>> {
170        let mailbox = self.mailbox.read().map_err(|e| {
171            mockforge_core::Error::generic(format!("Failed to acquire mailbox lock: {}", e))
172        })?;
173
174        Ok(mailbox.iter().find(|e| e.id == id).cloned())
175    }
176
177    /// Clear all emails from the mailbox
178    pub fn clear_mailbox(&self) -> Result<()> {
179        let mut mailbox = self.mailbox.write().map_err(|e| {
180            mockforge_core::Error::generic(format!("Failed to acquire mailbox lock: {}", e))
181        })?;
182
183        mailbox.clear();
184        info!("Mailbox cleared");
185        Ok(())
186    }
187
188    /// Get mailbox statistics
189    pub fn get_mailbox_stats(&self) -> Result<MailboxStats> {
190        let mailbox = self.mailbox.read().map_err(|e| {
191            mockforge_core::Error::generic(format!("Failed to acquire mailbox lock: {}", e))
192        })?;
193
194        Ok(MailboxStats {
195            total_emails: mailbox.len(),
196            max_capacity: self.max_mailbox_size,
197        })
198    }
199
200    /// Search emails with filters
201    pub fn search_emails(&self, filters: EmailSearchFilters) -> Result<Vec<StoredEmail>> {
202        let mailbox = self.mailbox.read().map_err(|e| {
203            mockforge_core::Error::generic(format!("Failed to acquire mailbox lock: {}", e))
204        })?;
205
206        let mut results: Vec<StoredEmail> = mailbox
207            .iter()
208            .filter(|email| {
209                // Helper function to check if field matches filter
210                let matches_filter = |field: &str, filter: &Option<String>| -> bool {
211                    if let Some(ref f) = filter {
212                        let field_cmp = if filters.case_sensitive {
213                            field.to_string()
214                        } else {
215                            field.to_lowercase()
216                        };
217                        let filter_cmp = if filters.case_sensitive {
218                            f.clone()
219                        } else {
220                            f.to_lowercase()
221                        };
222
223                        if filters.use_regex {
224                            if let Ok(re) = Regex::new(&filter_cmp) {
225                                re.is_match(&field_cmp)
226                            } else {
227                                false // invalid regex, no match
228                            }
229                        } else {
230                            field_cmp.contains(&filter_cmp)
231                        }
232                    } else {
233                        true
234                    }
235                };
236
237                // Filter by sender
238                if !matches_filter(&email.from, &filters.sender) {
239                    return false;
240                }
241
242                // Filter by recipient
243                if let Some(ref recipient_filter) = filters.recipient {
244                    let has_recipient = email
245                        .to
246                        .iter()
247                        .any(|to| matches_filter(to, &Some(recipient_filter.clone())));
248                    if !has_recipient {
249                        return false;
250                    }
251                }
252
253                // Filter by subject
254                if !matches_filter(&email.subject, &filters.subject) {
255                    return false;
256                }
257
258                // Filter by body
259                if !matches_filter(&email.body, &filters.body) {
260                    return false;
261                }
262
263                // Filter by date range
264                if let Some(since) = filters.since {
265                    if email.received_at < since {
266                        return false;
267                    }
268                }
269
270                if let Some(until) = filters.until {
271                    if email.received_at > until {
272                        return false;
273                    }
274                }
275
276                true
277            })
278            .cloned()
279            .collect();
280
281        // Sort by received_at descending (newest first)
282        results.sort_by(|a, b| b.received_at.cmp(&a.received_at));
283
284        Ok(results)
285    }
286}
287
288/// Mailbox statistics
289#[derive(Debug, Clone)]
290pub struct MailboxStats {
291    pub total_emails: usize,
292    pub max_capacity: usize,
293}
294
295impl Default for SmtpSpecRegistry {
296    fn default() -> Self {
297        Self::new()
298    }
299}
300
301impl SpecRegistry for SmtpSpecRegistry {
302    fn protocol(&self) -> Protocol {
303        Protocol::Smtp
304    }
305
306    fn operations(&self) -> Vec<SpecOperation> {
307        self.fixtures
308            .iter()
309            .map(|fixture| SpecOperation {
310                name: fixture.name.clone(),
311                path: fixture.identifier.clone(),
312                operation_type: "SEND".to_string(),
313                input_schema: None,
314                output_schema: None,
315                metadata: HashMap::from([
316                    ("description".to_string(), fixture.description.clone()),
317                    ("status_code".to_string(), fixture.response.status_code.to_string()),
318                ]),
319            })
320            .collect()
321    }
322
323    fn find_operation(&self, operation: &str, path: &str) -> Option<SpecOperation> {
324        self.fixtures
325            .iter()
326            .find(|f| f.identifier == path)
327            .map(|fixture| SpecOperation {
328                name: fixture.name.clone(),
329                path: fixture.identifier.clone(),
330                operation_type: operation.to_string(),
331                input_schema: None,
332                output_schema: None,
333                metadata: HashMap::from([
334                    ("description".to_string(), fixture.description.clone()),
335                    ("status_code".to_string(), fixture.response.status_code.to_string()),
336                ]),
337            })
338    }
339
340    fn validate_request(&self, request: &ProtocolRequest) -> Result<ValidationResult> {
341        // Validate protocol
342        if request.protocol != Protocol::Smtp {
343            return Ok(ValidationResult::failure(vec![ValidationError {
344                message: "Invalid protocol for SMTP registry".to_string(),
345                path: None,
346                code: Some("INVALID_PROTOCOL".to_string()),
347            }]));
348        }
349
350        // Basic SMTP validation
351        let from = request.metadata.get("from");
352        let to = request.metadata.get("to");
353
354        if from.is_none() {
355            return Ok(ValidationResult::failure(vec![ValidationError {
356                message: "Missing 'from' address".to_string(),
357                path: Some("metadata.from".to_string()),
358                code: Some("MISSING_FROM".to_string()),
359            }]));
360        }
361
362        if to.is_none() {
363            return Ok(ValidationResult::failure(vec![ValidationError {
364                message: "Missing 'to' address".to_string(),
365                path: Some("metadata.to".to_string()),
366                code: Some("MISSING_TO".to_string()),
367            }]));
368        }
369
370        Ok(ValidationResult::success())
371    }
372
373    fn generate_mock_response(&self, request: &ProtocolRequest) -> Result<ProtocolResponse> {
374        let from = request.metadata.get("from").unwrap_or(&String::new()).clone();
375        let to = request.metadata.get("to").unwrap_or(&String::new()).clone();
376        let subject = request.metadata.get("subject").unwrap_or(&String::new()).clone();
377
378        // Find matching fixture
379        let fixture = self
380            .find_matching_fixture(&from, &to, &subject)
381            .ok_or_else(|| mockforge_core::Error::generic("No matching fixture found for email"))?;
382
383        // Store email if configured
384        if fixture.storage.save_to_mailbox {
385            let email = StoredEmail {
386                id: uuid::Uuid::new_v4().to_string(),
387                from: from.clone(),
388                to: to.split(',').map(|s| s.trim().to_string()).collect(),
389                subject: subject.clone(),
390                body: String::from_utf8_lossy(request.body.as_ref().unwrap_or(&Vec::new()))
391                    .to_string(),
392                headers: request.metadata.clone(),
393                received_at: chrono::Utc::now(),
394                raw: request.body.clone(),
395            };
396
397            if let Err(e) = self.store_email(email) {
398                error!("Failed to store email: {}", e);
399            }
400        }
401
402        // Generate response
403        let response_message =
404            format!("{} {}\r\n", fixture.response.status_code, fixture.response.message);
405
406        Ok(ProtocolResponse {
407            status: ResponseStatus::SmtpStatus(fixture.response.status_code),
408            metadata: HashMap::new(),
409            body: response_message.into_bytes(),
410            content_type: "text/plain".to_string(),
411        })
412    }
413}
414
415#[cfg(test)]
416mod tests {
417    use super::*;
418
419    #[test]
420    fn test_registry_creation() {
421        let registry = SmtpSpecRegistry::new();
422        assert_eq!(registry.protocol(), Protocol::Smtp);
423        assert_eq!(registry.fixtures.len(), 0);
424    }
425
426    #[test]
427    fn test_mailbox_operations() {
428        let registry = SmtpSpecRegistry::new();
429
430        let email = StoredEmail {
431            id: "test-123".to_string(),
432            from: "sender@example.com".to_string(),
433            to: vec!["recipient@example.com".to_string()],
434            subject: "Test".to_string(),
435            body: "Test body".to_string(),
436            headers: HashMap::new(),
437            received_at: chrono::Utc::now(),
438            raw: None,
439        };
440
441        registry.store_email(email.clone()).unwrap();
442
443        let emails = registry.get_emails().unwrap();
444        assert_eq!(emails.len(), 1);
445        assert_eq!(emails[0].from, "sender@example.com");
446
447        registry.clear_mailbox().unwrap();
448        let emails = registry.get_emails().unwrap();
449        assert_eq!(emails.len(), 0);
450    }
451
452    #[test]
453    fn test_mailbox_size_limit() {
454        let registry = SmtpSpecRegistry::with_mailbox_size(2);
455
456        for i in 0..5 {
457            let email = StoredEmail {
458                id: format!("test-{}", i),
459                from: "sender@example.com".to_string(),
460                to: vec!["recipient@example.com".to_string()],
461                subject: format!("Test {}", i),
462                body: "Test body".to_string(),
463                headers: HashMap::new(),
464                received_at: chrono::Utc::now(),
465                raw: None,
466            };
467
468            registry.store_email(email).unwrap();
469        }
470
471        let emails = registry.get_emails().unwrap();
472        assert_eq!(emails.len(), 2); // Should only keep the last 2
473    }
474}