elif_email/
validation.rs

1use crate::EmailError;
2use regex::Regex;
3use std::collections::HashSet;
4use std::sync::OnceLock;
5
6/// Email validation utilities
7pub struct EmailValidator {
8    /// Regex for basic email validation
9    email_regex: Regex,
10    /// Domain blocklist
11    blocked_domains: HashSet<String>,
12    /// Domain allowlist (if set, only these domains are allowed)
13    allowed_domains: Option<HashSet<String>>,
14}
15
16impl EmailValidator {
17    /// Create new email validator
18    pub fn new() -> Result<Self, EmailError> {
19        let email_regex = Regex::new(
20            r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
21        ).map_err(|e| EmailError::configuration(format!("Invalid email regex: {}", e)))?;
22
23        Ok(Self {
24            email_regex,
25            blocked_domains: HashSet::new(),
26            allowed_domains: None,
27        })
28    }
29
30    /// Add blocked domain
31    pub fn block_domain(&mut self, domain: impl Into<String>) -> &mut Self {
32        self.blocked_domains.insert(domain.into().to_lowercase());
33        self
34    }
35
36    /// Add multiple blocked domains
37    pub fn block_domains(&mut self, domains: Vec<String>) -> &mut Self {
38        for domain in domains {
39            self.blocked_domains.insert(domain.to_lowercase());
40        }
41        self
42    }
43
44    /// Set allowed domains (only these will be accepted)
45    pub fn set_allowed_domains(&mut self, domains: Vec<String>) -> &mut Self {
46        let domains: HashSet<String> = domains.into_iter().map(|d| d.to_lowercase()).collect();
47        self.allowed_domains = Some(domains);
48        self
49    }
50
51    /// Validate email address
52    pub fn validate(&self, email: &str) -> Result<(), EmailError> {
53        let email = email.trim().to_lowercase();
54        
55        // Basic format validation
56        if !self.email_regex.is_match(&email) {
57            return Err(EmailError::validation("email", "Invalid email format"));
58        }
59
60        // Extract domain
61        let domain = email.split('@').nth(1)
62            .ok_or_else(|| EmailError::validation("email", "No domain found in email"))?;
63
64        // Check domain blocklist
65        if self.blocked_domains.contains(domain) {
66            return Err(EmailError::validation("email", format!("Domain '{}' is blocked", domain)));
67        }
68
69        // Check domain allowlist
70        if let Some(ref allowed_domains) = self.allowed_domains {
71            if !allowed_domains.contains(domain) {
72                return Err(EmailError::validation("email", format!("Domain '{}' is not allowed", domain)));
73            }
74        }
75
76        Ok(())
77    }
78
79    /// Validate multiple email addresses
80    pub fn validate_many(&self, emails: &[String]) -> Result<(), EmailError> {
81        for (index, email) in emails.iter().enumerate() {
82            self.validate(email).map_err(|e| {
83                EmailError::validation(
84                    &format!("email[{}]", index),
85                    format!("Email '{}': {}", email, e)
86                )
87            })?;
88        }
89        Ok(())
90    }
91
92    /// Validate email and return normalized version
93    pub fn validate_and_normalize(&self, email: &str) -> Result<String, EmailError> {
94        let normalized = email.trim().to_lowercase();
95        self.validate(&normalized)?;
96        Ok(normalized)
97    }
98}
99
100impl Default for EmailValidator {
101    fn default() -> Self {
102        Self::new().expect("Failed to create default email validator")
103    }
104}
105
106/// Global email validator instance
107static GLOBAL_VALIDATOR: OnceLock<EmailValidator> = OnceLock::new();
108
109/// Get global email validator
110pub fn global_validator() -> &'static EmailValidator {
111    GLOBAL_VALIDATOR.get_or_init(EmailValidator::default)
112}
113
114/// Initialize global validator with custom settings
115pub fn init_global_validator(validator: EmailValidator) -> Result<(), EmailError> {
116    GLOBAL_VALIDATOR.set(validator).map_err(|_| {
117        EmailError::configuration("Global email validator already initialized")
118    })
119}
120
121/// Quick email validation function
122pub fn validate_email(email: &str) -> Result<(), EmailError> {
123    global_validator().validate(email)
124}
125
126/// Quick email normalization function
127pub fn normalize_email(email: &str) -> Result<String, EmailError> {
128    global_validator().validate_and_normalize(email)
129}
130
131/// Email validation builder for configuration
132pub struct EmailValidatorBuilder {
133    validator: EmailValidator,
134}
135
136impl EmailValidatorBuilder {
137    /// Create new validator builder
138    pub fn new() -> Result<Self, EmailError> {
139        Ok(Self {
140            validator: EmailValidator::new()?,
141        })
142    }
143
144    /// Block domain
145    pub fn block_domain(mut self, domain: impl Into<String>) -> Self {
146        self.validator.block_domain(domain);
147        self
148    }
149
150    /// Block multiple domains
151    pub fn block_domains(mut self, domains: Vec<String>) -> Self {
152        self.validator.block_domains(domains);
153        self
154    }
155
156    /// Set allowed domains
157    pub fn allowed_domains(mut self, domains: Vec<String>) -> Self {
158        self.validator.set_allowed_domains(domains);
159        self
160    }
161
162    /// Build the validator
163    pub fn build(self) -> EmailValidator {
164        self.validator
165    }
166}
167
168impl Default for EmailValidatorBuilder {
169    fn default() -> Self {
170        Self::new().expect("Failed to create email validator builder")
171    }
172}
173
174/// Common domain blocklists
175pub mod blocklists {
176    /// Disposable email domains
177    pub const DISPOSABLE_DOMAINS: &[&str] = &[
178        "10minutemail.com",
179        "guerrillamail.com",
180        "mailinator.com",
181        "tempmail.org",
182        "yopmail.com",
183        "throwaway.email",
184        "temp-mail.org",
185        "fake-mail.ml",
186    ];
187
188    /// Test domains (should be blocked in production)
189    pub const TEST_DOMAINS: &[&str] = &[
190        "example.com",
191        "example.org",
192        "test.com",
193        "localhost",
194    ];
195
196    /// Get disposable email domains as Vec<String>
197    pub fn disposable_domains() -> Vec<String> {
198        DISPOSABLE_DOMAINS.iter().map(|s| s.to_string()).collect()
199    }
200
201    /// Get test domains as Vec<String>
202    pub fn test_domains() -> Vec<String> {
203        TEST_DOMAINS.iter().map(|s| s.to_string()).collect()
204    }
205}
206
207/// Email content validation
208pub struct EmailContentValidator;
209
210impl EmailContentValidator {
211    /// Validate email subject
212    pub fn validate_subject(subject: &str) -> Result<(), EmailError> {
213        if subject.is_empty() {
214            return Err(EmailError::validation("subject", "Subject cannot be empty"));
215        }
216
217        if subject.len() > 998 {
218            return Err(EmailError::validation("subject", "Subject too long (max 998 characters)"));
219        }
220
221        // Check for spam-like content
222        let spam_keywords = ["URGENT", "FREE MONEY", "CLICK HERE NOW", "GUARANTEED"];
223        let upper_subject = subject.to_uppercase();
224        
225        let spam_count = spam_keywords.iter()
226            .filter(|&&keyword| upper_subject.contains(keyword))
227            .count();
228        
229        if spam_count >= 2 {
230            return Err(EmailError::validation("subject", "Subject contains spam-like content"));
231        }
232
233        Ok(())
234    }
235
236    /// Validate email body length
237    pub fn validate_body_length(body: &str, max_length: usize) -> Result<(), EmailError> {
238        if body.len() > max_length {
239            return Err(EmailError::validation(
240                "body", 
241                format!("Body too long (max {} characters)", max_length)
242            ));
243        }
244        Ok(())
245    }
246
247    /// Validate that email has some content
248    pub fn validate_has_content(html_body: &Option<String>, text_body: &Option<String>) -> Result<(), EmailError> {
249        if html_body.is_none() && text_body.is_none() {
250            return Err(EmailError::validation("body", "Email must have either HTML or text body"));
251        }
252
253        if let Some(html) = html_body {
254            if html.trim().is_empty() && text_body.as_ref().map_or(true, |t| t.trim().is_empty()) {
255                return Err(EmailError::validation("body", "Email body cannot be empty"));
256            }
257        } else if let Some(text) = text_body {
258            if text.trim().is_empty() {
259                return Err(EmailError::validation("body", "Email body cannot be empty"));
260            }
261        }
262
263        Ok(())
264    }
265}
266
267/// Extension methods for Email validation
268impl crate::Email {
269    /// Validate this email
270    pub fn validate(&self) -> Result<(), EmailError> {
271        // Validate sender
272        if !self.from.is_empty() {
273            validate_email(&self.from)?;
274        }
275
276        // Validate recipients
277        if self.to.is_empty() {
278            return Err(EmailError::validation("to", "Email must have at least one recipient"));
279        }
280        
281        for (index, to_addr) in self.to.iter().enumerate() {
282            validate_email(to_addr).map_err(|e| {
283                EmailError::validation(&format!("to[{}]", index), e.to_string())
284            })?;
285        }
286
287        // Validate CC recipients
288        if let Some(ref cc_list) = self.cc {
289            for (index, cc_addr) in cc_list.iter().enumerate() {
290                validate_email(cc_addr).map_err(|e| {
291                    EmailError::validation(&format!("cc[{}]", index), e.to_string())
292                })?;
293            }
294        }
295
296        // Validate BCC recipients
297        if let Some(ref bcc_list) = self.bcc {
298            for (index, bcc_addr) in bcc_list.iter().enumerate() {
299                validate_email(bcc_addr).map_err(|e| {
300                    EmailError::validation(&format!("bcc[{}]", index), e.to_string())
301                })?;
302            }
303        }
304
305        // Validate reply-to
306        if let Some(ref reply_to) = self.reply_to {
307            validate_email(reply_to)?;
308        }
309
310        // Validate subject
311        EmailContentValidator::validate_subject(&self.subject)?;
312
313        // Validate body content
314        EmailContentValidator::validate_has_content(&self.html_body, &self.text_body)?;
315
316        // Validate body lengths
317        if let Some(ref html) = self.html_body {
318            EmailContentValidator::validate_body_length(html, 102_400)?; // 100KB
319        }
320        if let Some(ref text) = self.text_body {
321            EmailContentValidator::validate_body_length(text, 102_400)?; // 100KB
322        }
323
324        Ok(())
325    }
326}
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331
332    #[test]
333    fn test_email_validation() {
334        let validator = EmailValidator::new().unwrap();
335
336        // Valid emails
337        assert!(validator.validate("user@example.com").is_ok());
338        assert!(validator.validate("test.email+tag@domain.co.uk").is_ok());
339        
340        // Invalid emails
341        assert!(validator.validate("invalid-email").is_err());
342        assert!(validator.validate("@domain.com").is_err());
343        assert!(validator.validate("user@").is_err());
344    }
345
346    #[test]
347    fn test_domain_blocking() {
348        let mut validator = EmailValidator::new().unwrap();
349        validator.block_domain("spam.com");
350
351        assert!(validator.validate("user@example.com").is_ok());
352        assert!(validator.validate("user@spam.com").is_err());
353    }
354
355    #[test]
356    fn test_domain_allowlist() {
357        let mut validator = EmailValidator::new().unwrap();
358        validator.set_allowed_domains(vec!["allowed.com".to_string()]);
359
360        assert!(validator.validate("user@allowed.com").is_ok());
361        assert!(validator.validate("user@other.com").is_err());
362    }
363
364    #[test]
365    fn test_email_normalization() {
366        let validator = EmailValidator::new().unwrap();
367        
368        let normalized = validator.validate_and_normalize("  User@Example.COM  ").unwrap();
369        assert_eq!(normalized, "user@example.com");
370    }
371
372    #[test]
373    fn test_subject_validation() {
374        assert!(EmailContentValidator::validate_subject("Valid Subject").is_ok());
375        assert!(EmailContentValidator::validate_subject("").is_err());
376        
377        let long_subject = "a".repeat(1000);
378        assert!(EmailContentValidator::validate_subject(&long_subject).is_err());
379        
380        assert!(EmailContentValidator::validate_subject("URGENT FREE MONEY NOW").is_err());
381    }
382
383    #[test]
384    fn test_email_struct_validation() {
385        let mut email = crate::Email::new()
386            .from("sender@example.com")
387            .to("recipient@example.com")
388            .subject("Test Subject")
389            .text_body("Hello World");
390
391        assert!(email.validate().is_ok());
392
393        // Remove body to make it invalid
394        email.text_body = None;
395        assert!(email.validate().is_err());
396    }
397}