khive_runtime/
outbound_email_policy.rs1use std::sync::Arc;
2
3#[derive(Clone, Default)]
6pub struct OutboundEmailPolicy {
7 recipients: Option<Arc<[String]>>,
8}
9
10impl OutboundEmailPolicy {
11 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 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 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 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 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 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}