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>
32 where
33 I: IntoIterator<Item = S>,
34 S: AsRef<str>,
35 {
36 let mut list = Self::default();
37 for item in items {
38 let item = item.as_ref().trim();
39 if item.is_empty() {
40 continue;
41 }
42 list.insert(parse_email_policy_item(item)?);
43 }
44 Ok(list)
45 }
46
47 pub fn from_items_lossy<I, S, F>(items: I, mut on_invalid: F) -> Self
53 where
54 I: IntoIterator<Item = S>,
55 S: AsRef<str>,
56 F: FnMut(&str, &ValidationError),
57 {
58 let mut list = Self::default();
59 for item in items {
60 let item = item.as_ref().trim();
61 if item.is_empty() {
62 continue;
63 }
64 match parse_email_policy_item(item) {
65 Ok(entry) => list.insert(entry),
66 Err(error) => on_invalid(item, &error),
67 }
68 }
69 list
70 }
71
72 #[must_use]
74 pub fn is_empty(&self) -> bool {
75 self.emails.is_empty() && self.domains.is_empty()
76 }
77
78 #[must_use]
80 pub fn matches(&self, email: &str, domain: &str) -> bool {
81 self.emails.contains(email) || self.domains.contains(domain)
82 }
83
84 #[must_use]
86 pub fn entries(&self) -> Vec<String> {
87 self.emails
88 .iter()
89 .chain(self.domains.iter())
90 .cloned()
91 .collect::<BTreeSet<_>>()
92 .into_iter()
93 .collect()
94 }
95
96 fn insert(&mut self, item: EmailPolicyEntry) {
97 match item {
98 EmailPolicyEntry::Email(value) => {
99 self.emails.insert(value);
100 }
101 EmailPolicyEntry::Domain(value) => {
102 self.domains.insert(value);
103 }
104 }
105 }
106}
107
108#[derive(Debug, Clone, PartialEq, Eq)]
110pub enum EmailPolicyEntry {
111 Email(String),
113 Domain(String),
115}
116
117pub fn normalize_email_policy_items<I, S>(items: I) -> Result<Vec<String>>
123where
124 I: IntoIterator<Item = S>,
125 S: AsRef<str>,
126{
127 Ok(EmailPolicyList::from_items(items)?.entries())
128}
129
130pub fn parse_email_policy_item(item: &str) -> Result<EmailPolicyEntry> {
140 if let Some(domain) = item.strip_prefix('@')
141 && !domain.contains('@')
142 {
143 return normalize_email_policy_domain(domain).map(EmailPolicyEntry::Domain);
144 }
145
146 if item.contains('@') {
147 return normalize_email_policy_email(item).map(EmailPolicyEntry::Email);
148 }
149
150 normalize_email_policy_domain(item).map(EmailPolicyEntry::Domain)
151}
152
153pub fn normalize_email_policy_email(email: &str) -> Result<String> {
159 let normalized = normalize_email(email)?;
160 Ok(normalized.to_ascii_lowercase())
161}
162
163pub fn normalize_email_policy_domain(domain: &str) -> Result<String> {
170 let normalized = domain.trim().trim_start_matches('@').to_ascii_lowercase();
171 if normalized.is_empty()
172 || normalized.len() > 253
173 || normalized.contains('@')
174 || !normalized.contains('.')
175 || normalized.starts_with('.')
176 || normalized.ends_with('.')
177 || normalized.contains("..")
178 {
179 return Err(ValidationError::new(format!(
180 "invalid email policy domain '{domain}'"
181 )));
182 }
183
184 if !normalized.split('.').all(|label| {
185 !label.is_empty() && label.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
186 }) {
187 return Err(ValidationError::new(format!(
188 "invalid email policy domain '{domain}'"
189 )));
190 }
191
192 Ok(normalized)
193}
194
195pub fn normalized_email_and_domain(email: &str) -> Result<(String, String)> {
201 let normalized = normalize_email_policy_email(email)?;
202 let domain = email_domain(&normalized)?;
203 Ok((normalized, domain))
204}
205
206#[cfg(test)]
207mod tests {
208 use super::{
209 EmailPolicyEntry, EmailPolicyList, normalize_email_policy_domain,
210 normalize_email_policy_items, normalized_email_and_domain, parse_email_policy_item,
211 };
212
213 #[test]
214 fn policy_items_are_trimmed_lowercased_deduplicated_and_sorted() {
215 let normalized = normalize_email_policy_items([
216 " Example.COM ",
217 "alice@Example.com",
218 "example.com",
219 " ALICE@example.COM ",
220 "@Team.Example",
221 ])
222 .unwrap();
223
224 assert_eq!(
225 normalized,
226 vec![
227 "alice@example.com".to_string(),
228 "example.com".to_string(),
229 "team.example".to_string(),
230 ]
231 );
232 }
233
234 #[test]
235 fn policy_item_parser_classifies_emails_and_domains() {
236 assert_eq!(
237 parse_email_policy_item("alice@example.com").unwrap(),
238 EmailPolicyEntry::Email("alice@example.com".to_string())
239 );
240 assert_eq!(
241 parse_email_policy_item("@example.com").unwrap(),
242 EmailPolicyEntry::Domain("example.com".to_string())
243 );
244 assert_eq!(
245 parse_email_policy_item("example.com").unwrap(),
246 EmailPolicyEntry::Domain("example.com".to_string())
247 );
248 }
249
250 #[test]
251 fn invalid_domains_are_rejected() {
252 assert!(normalize_email_policy_domain("localhost").is_err());
253 assert!(normalize_email_policy_domain("用户.中国").is_err());
254 assert_eq!(
255 normalize_email_policy_domain("xn--fiq228c.xn--fiqs8s").unwrap(),
256 "xn--fiq228c.xn--fiqs8s"
257 );
258 }
259
260 #[test]
261 fn policy_list_matches_exact_emails_and_domains() {
262 let list = EmailPolicyList::from_items(["example.com", "alice@other.test", "blocked.test"])
263 .unwrap();
264
265 assert!(list.matches("bob@example.com", "example.com"));
266 assert!(list.matches("alice@other.test", "other.test"));
267 assert!(!list.matches("bob@sub.example.com", "sub.example.com"));
268 }
269
270 #[test]
271 fn lossy_policy_list_skips_invalid_items() {
272 let mut invalid = Vec::new();
273 let list =
274 EmailPolicyList::from_items_lossy(["example.com", "localhost"], |item, error| {
275 invalid.push((item.to_string(), error.to_string()));
276 });
277
278 assert!(list.matches("alice@example.com", "example.com"));
279 assert_eq!(invalid.len(), 1);
280 assert_eq!(invalid[0].0, "localhost");
281 }
282
283 #[test]
284 fn normalized_email_and_domain_returns_exact_match_parts() {
285 assert_eq!(
286 normalized_email_and_domain(" Alice@Example.COM ").unwrap(),
287 ("alice@example.com".to_string(), "example.com".to_string())
288 );
289 }
290}