Skip to main content

karbon_framework/validation/constraints/string/
email.rs

1use crate::validation::constraints::{Constraint, ConstraintResult, ConstraintViolation};
2
3/// Validates that a value is a valid email address.
4///
5/// Equivalent to Symfony's `Email` constraint.
6pub struct Email {
7    pub message: String,
8}
9
10impl Default for Email {
11    fn default() -> Self {
12        Self {
13            message: "This value is not a valid email address.".to_string(),
14        }
15    }
16}
17
18impl Email {
19    pub fn new() -> Self {
20        Self::default()
21    }
22
23    pub fn with_message(mut self, message: impl Into<String>) -> Self {
24        self.message = message.into();
25        self
26    }
27
28    fn is_valid_email(value: &str) -> bool {
29        if value.is_empty() {
30            return false;
31        }
32
33        let parts: Vec<&str> = value.splitn(2, '@').collect();
34        if parts.len() != 2 {
35            return false;
36        }
37
38        let local = parts[0];
39        let domain = parts[1];
40
41        // Local part validation
42        if local.is_empty() || local.len() > 64 {
43            return false;
44        }
45
46        // Domain validation
47        if domain.is_empty() || domain.len() > 255 {
48            return false;
49        }
50
51        // Domain must contain at least one dot
52        if !domain.contains('.') {
53            return false;
54        }
55
56        // Domain parts validation
57        for part in domain.split('.') {
58            if part.is_empty() || part.len() > 63 {
59                return false;
60            }
61            if part.starts_with('-') || part.ends_with('-') {
62                return false;
63            }
64            if !part.chars().all(|c| c.is_alphanumeric() || c == '-') {
65                return false;
66            }
67        }
68
69        // Local part: allowed characters
70        let valid_local = local.chars().all(|c| {
71            c.is_alphanumeric() || ".!#$%&'*+/=?^_`{|}~-".contains(c)
72        });
73
74        if !valid_local {
75            return false;
76        }
77
78        // No consecutive dots in local part
79        if local.contains("..") {
80            return false;
81        }
82
83        true
84    }
85}
86
87impl Constraint for Email {
88    fn validate(&self, value: &str) -> ConstraintResult {
89        if !Self::is_valid_email(value) {
90            return Err(ConstraintViolation::new(
91                self.name(),
92                &self.message,
93                value,
94            ));
95        }
96        Ok(())
97    }
98
99    fn name(&self) -> &'static str {
100        "Email"
101    }
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107
108    #[test]
109    fn test_valid_emails() {
110        let constraint = Email::new();
111        assert!(constraint.validate("user@example.com").is_ok());
112        assert!(constraint.validate("user.name@example.com").is_ok());
113        assert!(constraint.validate("user+tag@example.com").is_ok());
114        assert!(constraint.validate("user@sub.domain.com").is_ok());
115        assert!(constraint.validate("u@example.co").is_ok());
116    }
117
118    #[test]
119    fn test_invalid_emails() {
120        let constraint = Email::new();
121        assert!(constraint.validate("").is_err());
122        assert!(constraint.validate("not-an-email").is_err());
123        assert!(constraint.validate("@example.com").is_err());
124        assert!(constraint.validate("user@").is_err());
125        assert!(constraint.validate("user@.com").is_err());
126        assert!(constraint.validate("user@domain").is_err());
127        assert!(constraint.validate("user..name@example.com").is_err());
128        assert!(constraint.validate("user@-domain.com").is_err());
129    }
130
131    #[test]
132    fn test_custom_message() {
133        let constraint = Email::new().with_message("Email invalide.");
134        let err = constraint.validate("invalid").unwrap_err();
135        assert_eq!(err.message, "Email invalide.");
136    }
137}