1use crate::EmailError;
2use regex::Regex;
3use std::collections::HashSet;
4use std::sync::OnceLock;
5
6pub struct EmailValidator {
8 email_regex: Regex,
10 blocked_domains: HashSet<String>,
12 allowed_domains: Option<HashSet<String>>,
14}
15
16impl EmailValidator {
17 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 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 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 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 pub fn validate(&self, email: &str) -> Result<(), EmailError> {
53 let email = email.trim().to_lowercase();
54
55 if !self.email_regex.is_match(&email) {
57 return Err(EmailError::validation("email", "Invalid email format"));
58 }
59
60 let domain = email.split('@').nth(1)
62 .ok_or_else(|| EmailError::validation("email", "No domain found in email"))?;
63
64 if self.blocked_domains.contains(domain) {
66 return Err(EmailError::validation("email", format!("Domain '{}' is blocked", domain)));
67 }
68
69 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 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 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
106static GLOBAL_VALIDATOR: OnceLock<EmailValidator> = OnceLock::new();
108
109pub fn global_validator() -> &'static EmailValidator {
111 GLOBAL_VALIDATOR.get_or_init(EmailValidator::default)
112}
113
114pub 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
121pub fn validate_email(email: &str) -> Result<(), EmailError> {
123 global_validator().validate(email)
124}
125
126pub fn normalize_email(email: &str) -> Result<String, EmailError> {
128 global_validator().validate_and_normalize(email)
129}
130
131pub struct EmailValidatorBuilder {
133 validator: EmailValidator,
134}
135
136impl EmailValidatorBuilder {
137 pub fn new() -> Result<Self, EmailError> {
139 Ok(Self {
140 validator: EmailValidator::new()?,
141 })
142 }
143
144 pub fn block_domain(mut self, domain: impl Into<String>) -> Self {
146 self.validator.block_domain(domain);
147 self
148 }
149
150 pub fn block_domains(mut self, domains: Vec<String>) -> Self {
152 self.validator.block_domains(domains);
153 self
154 }
155
156 pub fn allowed_domains(mut self, domains: Vec<String>) -> Self {
158 self.validator.set_allowed_domains(domains);
159 self
160 }
161
162 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
174pub mod blocklists {
176 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 pub const TEST_DOMAINS: &[&str] = &[
190 "example.com",
191 "example.org",
192 "test.com",
193 "localhost",
194 ];
195
196 pub fn disposable_domains() -> Vec<String> {
198 DISPOSABLE_DOMAINS.iter().map(|s| s.to_string()).collect()
199 }
200
201 pub fn test_domains() -> Vec<String> {
203 TEST_DOMAINS.iter().map(|s| s.to_string()).collect()
204 }
205}
206
207pub struct EmailContentValidator;
209
210impl EmailContentValidator {
211 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 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 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 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
267impl crate::Email {
269 pub fn validate(&self) -> Result<(), EmailError> {
271 if !self.from.is_empty() {
273 validate_email(&self.from)?;
274 }
275
276 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 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 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 if let Some(ref reply_to) = self.reply_to {
307 validate_email(reply_to)?;
308 }
309
310 EmailContentValidator::validate_subject(&self.subject)?;
312
313 EmailContentValidator::validate_has_content(&self.html_body, &self.text_body)?;
315
316 if let Some(ref html) = self.html_body {
318 EmailContentValidator::validate_body_length(html, 102_400)?; }
320 if let Some(ref text) = self.text_body {
321 EmailContentValidator::validate_body_length(text, 102_400)?; }
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 assert!(validator.validate("user@example.com").is_ok());
338 assert!(validator.validate("test.email+tag@domain.co.uk").is_ok());
339
340 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 email.text_body = None;
395 assert!(email.validate().is_err());
396 }
397}