aster_forge_validation/
email_policy.rs1use std::collections::BTreeSet;
10
11use crate::email::{email_domain, normalize_email};
12use crate::{Result, ValidationError};
13
14#[derive(Debug, Clone, Default, PartialEq, Eq)]
16pub struct EmailPolicyList {
17 emails: BTreeSet<String>,
18 domains: BTreeSet<String>,
19}
20
21impl EmailPolicyList {
22 pub fn from_items<I, S>(items: I) -> Result<Self>
27 where
28 I: IntoIterator<Item = S>,
29 S: AsRef<str>,
30 {
31 let mut list = Self::default();
32 for item in items {
33 let item = item.as_ref().trim();
34 if item.is_empty() {
35 continue;
36 }
37 list.insert(parse_email_policy_item(item)?);
38 }
39 Ok(list)
40 }
41
42 pub fn from_items_lossy<I, S, F>(items: I, mut on_invalid: F) -> Self
48 where
49 I: IntoIterator<Item = S>,
50 S: AsRef<str>,
51 F: FnMut(&str, &ValidationError),
52 {
53 let mut list = Self::default();
54 for item in items {
55 let item = item.as_ref().trim();
56 if item.is_empty() {
57 continue;
58 }
59 match parse_email_policy_item(item) {
60 Ok(entry) => list.insert(entry),
61 Err(error) => on_invalid(item, &error),
62 }
63 }
64 list
65 }
66
67 pub fn is_empty(&self) -> bool {
69 self.emails.is_empty() && self.domains.is_empty()
70 }
71
72 pub fn matches(&self, email: &str, domain: &str) -> bool {
74 self.emails.contains(email) || self.domains.contains(domain)
75 }
76
77 pub fn entries(&self) -> Vec<String> {
79 self.emails
80 .iter()
81 .chain(self.domains.iter())
82 .cloned()
83 .collect::<BTreeSet<_>>()
84 .into_iter()
85 .collect()
86 }
87
88 fn insert(&mut self, item: EmailPolicyEntry) {
89 match item {
90 EmailPolicyEntry::Email(value) => {
91 self.emails.insert(value);
92 }
93 EmailPolicyEntry::Domain(value) => {
94 self.domains.insert(value);
95 }
96 }
97 }
98}
99
100#[derive(Debug, Clone, PartialEq, Eq)]
102pub enum EmailPolicyEntry {
103 Email(String),
105 Domain(String),
107}
108
109pub fn normalize_email_policy_items<I, S>(items: I) -> Result<Vec<String>>
111where
112 I: IntoIterator<Item = S>,
113 S: AsRef<str>,
114{
115 Ok(EmailPolicyList::from_items(items)?.entries())
116}
117
118pub fn parse_email_policy_item(item: &str) -> Result<EmailPolicyEntry> {
124 if let Some(domain) = item.strip_prefix('@')
125 && !domain.contains('@')
126 {
127 return normalize_email_policy_domain(domain).map(EmailPolicyEntry::Domain);
128 }
129
130 if item.contains('@') {
131 return normalize_email_policy_email(item).map(EmailPolicyEntry::Email);
132 }
133
134 normalize_email_policy_domain(item).map(EmailPolicyEntry::Domain)
135}
136
137pub fn normalize_email_policy_email(email: &str) -> Result<String> {
139 let normalized = normalize_email(email)?;
140 Ok(normalized.to_ascii_lowercase())
141}
142
143pub fn normalize_email_policy_domain(domain: &str) -> Result<String> {
145 let normalized = domain.trim().trim_start_matches('@').to_ascii_lowercase();
146 if normalized.is_empty()
147 || normalized.len() > 253
148 || normalized.contains('@')
149 || !normalized.contains('.')
150 || normalized.starts_with('.')
151 || normalized.ends_with('.')
152 || normalized.contains("..")
153 {
154 return Err(ValidationError::new(format!(
155 "invalid email policy domain '{domain}'"
156 )));
157 }
158
159 if !normalized.split('.').all(|label| {
160 !label.is_empty() && label.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
161 }) {
162 return Err(ValidationError::new(format!(
163 "invalid email policy domain '{domain}'"
164 )));
165 }
166
167 Ok(normalized)
168}
169
170pub fn normalized_email_and_domain(email: &str) -> Result<(String, String)> {
172 let normalized = normalize_email_policy_email(email)?;
173 let domain = email_domain(&normalized)?;
174 Ok((normalized, domain))
175}
176
177#[cfg(test)]
178mod tests {
179 use super::{
180 EmailPolicyEntry, EmailPolicyList, normalize_email_policy_domain,
181 normalize_email_policy_items, normalized_email_and_domain, parse_email_policy_item,
182 };
183
184 #[test]
185 fn policy_items_are_trimmed_lowercased_deduplicated_and_sorted() {
186 let normalized = normalize_email_policy_items([
187 " Example.COM ",
188 "alice@Example.com",
189 "example.com",
190 " ALICE@example.COM ",
191 "@Team.Example",
192 ])
193 .unwrap();
194
195 assert_eq!(
196 normalized,
197 vec![
198 "alice@example.com".to_string(),
199 "example.com".to_string(),
200 "team.example".to_string(),
201 ]
202 );
203 }
204
205 #[test]
206 fn policy_item_parser_classifies_emails_and_domains() {
207 assert_eq!(
208 parse_email_policy_item("alice@example.com").unwrap(),
209 EmailPolicyEntry::Email("alice@example.com".to_string())
210 );
211 assert_eq!(
212 parse_email_policy_item("@example.com").unwrap(),
213 EmailPolicyEntry::Domain("example.com".to_string())
214 );
215 assert_eq!(
216 parse_email_policy_item("example.com").unwrap(),
217 EmailPolicyEntry::Domain("example.com".to_string())
218 );
219 }
220
221 #[test]
222 fn invalid_domains_are_rejected() {
223 assert!(normalize_email_policy_domain("localhost").is_err());
224 assert!(normalize_email_policy_domain("用户.中国").is_err());
225 assert_eq!(
226 normalize_email_policy_domain("xn--fiq228c.xn--fiqs8s").unwrap(),
227 "xn--fiq228c.xn--fiqs8s"
228 );
229 }
230
231 #[test]
232 fn policy_list_matches_exact_emails_and_domains() {
233 let list = EmailPolicyList::from_items(["example.com", "alice@other.test", "blocked.test"])
234 .unwrap();
235
236 assert!(list.matches("bob@example.com", "example.com"));
237 assert!(list.matches("alice@other.test", "other.test"));
238 assert!(!list.matches("bob@sub.example.com", "sub.example.com"));
239 }
240
241 #[test]
242 fn lossy_policy_list_skips_invalid_items() {
243 let mut invalid = Vec::new();
244 let list =
245 EmailPolicyList::from_items_lossy(["example.com", "localhost"], |item, error| {
246 invalid.push((item.to_string(), error.to_string()));
247 });
248
249 assert!(list.matches("alice@example.com", "example.com"));
250 assert_eq!(invalid.len(), 1);
251 assert_eq!(invalid[0].0, "localhost");
252 }
253
254 #[test]
255 fn normalized_email_and_domain_returns_exact_match_parts() {
256 assert_eq!(
257 normalized_email_and_domain(" Alice@Example.COM ").unwrap(),
258 ("alice@example.com".to_string(), "example.com".to_string())
259 );
260 }
261}