Skip to main content

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    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    /// Builds a best-effort policy list from raw entries.
43    ///
44    /// Invalid entries are skipped and passed to `on_invalid`, which lets
45    /// runtime readers preserve fail-open startup behavior while still logging
46    /// the ignored item.
47    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    /// Returns whether no emails or domains are configured.
68    pub fn is_empty(&self) -> bool {
69        self.emails.is_empty() && self.domains.is_empty()
70    }
71
72    /// Returns whether `email` or `domain` exactly matches this list.
73    pub fn matches(&self, email: &str, domain: &str) -> bool {
74        self.emails.contains(email) || self.domains.contains(domain)
75    }
76
77    /// Returns normalized entries as a stable sorted vector.
78    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/// Normalized policy entry classified as either an exact email or an exact domain.
101#[derive(Debug, Clone, PartialEq, Eq)]
102pub enum EmailPolicyEntry {
103    /// Exact normalized email address.
104    Email(String),
105    /// Exact normalized email domain.
106    Domain(String),
107}
108
109/// Normalizes and deduplicates raw email policy entries into a stable vector.
110pub 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
118/// Parses one raw policy entry.
119///
120/// Entries containing `@` are treated as exact email addresses unless they start
121/// with a single leading `@`, in which case they are treated as domains. Entries
122/// without `@` are treated as exact domains.
123pub 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
137/// Normalizes an exact email policy entry.
138pub fn normalize_email_policy_email(email: &str) -> Result<String> {
139    let normalized = normalize_email(email)?;
140    Ok(normalized.to_ascii_lowercase())
141}
142
143/// Normalizes an exact email domain policy entry.
144pub 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
170/// Normalizes an email and returns its exact-match domain.
171pub 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}