use std::sync::Arc;
#[derive(Clone, Default)]
pub struct OutboundEmailPolicy {
recipients: Option<Arc<[String]>>,
}
impl OutboundEmailPolicy {
pub fn configured(recipients: Vec<String>) -> Result<Self, String> {
let recipients = recipients
.iter()
.map(|value| {
khive_types::email_address::normalize_email_recipient(value)
.ok_or_else(|| "outbound email policy contains an invalid address".to_string())
})
.collect::<Result<Vec<_>, _>>()?;
Ok(Self {
recipients: Some(recipients.into()),
})
}
pub fn is_configured(&self) -> bool {
self.recipients.is_some()
}
pub fn allows(&self, recipient: &str) -> bool {
self.recipients.as_ref().is_none_or(|recipients| {
khive_types::email_address::normalize_email_recipient(recipient)
.is_some_and(|recipient| recipients.iter().any(|allowed| allowed == &recipient))
})
}
pub fn from_env() -> Result<Self, String> {
fn read(name: &str) -> Result<Option<String>, String> {
match std::env::var(name) {
Ok(value) => Ok(Some(value)),
Err(std::env::VarError::NotPresent) => Ok(None),
Err(std::env::VarError::NotUnicode(_)) => {
Err(format!("{name} must contain valid Unicode text"))
}
}
}
let explicit = read("KHIVE_EMAIL_SEND_ALLOWED_RECIPIENTS")?;
let maintainer = read("KHIVE_EMAIL_MAINTAINER_ADDRESS")?;
Self::resolve(explicit.as_deref(), maintainer.as_deref())
}
fn resolve(explicit: Option<&str>, maintainer: Option<&str>) -> Result<Self, String> {
if let Some(policy) = Self::explicit(explicit)? {
if let Some(raw) = maintainer {
Self::maintainer_primary(raw)?;
}
return Ok(policy);
}
Self::maintainer(maintainer, explicit.is_some())
}
fn explicit(raw: Option<&str>) -> Result<Option<Self>, String> {
let recipients: Vec<String> = raw
.unwrap_or_default()
.split(',')
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_owned)
.collect();
if recipients.is_empty() {
return Ok(None);
}
Self::configured(recipients)
.map(Some)
.map_err(|_| "KHIVE_EMAIL_SEND_ALLOWED_RECIPIENTS contains an invalid address".into())
}
fn maintainer(raw: Option<&str>, explicit_was_set: bool) -> Result<Self, String> {
let Some(raw) = raw else {
if explicit_was_set {
return Err(
"configured outbound email policy must contain at least one address".into(),
);
}
return Ok(Self::default());
};
Self::configured(vec![Self::maintainer_primary(raw)?])
}
fn maintainer_primary(raw: &str) -> Result<String, String> {
let addresses = raw
.split(',')
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| {
khive_types::email_address::normalize_email_recipient(value).ok_or_else(|| {
"KHIVE_EMAIL_MAINTAINER_ADDRESS contains an invalid address".to_string()
})
})
.collect::<Result<Vec<_>, _>>()?;
let Some(primary) = addresses.into_iter().next() else {
return Err("KHIVE_EMAIL_MAINTAINER_ADDRESS must contain at least one address".into());
};
Ok(primary)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn explicit_policy_and_requests_share_maintainer_normalization() {
let policy =
OutboundEmailPolicy::explicit(Some(" , First@Example.com , second@example.com,,"))
.unwrap()
.unwrap();
assert!(policy.allows("First@Example.com"));
assert!(policy.allows("second@example.com"));
assert!(policy.allows("first@example.com"));
assert!(policy.allows(" First@Example.com "));
assert!(policy.allows("First <FIRST@EXAMPLE.COM>"));
assert!(!policy.allows("other@example.com"));
assert!(!policy.allows("not-an-address"));
assert!(policy.is_configured());
assert_eq!(
policy.recipients.as_deref().unwrap(),
["first@example.com", "second@example.com"]
);
assert!(OutboundEmailPolicy::explicit(Some(" , , "))
.unwrap()
.is_none());
assert!(OutboundEmailPolicy::explicit(Some("not-an-address")).is_err());
}
#[test]
fn fallback_validates_all_maintainers_and_allows_only_normalized_primary() {
let policy = OutboundEmailPolicy::maintainer(
Some("Owner <Primary@Example.com>, second@example.com"),
false,
)
.unwrap();
assert!(policy.allows("primary@example.com"));
assert!(policy.allows("Primary@Example.com"));
assert!(policy.allows("Owner <PRIMARY@EXAMPLE.COM>"));
assert!(!policy.allows("second@example.com"));
assert!(
OutboundEmailPolicy::maintainer(Some("primary@example.com,invalid"), false).is_err()
);
assert!(OutboundEmailPolicy::maintainer(Some(" , "), false).is_err());
assert!(OutboundEmailPolicy::maintainer(None, true).is_err());
assert!(
OutboundEmailPolicy::maintainer(Some("primary@example.com"), true)
.unwrap()
.allows("PRIMARY@example.com")
);
assert!(OutboundEmailPolicy::maintainer(None, false)
.unwrap()
.allows("backlog@example.com"));
assert!(!OutboundEmailPolicy::default().is_configured());
let deny_all = OutboundEmailPolicy::configured(vec![]).unwrap();
assert!(deny_all.is_configured());
assert!(!deny_all.allows("backlog@example.com"));
assert!(OutboundEmailPolicy::configured(vec!["invalid".into()]).is_err());
}
#[test]
fn present_maintainer_is_validated_beside_an_explicit_list_and_an_unset_one_is_not() {
let resolve = OutboundEmailPolicy::resolve;
let explicit = Some("allowed@example.com");
for maintainer in [
"",
" , ",
"primary@example.com,invalid",
"invalid,primary@example.com",
] {
assert!(
resolve(explicit, Some(maintainer)).is_err(),
"maintainer {maintainer:?} beside a valid explicit list must be refused"
);
}
let unset = resolve(explicit, None).unwrap();
assert!(unset.is_configured());
assert!(unset.allows("ALLOWED@example.com"));
assert!(!unset.allows("primary@example.com"));
let list = Some("Owner <Primary@Example.com>, second@example.com");
let valid = resolve(explicit, list).unwrap();
assert!(valid.allows("allowed@example.com"));
assert!(!valid.allows("primary@example.com"));
let blank = Some(" , ");
let fallback = resolve(blank, list).unwrap();
assert!(fallback.allows("primary@example.com"));
assert!(!fallback.allows("second@example.com"));
assert!(resolve(blank, None).is_err());
assert!(resolve(Some("not-an-address"), None).is_err());
assert!(resolve(None, Some("primary@example.com,invalid")).is_err());
assert!(!resolve(None, None).unwrap().is_configured());
}
#[test]
fn comparison_folds_ascii_case_only() {
let policy = OutboundEmailPolicy::configured(vec!["kevin@example.com".into()]).unwrap();
assert!(!policy.allows("\u{212A}evin@example.com"));
assert!(policy.allows("KEVIN@Example.COM"));
assert!(policy.allows("kevin@example.com"));
}
#[test]
fn configured_entries_and_requests_share_one_normalization() {
let policy =
OutboundEmailPolicy::configured(vec!["\u{212A}evin@Example.COM".into()]).unwrap();
assert_eq!(
policy.recipients.as_deref().unwrap(),
["\u{212A}evin@example.com"]
);
assert!(policy.allows("\u{212A}evin@EXAMPLE.com"));
assert!(!policy.allows("kevin@example.com"));
let accented =
OutboundEmailPolicy::maintainer(Some("\u{C9}mile@Example.com"), false).unwrap();
assert!(accented.allows("\u{C9}mile@example.COM"));
assert!(!accented.allows("\u{E9}mile@example.com"));
}
#[test]
fn cloned_policy_keeps_the_same_immutable_recipient_set() {
let policy = OutboundEmailPolicy::configured(vec!["allowed@example.com".into()]).unwrap();
let clone = policy.clone();
assert!(Arc::ptr_eq(
policy.recipients.as_ref().unwrap(),
clone.recipients.as_ref().unwrap()
));
}
}