1use regex::Regex;
34use std::sync::LazyLock;
35
36#[derive(Debug, Clone, PartialEq, Eq)]
38pub enum EmailValidationError {
39 Empty,
41 NoAtSymbol,
43 MultipleAtSymbols,
45 LocalPartEmpty,
47 LocalPartTooLong,
49 DomainEmpty,
51 DomainTooLong,
53 InvalidLocalPartCharacter,
55 InvalidDomainCharacter,
57 ConsecutiveDots,
59 LeadingDot,
61 TrailingDot,
63 InvalidQuotedString,
65 UnbalancedQuotes,
67 InvalidIPLiteral,
69 DomainLabelTooLong,
71 TotalLengthExceeded,
73}
74
75impl std::fmt::Display for EmailValidationError {
76 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
77 match self {
78 Self::Empty => write!(f, "email address is empty"),
79 Self::NoAtSymbol => write!(f, "missing @ symbol"),
80 Self::MultipleAtSymbols => write!(f, "multiple @ symbols"),
81 Self::LocalPartEmpty => write!(f, "local part is empty"),
82 Self::LocalPartTooLong => write!(f, "local part exceeds 64 characters"),
83 Self::DomainEmpty => write!(f, "domain is empty"),
84 Self::DomainTooLong => write!(f, "domain exceeds 255 characters"),
85 Self::InvalidLocalPartCharacter => write!(f, "invalid character in local part"),
86 Self::InvalidDomainCharacter => write!(f, "invalid character in domain"),
87 Self::ConsecutiveDots => write!(f, "consecutive dots not allowed"),
88 Self::LeadingDot => write!(f, "leading dot not allowed"),
89 Self::TrailingDot => write!(f, "trailing dot not allowed"),
90 Self::InvalidQuotedString => write!(f, "invalid quoted string"),
91 Self::UnbalancedQuotes => write!(f, "unbalanced quotes"),
92 Self::InvalidIPLiteral => write!(f, "invalid IP literal"),
93 Self::DomainLabelTooLong => write!(f, "domain label exceeds 63 characters"),
94 Self::TotalLengthExceeded => write!(f, "email exceeds 254 characters"),
95 }
96 }
97}
98
99impl std::error::Error for EmailValidationError {}
100
101pub type ValidationResult = Result<(), EmailValidationError>;
103
104pub fn validate_email(email: &str) -> ValidationResult {
123 if email.is_empty() {
124 return Err(EmailValidationError::Empty);
125 }
126 if email.len() > 254 {
127 return Err(EmailValidationError::TotalLengthExceeded);
128 }
129
130 let (local_part, domain) = split_email(email)?;
131 validate_local_part(local_part)?;
132 validate_domain(domain)?;
133
134 Ok(())
135}
136
137#[inline]
151pub fn is_valid_email(email: &str) -> bool {
152 validate_email(email).is_ok()
153}
154
155fn split_email(email: &str) -> Result<(&str, &str), EmailValidationError> {
156 let bytes = email.as_bytes();
157 let mut in_quotes = false;
158 let mut escape_next = false;
159 let mut at_pos = None;
160
161 for (i, &byte) in bytes.iter().enumerate() {
162 if escape_next {
163 escape_next = false;
164 continue;
165 }
166 match byte {
167 b'\\' if in_quotes => escape_next = true,
168 b'"' => in_quotes = !in_quotes,
169 b'@' if !in_quotes => {
170 if at_pos.is_some() {
171 return Err(EmailValidationError::MultipleAtSymbols);
172 }
173 at_pos = Some(i);
174 }
175 _ => {}
176 }
177 }
178
179 if in_quotes {
180 return Err(EmailValidationError::UnbalancedQuotes);
181 }
182
183 match at_pos {
184 None => Err(EmailValidationError::NoAtSymbol),
185 Some(pos) => {
186 let local = &email[..pos];
187 let domain = &email[pos + 1..];
188 if local.is_empty() {
189 return Err(EmailValidationError::LocalPartEmpty);
190 }
191 if domain.is_empty() {
192 return Err(EmailValidationError::DomainEmpty);
193 }
194 Ok((local, domain))
195 }
196 }
197}
198
199fn validate_local_part(local: &str) -> ValidationResult {
200 if local.len() > 64 {
201 return Err(EmailValidationError::LocalPartTooLong);
202 }
203
204 if local.starts_with('"') && local.ends_with('"') && local.len() >= 2 {
205 validate_quoted_string(local)
206 } else if local.contains('"') {
207 Err(EmailValidationError::InvalidQuotedString)
208 } else {
209 validate_dot_atom(local, true)
210 }
211}
212
213fn validate_quoted_string(s: &str) -> ValidationResult {
214 let inner = &s[1..s.len() - 1];
215 let bytes = inner.as_bytes();
216 let mut i = 0;
217
218 while i < bytes.len() {
219 let byte = bytes[i];
220 if byte == b'\\' {
221 if i + 1 >= bytes.len() {
222 return Err(EmailValidationError::InvalidQuotedString);
223 }
224 let next = bytes[i + 1];
225 if !is_vchar(next) && !is_wsp(next) {
226 return Err(EmailValidationError::InvalidQuotedString);
227 }
228 i += 2;
229 } else if is_qtext(byte) || is_wsp(byte) {
230 i += 1;
231 } else {
232 return Err(EmailValidationError::InvalidQuotedString);
233 }
234 }
235 Ok(())
236}
237
238fn validate_dot_atom(s: &str, is_local: bool) -> ValidationResult {
239 if s.is_empty() {
240 return Err(if is_local {
241 EmailValidationError::LocalPartEmpty
242 } else {
243 EmailValidationError::DomainEmpty
244 });
245 }
246 if s.starts_with('.') {
247 return Err(EmailValidationError::LeadingDot);
248 }
249 if s.ends_with('.') {
250 return Err(EmailValidationError::TrailingDot);
251 }
252 if s.contains("..") {
253 return Err(EmailValidationError::ConsecutiveDots);
254 }
255
256 for byte in s.bytes() {
257 if byte == b'.' {
258 continue;
259 }
260 if is_local {
261 if !is_atext(byte) {
262 return Err(EmailValidationError::InvalidLocalPartCharacter);
263 }
264 } else if !is_domain_char(byte) {
265 return Err(EmailValidationError::InvalidDomainCharacter);
266 }
267 }
268 Ok(())
269}
270
271fn validate_domain(domain: &str) -> ValidationResult {
272 if domain.len() > 255 {
273 return Err(EmailValidationError::DomainTooLong);
274 }
275
276 if domain.starts_with('[') && domain.ends_with(']') {
277 validate_domain_literal(domain)
278 } else {
279 validate_domain_name(domain)
280 }
281}
282
283fn validate_domain_literal(domain: &str) -> ValidationResult {
284 let inner = &domain[1..domain.len() - 1];
285 if let Some(ipv6) = inner.strip_prefix("IPv6:") {
286 validate_ipv6(ipv6)
287 } else {
288 validate_ipv4(inner)
289 }
290}
291
292fn validate_ipv4(ip: &str) -> ValidationResult {
293 static IPV4_RE: LazyLock<Regex> = LazyLock::new(|| {
294 Regex::new(
295 r"^(?:(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])$",
296 )
297 .unwrap()
298 });
299
300 if IPV4_RE.is_match(ip) {
301 Ok(())
302 } else {
303 Err(EmailValidationError::InvalidIPLiteral)
304 }
305}
306
307fn validate_ipv6(ip: &str) -> ValidationResult {
308 if ip.is_empty() {
309 return Err(EmailValidationError::InvalidIPLiteral);
310 }
311 if ip == "::" {
312 return Ok(());
313 }
314
315 let double_colon_count = ip.matches("::").count();
316 if double_colon_count > 1 {
317 return Err(EmailValidationError::InvalidIPLiteral);
318 }
319
320 let has_double_colon = double_colon_count == 1;
321 let parts: Vec<&str> = ip.split(':').collect();
322
323 if has_double_colon {
324 if parts.len() > 8 {
325 return Err(EmailValidationError::InvalidIPLiteral);
326 }
327 } else if parts.len() != 8 {
328 return Err(EmailValidationError::InvalidIPLiteral);
329 }
330
331 for part in &parts {
332 if part.is_empty() {
333 continue;
334 }
335 if part.len() > 4 {
336 return Err(EmailValidationError::InvalidIPLiteral);
337 }
338 if !part.chars().all(|c| c.is_ascii_hexdigit()) {
339 return Err(EmailValidationError::InvalidIPLiteral);
340 }
341 }
342 Ok(())
343}
344
345fn validate_domain_name(domain: &str) -> ValidationResult {
346 validate_dot_atom(domain, false)?;
347
348 for label in domain.split('.') {
349 if label.len() > 63 {
350 return Err(EmailValidationError::DomainLabelTooLong);
351 }
352 if label.starts_with('-') || label.ends_with('-') {
353 return Err(EmailValidationError::InvalidDomainCharacter);
354 }
355 }
356 Ok(())
357}
358
359#[inline]
360const fn is_vchar(byte: u8) -> bool {
361 byte >= 0x21 && byte <= 0x7E
362}
363
364#[inline]
365const fn is_wsp(byte: u8) -> bool {
366 byte == b' ' || byte == b'\t'
367}
368
369#[inline]
370const fn is_qtext(byte: u8) -> bool {
371 byte == 33 || (byte >= 35 && byte <= 91) || (byte >= 93 && byte <= 126)
372}
373
374#[inline]
375fn is_atext(byte: u8) -> bool {
376 byte.is_ascii_alphanumeric()
377 || matches!(
378 byte,
379 b'!' | b'#'
380 | b'$'
381 | b'%'
382 | b'&'
383 | b'\''
384 | b'*'
385 | b'+'
386 | b'-'
387 | b'/'
388 | b'='
389 | b'?'
390 | b'^'
391 | b'_'
392 | b'`'
393 | b'{'
394 | b'|'
395 | b'}'
396 | b'~'
397 )
398}
399
400#[inline]
401fn is_domain_char(byte: u8) -> bool {
402 byte.is_ascii_alphanumeric() || byte == b'-'
403}
404
405#[cfg(test)]
406mod tests {
407 use super::*;
408
409 #[test]
410 fn valid_simple_emails() {
411 assert!(validate_email("test@example.com").is_ok());
412 assert!(validate_email("user.name@domain.org").is_ok());
413 assert!(validate_email("user+tag@example.com").is_ok());
414 assert!(validate_email("a@b.co").is_ok());
415 }
416
417 #[test]
418 fn valid_special_chars() {
419 let specials = [
420 "user!def@example.com",
421 "user#comment@example.com",
422 "user$money@example.com",
423 "user%encoded@example.com",
424 "user&and@example.com",
425 "user'quote@example.com",
426 "user*star@example.com",
427 "user/slash@example.com",
428 "user=equals@example.com",
429 "user?query@example.com",
430 "user^caret@example.com",
431 "user_underscore@example.com",
432 "user`backtick@example.com",
433 "user{brace@example.com",
434 "user|pipe@example.com",
435 "user}brace@example.com",
436 "user~tilde@example.com",
437 ];
438 for email in specials {
439 assert!(validate_email(email).is_ok(), "Failed: {}", email);
440 }
441 }
442
443 #[test]
444 fn valid_quoted_strings() {
445 assert!(validate_email("\"john doe\"@example.com").is_ok());
446 assert!(validate_email("\"john@doe\"@example.com").is_ok());
447 assert!(validate_email("\"john\\\"doe\"@example.com").is_ok());
448 assert!(validate_email("\"\"@example.com").is_ok());
449 assert!(validate_email("\"user@domain\"@example.com").is_ok());
450 }
451
452 #[test]
453 fn valid_ip_literals() {
454 assert!(validate_email("user@[192.168.1.1]").is_ok());
455 assert!(validate_email("user@[127.0.0.1]").is_ok());
456 assert!(validate_email("user@[0.0.0.0]").is_ok());
457 assert!(validate_email("user@[255.255.255.255]").is_ok());
458 }
459
460 #[test]
461 fn valid_ipv6_literals() {
462 assert!(validate_email("user@[IPv6:2001:db8::1]").is_ok());
463 assert!(validate_email("user@[IPv6:2001:db8:85a3:0000:0000:8a2e:0370:7334]").is_ok());
464 assert!(validate_email("user@[IPv6:::]").is_ok()); assert!(validate_email("user@[IPv6:::1]").is_ok()); assert!(validate_email("user@[IPv6:fe80::1]").is_ok());
467 }
468
469 #[test]
470 fn invalid_empty() {
471 assert_eq!(validate_email(""), Err(EmailValidationError::Empty));
472 }
473
474 #[test]
475 fn invalid_no_at() {
476 assert_eq!(
477 validate_email("userexample.com"),
478 Err(EmailValidationError::NoAtSymbol)
479 );
480 }
481
482 #[test]
483 fn invalid_multiple_at() {
484 assert_eq!(
485 validate_email("user@@example.com"),
486 Err(EmailValidationError::MultipleAtSymbols)
487 );
488 assert_eq!(
489 validate_email("user@name@example.com"),
490 Err(EmailValidationError::MultipleAtSymbols)
491 );
492 }
493
494 #[test]
495 fn invalid_empty_parts() {
496 assert_eq!(
497 validate_email("@example.com"),
498 Err(EmailValidationError::LocalPartEmpty)
499 );
500 assert_eq!(
501 validate_email("user@"),
502 Err(EmailValidationError::DomainEmpty)
503 );
504 }
505
506 #[test]
507 fn invalid_dots() {
508 assert_eq!(
509 validate_email(".user@example.com"),
510 Err(EmailValidationError::LeadingDot)
511 );
512 assert_eq!(
513 validate_email("user.@example.com"),
514 Err(EmailValidationError::TrailingDot)
515 );
516 assert_eq!(
517 validate_email("user..name@example.com"),
518 Err(EmailValidationError::ConsecutiveDots)
519 );
520 }
521
522 #[test]
523 fn invalid_characters() {
524 assert_eq!(
525 validate_email("user name@example.com"),
526 Err(EmailValidationError::InvalidLocalPartCharacter)
527 );
528 assert_eq!(
529 validate_email("user\t@example.com"),
530 Err(EmailValidationError::InvalidLocalPartCharacter)
531 );
532 }
533
534 #[test]
535 fn local_part_length_limit() {
536 let long_local = "a".repeat(65);
537 assert_eq!(
538 validate_email(&format!("{}@example.com", long_local)),
539 Err(EmailValidationError::LocalPartTooLong)
540 );
541
542 let max_local = "a".repeat(64);
543 assert!(validate_email(&format!("{}@example.com", max_local)).is_ok());
544 }
545
546 #[test]
547 fn total_length_limit() {
548 let long_email = format!("user@{}.com", "a".repeat(250));
549 assert_eq!(
550 validate_email(&long_email),
551 Err(EmailValidationError::TotalLengthExceeded)
552 );
553 }
554
555 #[test]
556 fn domain_length_limit() {
557 let domain_256 = format!("{}.{}.co", "a".repeat(200), "b".repeat(52));
562 assert!(domain_256.len() > 255, "domain len: {}", domain_256.len());
563 assert_eq!(
565 validate_email(&format!("u@{}", domain_256)),
566 Err(EmailValidationError::TotalLengthExceeded)
567 );
568 }
569
570 #[test]
571 fn domain_label_limits() {
572 let long_label = "a".repeat(64);
573 assert_eq!(
574 validate_email(&format!("user@{}.com", long_label)),
575 Err(EmailValidationError::DomainLabelTooLong)
576 );
577
578 let max_label = "a".repeat(63);
579 assert!(validate_email(&format!("user@{}.com", max_label)).is_ok());
580 }
581
582 #[test]
583 fn domain_hyphen_rules() {
584 assert_eq!(
585 validate_email("user@-example.com"),
586 Err(EmailValidationError::InvalidDomainCharacter)
587 );
588 assert_eq!(
589 validate_email("user@example-.com"),
590 Err(EmailValidationError::InvalidDomainCharacter)
591 );
592 assert!(validate_email("user@ex-ample.com").is_ok());
593 assert!(validate_email("user@ex--ample.com").is_ok());
594 }
595
596 #[test]
597 fn invalid_quoted_strings() {
598 assert_eq!(
599 validate_email("\"user@example.com"),
600 Err(EmailValidationError::UnbalancedQuotes)
601 );
602 assert_eq!(
603 validate_email("user\"quoted\"@example.com"),
604 Err(EmailValidationError::InvalidQuotedString)
605 );
606 }
607
608 #[test]
609 fn invalid_ip_literals() {
610 assert_eq!(
611 validate_email("user@[999.999.999.999]"),
612 Err(EmailValidationError::InvalidIPLiteral)
613 );
614 assert_eq!(
615 validate_email("user@[192.168.1]"),
616 Err(EmailValidationError::InvalidIPLiteral)
617 );
618 assert_eq!(
619 validate_email("user@[not.an.ip.addr]"),
620 Err(EmailValidationError::InvalidIPLiteral)
621 );
622 }
623
624 #[test]
625 fn invalid_ipv6_literals() {
626 assert_eq!(
627 validate_email("user@[IPv6:1:2:3:4:5:6:7:8:9]"),
628 Err(EmailValidationError::InvalidIPLiteral)
629 );
630 assert_eq!(
631 validate_email("user@[IPv6:2001::db8::1]"),
632 Err(EmailValidationError::InvalidIPLiteral)
633 );
634 assert_eq!(
635 validate_email("user@[IPv6:GHIJ::]"),
636 Err(EmailValidationError::InvalidIPLiteral)
637 );
638 }
639
640 #[test]
641 fn is_valid_email_helper() {
642 assert!(is_valid_email("user@example.com"));
643 assert!(!is_valid_email("invalid"));
644 }
645
646 #[test]
647 fn error_display() {
648 assert_eq!(
649 EmailValidationError::Empty.to_string(),
650 "email address is empty"
651 );
652 assert_eq!(
653 EmailValidationError::NoAtSymbol.to_string(),
654 "missing @ symbol"
655 );
656 }
657}