@jpgilldev / steid

steid/src/domain/email.rs
3.0 KBRaw
1use std::fmt;
2
3use super::DomainError;
4
5/// A validated email address, normalised to lowercase.
6///
7/// Validation is deliberately shallow — a local part, an `@`, and a dotted domain.
8/// Anything stricter rejects addresses that are legal in practice; the only real proof
9/// an address works is sending to it.
10#[derive(Debug, Clone, PartialEq, Eq, Hash)]
11pub struct Email(String);
12
13impl Email {
14 /// Validates and normalises user-supplied input.
15 pub fn new(value: impl Into<String>) -> Result<Self, DomainError> {
16 let value = value.into();
17 let trimmed = value.trim();
18
19 let invalid = |reason: &str| DomainError::validation("email", reason);
20
21 let (local, domain) = trimmed
22 .split_once('@')
23 .ok_or_else(|| invalid("must contain '@'"))?;
24
25 if local.is_empty() {
26 return Err(invalid("missing local part"));
27 }
28 if domain.is_empty() {
29 return Err(invalid("missing domain"));
30 }
31 if domain.contains('@') {
32 return Err(invalid("must contain exactly one '@'"));
33 }
34 if !domain.contains('.') || domain.starts_with('.') || domain.ends_with('.') {
35 return Err(invalid("domain must be dotted"));
36 }
37 if trimmed.contains(char::is_whitespace) {
38 return Err(invalid("must not contain whitespace"));
39 }
40
41 Ok(Self(trimmed.to_lowercase()))
42 }
43
44 /// Wraps a value already validated on the way into the database.
45 ///
46 /// Re-validating stored rows means a change to the rules above makes old rows
47 /// unreadable, so persistence adapters must use this.
48 pub fn from_trusted(value: impl Into<String>) -> Self {
49 Self(value.into())
50 }
51
52 pub fn as_str(&self) -> &str {
53 &self.0
54 }
55}
56
57impl fmt::Display for Email {
58 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
59 f.write_str(&self.0)
60 }
61}
62
63#[cfg(test)]
64mod tests {
65 use super::*;
66
67 #[test]
68 fn accepts_an_ordinary_address() {
69 let email = Email::new("dev@example.com").expect("should be valid");
70
71 assert_eq!(email.as_str(), "dev@example.com");
72 }
73
74 #[test]
75 fn normalises_case_and_surrounding_whitespace() {
76 let email = Email::new(" Dev@Example.COM ").expect("should be valid");
77
78 assert_eq!(email.as_str(), "dev@example.com");
79 }
80
81 #[test]
82 fn rejects_malformed_addresses() {
83 for input in [
84 "",
85 "no-at-sign",
86 "@example.com",
87 "dev@",
88 "dev@localhost",
89 "dev@@example.com",
90 "dev@.com",
91 "dev@example.",
92 "two words@example.com",
93 ] {
94 assert!(
95 Email::new(input).is_err(),
96 "expected {input:?} to be rejected"
97 );
98 }
99 }
100
101 #[test]
102 fn trusted_values_skip_validation() {
103 // A row written under older rules must still load.
104 let email = Email::from_trusted("legacy@localhost");
105
106 assert_eq!(email.as_str(), "legacy@localhost");
107 }
108}