Skip to main content

khive_runtime/
outbound_email_policy.rs

1use std::sync::Arc;
2
3/// Boot-resolved recipient policy shared by comm admission and email delivery.
4/// An absent policy permits queuing without claiming that delivery is configured.
5#[derive(Clone, Default)]
6pub struct OutboundEmailPolicy {
7    recipients: Option<Arc<[String]>>,
8}
9
10impl OutboundEmailPolicy {
11    /// Install a normalized recipient set; an explicitly empty set denies all recipients.
12    pub fn configured(recipients: Vec<String>) -> Result<Self, String> {
13        let recipients = recipients
14            .iter()
15            .map(|value| {
16                khive_types::email_address::normalize_email_recipient(value)
17                    .ok_or_else(|| "outbound email policy contains an invalid address".to_string())
18            })
19            .collect::<Result<Vec<_>, _>>()?;
20        Ok(Self {
21            recipients: Some(recipients.into()),
22        })
23    }
24
25    pub fn is_configured(&self) -> bool {
26        self.recipients.is_some()
27    }
28
29    pub fn allows(&self, recipient: &str) -> bool {
30        self.recipients.as_ref().is_none_or(|recipients| {
31            khive_types::email_address::normalize_email_recipient(recipient)
32                .is_some_and(|recipient| recipients.iter().any(|allowed| allowed == &recipient))
33        })
34    }
35
36    /// Read only public policy inputs, without loading a connector or credentials.
37    pub fn from_env() -> Result<Self, String> {
38        fn read(name: &str) -> Result<Option<String>, String> {
39            match std::env::var(name) {
40                Ok(value) => Ok(Some(value)),
41                Err(std::env::VarError::NotPresent) => Ok(None),
42                Err(std::env::VarError::NotUnicode(_)) => {
43                    Err(format!("{name} must contain valid Unicode text"))
44                }
45            }
46        }
47        let explicit = read("KHIVE_EMAIL_SEND_ALLOWED_RECIPIENTS")?;
48        let maintainer = read("KHIVE_EMAIL_MAINTAINER_ADDRESS")?;
49        Self::resolve(explicit.as_deref(), maintainer.as_deref())
50    }
51
52    fn resolve(explicit: Option<&str>, maintainer: Option<&str>) -> Result<Self, String> {
53        if let Some(policy) = Self::explicit(explicit)? {
54            // A present maintainer value is read the way the email connector reads it at
55            // start, so a value the connector would refuse is refused here too, even
56            // when the explicit list supplies the recipients. An unset one is not an error.
57            if let Some(raw) = maintainer {
58                Self::maintainer_primary(raw)?;
59            }
60            return Ok(policy);
61        }
62        Self::maintainer(maintainer, explicit.is_some())
63    }
64
65    fn explicit(raw: Option<&str>) -> Result<Option<Self>, String> {
66        let recipients: Vec<String> = raw
67            .unwrap_or_default()
68            .split(',')
69            .map(str::trim)
70            .filter(|value| !value.is_empty())
71            .map(str::to_owned)
72            .collect();
73        if recipients.is_empty() {
74            return Ok(None);
75        }
76        Self::configured(recipients)
77            .map(Some)
78            .map_err(|_| "KHIVE_EMAIL_SEND_ALLOWED_RECIPIENTS contains an invalid address".into())
79    }
80
81    fn maintainer(raw: Option<&str>, explicit_was_set: bool) -> Result<Self, String> {
82        let Some(raw) = raw else {
83            if explicit_was_set {
84                return Err(
85                    "configured outbound email policy must contain at least one address".into(),
86                );
87            }
88            return Ok(Self::default());
89        };
90        Self::configured(vec![Self::maintainer_primary(raw)?])
91    }
92
93    /// Validate a present maintainer value and return its first (primary) address:
94    /// split on commas, trim, drop empty values, every remaining value must parse
95    /// and at least one must remain.
96    fn maintainer_primary(raw: &str) -> Result<String, String> {
97        let addresses = raw
98            .split(',')
99            .map(str::trim)
100            .filter(|value| !value.is_empty())
101            .map(|value| {
102                khive_types::email_address::normalize_email_recipient(value).ok_or_else(|| {
103                    "KHIVE_EMAIL_MAINTAINER_ADDRESS contains an invalid address".to_string()
104                })
105            })
106            .collect::<Result<Vec<_>, _>>()?;
107        let Some(primary) = addresses.into_iter().next() else {
108            return Err("KHIVE_EMAIL_MAINTAINER_ADDRESS must contain at least one address".into());
109        };
110        Ok(primary)
111    }
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117
118    #[test]
119    fn explicit_policy_and_requests_share_maintainer_normalization() {
120        let policy =
121            OutboundEmailPolicy::explicit(Some(" , First@Example.com , second@example.com,,"))
122                .unwrap()
123                .unwrap();
124        assert!(policy.allows("First@Example.com"));
125        assert!(policy.allows("second@example.com"));
126        assert!(policy.allows("first@example.com"));
127        assert!(policy.allows(" First@Example.com "));
128        assert!(policy.allows("First <FIRST@EXAMPLE.COM>"));
129        assert!(!policy.allows("other@example.com"));
130        assert!(!policy.allows("not-an-address"));
131        assert!(policy.is_configured());
132        assert_eq!(
133            policy.recipients.as_deref().unwrap(),
134            ["first@example.com", "second@example.com"]
135        );
136        assert!(OutboundEmailPolicy::explicit(Some(" , , "))
137            .unwrap()
138            .is_none());
139        assert!(OutboundEmailPolicy::explicit(Some("not-an-address")).is_err());
140    }
141
142    #[test]
143    fn fallback_validates_all_maintainers_and_allows_only_normalized_primary() {
144        let policy = OutboundEmailPolicy::maintainer(
145            Some("Owner <Primary@Example.com>, second@example.com"),
146            false,
147        )
148        .unwrap();
149        assert!(policy.allows("primary@example.com"));
150        assert!(policy.allows("Primary@Example.com"));
151        assert!(policy.allows("Owner <PRIMARY@EXAMPLE.COM>"));
152        assert!(!policy.allows("second@example.com"));
153        assert!(
154            OutboundEmailPolicy::maintainer(Some("primary@example.com,invalid"), false).is_err()
155        );
156        assert!(OutboundEmailPolicy::maintainer(Some(" , "), false).is_err());
157        assert!(OutboundEmailPolicy::maintainer(None, true).is_err());
158        assert!(
159            OutboundEmailPolicy::maintainer(Some("primary@example.com"), true)
160                .unwrap()
161                .allows("PRIMARY@example.com")
162        );
163        assert!(OutboundEmailPolicy::maintainer(None, false)
164            .unwrap()
165            .allows("backlog@example.com"));
166        assert!(!OutboundEmailPolicy::default().is_configured());
167        let deny_all = OutboundEmailPolicy::configured(vec![]).unwrap();
168        assert!(deny_all.is_configured());
169        assert!(!deny_all.allows("backlog@example.com"));
170        assert!(OutboundEmailPolicy::configured(vec!["invalid".into()]).is_err());
171    }
172
173    #[test]
174    fn present_maintainer_is_validated_beside_an_explicit_list_and_an_unset_one_is_not() {
175        let resolve = OutboundEmailPolicy::resolve;
176        let explicit = Some("allowed@example.com");
177        for maintainer in [
178            "",
179            " , ",
180            "primary@example.com,invalid",
181            "invalid,primary@example.com",
182        ] {
183            assert!(
184                resolve(explicit, Some(maintainer)).is_err(),
185                "maintainer {maintainer:?} beside a valid explicit list must be refused"
186            );
187        }
188        let unset = resolve(explicit, None).unwrap();
189        assert!(unset.is_configured());
190        assert!(unset.allows("ALLOWED@example.com"));
191        assert!(!unset.allows("primary@example.com"));
192        let list = Some("Owner <Primary@Example.com>, second@example.com");
193        let valid = resolve(explicit, list).unwrap();
194        assert!(valid.allows("allowed@example.com"));
195        assert!(!valid.allows("primary@example.com"));
196        let blank = Some(" , ");
197        let fallback = resolve(blank, list).unwrap();
198        assert!(fallback.allows("primary@example.com"));
199        assert!(!fallback.allows("second@example.com"));
200        assert!(resolve(blank, None).is_err());
201        assert!(resolve(Some("not-an-address"), None).is_err());
202        assert!(resolve(None, Some("primary@example.com,invalid")).is_err());
203        assert!(!resolve(None, None).unwrap().is_configured());
204    }
205
206    #[test]
207    fn comparison_folds_ascii_case_only() {
208        let policy = OutboundEmailPolicy::configured(vec!["kevin@example.com".into()]).unwrap();
209        // U+212A KELVIN SIGN lowercases to ASCII `k` under Unicode rules; it is a different
210        // address.
211        assert!(!policy.allows("\u{212A}evin@example.com"));
212        assert!(policy.allows("KEVIN@Example.COM"));
213        assert!(policy.allows("kevin@example.com"));
214    }
215
216    #[test]
217    fn configured_entries_and_requests_share_one_normalization() {
218        // The entry and the request pass through the same function, so a non-ASCII letter in
219        // an entry matches only its exact spelling while ASCII case still folds on both sides.
220        let policy =
221            OutboundEmailPolicy::configured(vec!["\u{212A}evin@Example.COM".into()]).unwrap();
222        assert_eq!(
223            policy.recipients.as_deref().unwrap(),
224            ["\u{212A}evin@example.com"]
225        );
226        assert!(policy.allows("\u{212A}evin@EXAMPLE.com"));
227        assert!(!policy.allows("kevin@example.com"));
228        let accented =
229            OutboundEmailPolicy::maintainer(Some("\u{C9}mile@Example.com"), false).unwrap();
230        assert!(accented.allows("\u{C9}mile@example.COM"));
231        assert!(!accented.allows("\u{E9}mile@example.com"));
232    }
233
234    #[test]
235    fn cloned_policy_keeps_the_same_immutable_recipient_set() {
236        let policy = OutboundEmailPolicy::configured(vec!["allowed@example.com".into()]).unwrap();
237        let clone = policy.clone();
238        assert!(Arc::ptr_eq(
239            policy.recipients.as_ref().unwrap(),
240            clone.recipients.as_ref().unwrap()
241        ));
242    }
243}