aster_forge_validation/
email_policy.rs

1//! Email allow/block list normalization and matching helpers.
2//!
3//! Product crates still decide which configuration keys contain allowlists or
4//! blocklists, which API error codes to return, and whether an empty allowlist
5//! means "allow everyone" or "deny everyone". This module only owns the shared
6//! mechanics for parsing policy entries, normalizing them, deduplicating them in
7//! a stable order, and testing exact email/domain matches.
8
9use std::collections::BTreeSet;
10
11use crate::email::{email_domain, normalize_email};
12use crate::{Result, ValidationError};
13
14/// Normalized email policy list split into exact email and exact domain sets.
15#[derive(Debug, Clone, Default, PartialEq, Eq)]
16pub struct EmailPolicyList {
17    emails: BTreeSet<String>,
18    domains: BTreeSet<String>,
19}
20
21impl EmailPolicyList {
22    /// Builds a policy list from raw entries.
23    ///
24    /// Blank entries are ignored. Non-blank invalid entries fail the whole
25    /// normalization pass so configuration writes do not silently persist typos.
26    ///
27    /// # Errors
28    ///
29    /// Returns an error when any non-blank item is neither a valid exact email address nor a valid
30    /// exact domain.
31    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    /// Builds a best-effort policy list from raw entries.
48    ///
49    /// Invalid entries are skipped and passed to `on_invalid`, which lets
50    /// runtime readers preserve fail-open startup behavior while still logging
51    /// the ignored item.
52    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    /// Returns whether no emails or domains are configured.
73    #[must_use]
74    pub fn is_empty(&self) -> bool {
75        self.emails.is_empty() && self.domains.is_empty()
76    }
77
78    /// Returns whether `email` or `domain` exactly matches this list.
79    #[must_use]
80    pub fn matches(&self, email: &str, domain: &str) -> bool {
81        self.emails.contains(email) || self.domains.contains(domain)
82    }
83
84    /// Returns normalized entries as a stable sorted vector.
85    #[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/// Normalized policy entry classified as either an exact email or an exact domain.
109#[derive(Debug, Clone, PartialEq, Eq)]
110pub enum EmailPolicyEntry {
111    /// Exact normalized email address.
112    Email(String),
113    /// Exact normalized email domain.
114    Domain(String),
115}
116
117/// Normalizes and deduplicates raw email policy entries into a stable vector.
118///
119/// # Errors
120///
121/// Returns an error when any non-blank entry is invalid.
122pub 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
130/// Parses one raw policy entry.
131///
132/// Entries containing `@` are treated as exact email addresses unless they start
133/// with a single leading `@`, in which case they are treated as domains. Entries
134/// without `@` are treated as exact domains.
135///
136/// # Errors
137///
138/// Returns an error when the selected email or domain normalizer rejects the entry.
139pub 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
153/// Normalizes an exact email policy entry.
154///
155/// # Errors
156///
157/// Returns an error when `email` fails the shared lightweight email validation rules.
158pub fn normalize_email_policy_email(email: &str) -> Result<String> {
159    let normalized = normalize_email(email)?;
160    Ok(normalized.to_ascii_lowercase())
161}
162
163/// Normalizes an exact email domain policy entry.
164///
165/// # Errors
166///
167/// Returns an error when the domain is empty, too long, non-ASCII, structurally malformed, or
168/// contains characters other than ASCII letters, digits, hyphens, and dots.
169pub 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
195/// Normalizes an email and returns its exact-match domain.
196///
197/// # Errors
198///
199/// Returns an error when the email or its extracted domain fails normalization.
200pub 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}