1use std::collections::BTreeMap;
2use std::sync::Arc;
3
4use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
5use sha2::{Digest, Sha256};
6
7const UNSUBSCRIBE_TOKEN_VERSION: u8 = 0x01;
9const RECIPIENT_KEY_BYTES: usize = 32;
11const UNSUBSCRIBE_TAG_BYTES: usize = 16;
13const UNSUBSCRIBE_TOKEN_BYTES: usize = 1 + 1 + RECIPIENT_KEY_BYTES + UNSUBSCRIBE_TAG_BYTES;
15const UNSUBSCRIBE_PLACEHOLDER: &str = "{unsubscribe}";
17const MIN_KEY_BYTES: usize = 32;
19
20#[derive(Clone)]
23pub struct OutboundEmailPolicy {
24 recipients: Option<Arc<[String]>>,
25 external_senders: Option<Arc<[String]>>,
26 external_daily_cap: Option<u64>,
27 recipient_key_secret: Option<Arc<[u8]>>,
30 token_keys: Arc<BTreeMap<u8, Arc<[u8]>>>,
33 external_footer: Option<Result<Arc<str>, Arc<str>>>,
36 external_admission: Arc<tokio::sync::Mutex<()>>,
37}
38
39impl Default for OutboundEmailPolicy {
40 fn default() -> Self {
41 Self {
42 recipients: None,
43 external_senders: None,
44 external_daily_cap: None,
45 recipient_key_secret: None,
46 token_keys: Arc::default(),
47 external_footer: None,
48 external_admission: Arc::new(tokio::sync::Mutex::new(())),
49 }
50 }
51}
52
53fn hmac_sha256(key: &[u8], data: &[u8]) -> [u8; 32] {
58 const BLOCK: usize = 64;
59 let mut block = [0u8; BLOCK];
60 if key.len() > BLOCK {
61 block[..32].copy_from_slice(&Sha256::digest(key));
62 } else {
63 block[..key.len()].copy_from_slice(key);
64 }
65 let mut inner_pad = [0x36u8; BLOCK];
66 let mut outer_pad = [0x5cu8; BLOCK];
67 for index in 0..BLOCK {
68 inner_pad[index] ^= block[index];
69 outer_pad[index] ^= block[index];
70 }
71 let mut inner = Sha256::new();
72 inner.update(inner_pad);
73 inner.update(data);
74 let inner_digest = inner.finalize();
75 let mut outer = Sha256::new();
76 outer.update(outer_pad);
77 outer.update(inner_digest);
78 let mut mac = [0u8; 32];
79 mac.copy_from_slice(&outer.finalize());
80 mac
81}
82
83#[derive(Clone, Copy, Debug, PartialEq, Eq)]
85pub enum EmailSuppressionScope {
86 Outbound,
87 Inbound,
88}
89
90#[derive(Clone, Copy, Debug, PartialEq, Eq)]
92pub enum EmailSuppressionKind {
93 Address,
94 Domain,
95 RecipientKey,
96}
97
98#[derive(Clone, Debug, PartialEq, Eq)]
100pub struct EmailSuppression {
101 pub scope: EmailSuppressionScope,
102 pub kind: EmailSuppressionKind,
103 pub value: String,
104}
105
106pub fn outbound_email_is_suppressed(
112 normalized_recipient: &str,
113 recipient_key: Option<&str>,
114 entries: &[EmailSuppression],
115) -> bool {
116 let recipient_domain = normalized_recipient
117 .rsplit_once('@')
118 .map(|(_, domain)| domain);
119 entries.iter().any(|entry| {
120 if entry.scope != EmailSuppressionScope::Outbound {
121 return false;
122 }
123 match entry.kind {
124 EmailSuppressionKind::Address => entry.value == normalized_recipient,
125 EmailSuppressionKind::Domain => recipient_domain == Some(entry.value.as_str()),
126 EmailSuppressionKind::RecipientKey => recipient_key == Some(entry.value.as_str()),
127 }
128 })
129}
130
131pub fn inbound_email_is_suppressed(normalized_sender: &str, entries: &[EmailSuppression]) -> bool {
140 let sender_domain = normalized_sender.rsplit_once('@').map(|(_, domain)| domain);
141 entries.iter().any(|entry| {
142 if entry.scope != EmailSuppressionScope::Inbound {
143 return false;
144 }
145 match entry.kind {
146 EmailSuppressionKind::Address => entry.value == normalized_sender,
147 EmailSuppressionKind::Domain => sender_domain == Some(entry.value.as_str()),
148 EmailSuppressionKind::RecipientKey => false,
149 }
150 })
151}
152
153impl OutboundEmailPolicy {
154 pub fn configured(recipients: Vec<String>) -> Result<Self, String> {
156 let recipients = recipients
157 .iter()
158 .map(|value| {
159 khive_types::email_address::normalize_email_recipient(value)
160 .ok_or_else(|| "outbound email policy contains an invalid address".to_string())
161 })
162 .collect::<Result<Vec<_>, _>>()?;
163 Ok(Self {
164 recipients: Some(recipients.into()),
165 ..Self::default()
166 })
167 }
168
169 pub fn is_configured(&self) -> bool {
170 self.recipients.is_some()
171 }
172
173 pub fn allows(&self, recipient: &str) -> bool {
174 self.recipients.as_ref().is_none_or(|recipients| {
175 khive_types::email_address::normalize_email_recipient(recipient)
176 .is_some_and(|recipient| recipients.iter().any(|allowed| allowed == &recipient))
177 })
178 }
179
180 pub fn allows_external_sender(&self, actor_id: &str) -> bool {
181 self.external_senders
182 .as_ref()
183 .is_some_and(|senders| senders.iter().any(|sender| sender == actor_id))
184 }
185
186 pub fn has_external_senders(&self) -> bool {
187 self.external_senders.is_some()
188 }
189
190 pub fn external_daily_cap(&self) -> Option<u64> {
191 self.external_daily_cap
192 }
193
194 pub async fn lock_external_admission(&self) -> tokio::sync::OwnedMutexGuard<()> {
195 Arc::clone(&self.external_admission).lock_owned().await
196 }
197
198 pub fn recipient_key(&self, normalized_recipient: &str) -> Option<String> {
202 self.recipient_key_secret
203 .as_deref()
204 .map(|secret| hex_lower(&hmac_sha256(secret, normalized_recipient.as_bytes())))
205 }
206
207 fn recipient_key_bytes(&self, normalized_recipient: &str) -> Option<[u8; RECIPIENT_KEY_BYTES]> {
209 self.recipient_key_secret
210 .as_deref()
211 .map(|secret| hmac_sha256(secret, normalized_recipient.as_bytes()))
212 }
213
214 pub fn external_footer(&self) -> Option<Result<&str, &str>> {
217 self.external_footer.as_ref().map(|value| {
218 value
219 .as_ref()
220 .map(|text| &**text)
221 .map_err(|reason| &**reason)
222 })
223 }
224
225 pub fn mint_unsubscribe_token(&self, normalized_recipient: &str) -> Option<String> {
228 let recipient_key = self.recipient_key_bytes(normalized_recipient)?;
229 let (&key_id, key) = self.token_keys.iter().next_back()?;
230 Some(encode_unsubscribe_token(key_id, key, &recipient_key))
231 }
232
233 pub fn verify_unsubscribe_token(&self, token: &str) -> Option<String> {
236 let decoded = URL_SAFE_NO_PAD.decode(token).ok()?;
237 if decoded.len() != UNSUBSCRIBE_TOKEN_BYTES {
238 return None;
239 }
240 if decoded[0] != UNSUBSCRIBE_TOKEN_VERSION {
241 return None;
242 }
243 let key_id = decoded[1];
244 let key = self.token_keys.get(&key_id)?;
245 let (body, tag) = decoded.split_at(decoded.len() - UNSUBSCRIBE_TAG_BYTES);
246 let expected = hmac_sha256(key, body);
247 if !constant_time_eq(&expected[..UNSUBSCRIBE_TAG_BYTES], tag) {
248 return None;
249 }
250 let recipient_key = &body[2..];
251 Some(hex_lower(recipient_key))
252 }
253
254 pub fn from_env() -> Result<Self, String> {
259 fn read(name: &str) -> Result<Option<String>, String> {
260 match std::env::var(name) {
261 Ok(value) => Ok(Some(value)),
262 Err(std::env::VarError::NotPresent) => Ok(None),
263 Err(std::env::VarError::NotUnicode(_)) => {
264 Err(format!("{name} must contain valid Unicode text"))
265 }
266 }
267 }
268 let external_senders = read("KHIVE_EMAIL_EXTERNAL_SENDERS")?;
269 let external_daily_cap = read("KHIVE_EMAIL_EXTERNAL_DAILY_CAP")?;
270 let explicit = read("KHIVE_EMAIL_SEND_ALLOWED_RECIPIENTS")?;
271 let maintainer = read("KHIVE_EMAIL_MAINTAINER_ADDRESS")?;
272 let (recipient_key_secret, token_keys) = if external_senders.is_some() {
273 (
274 read("KHIVE_EMAIL_UNSUBSCRIBE_RK_KEY")?,
275 read("KHIVE_EMAIL_UNSUBSCRIBE_TOKEN_KEYS")?,
276 )
277 } else {
278 (None, None)
279 };
280 let footer_file = if external_senders.is_some() {
281 read("KHIVE_EMAIL_EXTERNAL_FOOTER_FILE")?
282 } else {
283 None
284 };
285 Self::resolve_with_external(
286 explicit.as_deref(),
287 maintainer.as_deref(),
288 external_senders.as_deref(),
289 external_daily_cap.as_deref(),
290 recipient_key_secret.as_deref(),
291 token_keys.as_deref(),
292 footer_file.as_deref(),
293 )
294 }
295
296 #[cfg(test)]
297 fn resolve(explicit: Option<&str>, maintainer: Option<&str>) -> Result<Self, String> {
298 Self::resolve_with_external(explicit, maintainer, None, None, None, None, None)
299 }
300
301 #[cfg(any(test, debug_assertions))]
304 #[doc(hidden)]
305 pub fn configured_for_tests(
306 recipients: Vec<String>,
307 external_senders: Vec<String>,
308 daily_cap: u64,
309 ) -> Result<Self, String> {
310 let token_keys = format!("0:{},1:{}", "11".repeat(32), "22".repeat(32));
311 Self::configured_for_tests_with(
312 recipients,
313 external_senders,
314 daily_cap,
315 &"cc".repeat(32),
316 &token_keys,
317 Ok("Reply to unsubscribe: {unsubscribe}"),
318 )
319 }
320
321 #[cfg(any(test, debug_assertions))]
324 #[doc(hidden)]
325 #[allow(clippy::too_many_arguments)]
326 pub fn configured_for_tests_with(
327 recipients: Vec<String>,
328 external_senders: Vec<String>,
329 daily_cap: u64,
330 recipient_key_secret_hex: &str,
331 token_keys: &str,
332 footer: Result<&str, &str>,
333 ) -> Result<Self, String> {
334 if daily_cap == 0 {
335 return Err("KHIVE_EMAIL_EXTERNAL_DAILY_CAP must be a positive integer".into());
336 }
337 let external_senders = parse_external_senders(&external_senders.join(","))?;
338 if external_senders.is_empty() {
339 return Err("KHIVE_EMAIL_EXTERNAL_SENDERS must contain at least one actor id".into());
340 }
341 let mut policy = Self::configured(recipients)?;
342 policy.external_senders = Some(external_senders.into());
343 policy.external_daily_cap = Some(daily_cap);
344 let secret = parse_hex_key("KHIVE_EMAIL_UNSUBSCRIBE_RK_KEY", recipient_key_secret_hex)?;
345 policy.recipient_key_secret = Some(Arc::from(secret.as_slice()));
346 policy.token_keys = Arc::new(parse_token_keys(token_keys)?);
347 policy.external_footer = Some(footer.map(Arc::from).map_err(Arc::from));
348 Ok(policy)
349 }
350
351 #[cfg(any(test, debug_assertions))]
353 #[doc(hidden)]
354 pub fn with_external_footer_for_tests(mut self, footer: Result<&str, &str>) -> Self {
355 self.external_footer = Some(footer.map(Arc::from).map_err(Arc::from));
356 self
357 }
358
359 #[allow(clippy::too_many_arguments)]
360 fn resolve_with_external(
361 explicit: Option<&str>,
362 maintainer: Option<&str>,
363 external_senders: Option<&str>,
364 external_daily_cap: Option<&str>,
365 recipient_key_secret: Option<&str>,
366 token_keys: Option<&str>,
367 footer_file: Option<&str>,
368 ) -> Result<Self, String> {
369 let senders = external_senders.map(parse_external_senders).transpose()?;
370 let cap = match external_daily_cap {
371 Some(value) => {
372 let parsed = value.parse::<u64>().map_err(|_| {
373 "KHIVE_EMAIL_EXTERNAL_DAILY_CAP must be a positive integer".to_string()
374 })?;
375 if parsed == 0 {
376 return Err("KHIVE_EMAIL_EXTERNAL_DAILY_CAP must be a positive integer".into());
377 }
378 Some(parsed)
379 }
380 None if senders.is_some() => {
381 return Err("KHIVE_EMAIL_EXTERNAL_DAILY_CAP is required when KHIVE_EMAIL_EXTERNAL_SENDERS is set".into());
382 }
383 None => None,
384 };
385 let mut policy = Self::resolve_recipients(explicit, maintainer)?;
386 policy.external_daily_cap = cap;
387 if let Some(senders) = senders {
388 policy.external_senders = Some(senders.into());
389 let recipient_key_secret = parse_hex_key(
390 "KHIVE_EMAIL_UNSUBSCRIBE_RK_KEY",
391 recipient_key_secret.ok_or_else(|| {
392 "KHIVE_EMAIL_UNSUBSCRIBE_RK_KEY is required when KHIVE_EMAIL_EXTERNAL_SENDERS is set".to_string()
393 })?,
394 )?;
395 let token_keys = parse_token_keys(token_keys.ok_or_else(|| {
396 "KHIVE_EMAIL_UNSUBSCRIBE_TOKEN_KEYS is required when KHIVE_EMAIL_EXTERNAL_SENDERS is set".to_string()
397 })?)?;
398 policy.recipient_key_secret = Some(Arc::from(recipient_key_secret.as_slice()));
399 policy.token_keys = Arc::new(token_keys);
400 policy.external_footer = Some(read_external_footer(footer_file));
401 }
402 Ok(policy)
403 }
404
405 fn resolve_recipients(
406 explicit: Option<&str>,
407 maintainer: Option<&str>,
408 ) -> Result<Self, String> {
409 if let Some(policy) = Self::explicit(explicit)? {
410 if let Some(raw) = maintainer {
414 Self::maintainer_primary(raw)?;
415 }
416 return Ok(policy);
417 }
418 Self::maintainer(maintainer, explicit.is_some())
419 }
420
421 fn explicit(raw: Option<&str>) -> Result<Option<Self>, String> {
422 let recipients: Vec<String> = raw
423 .unwrap_or_default()
424 .split(',')
425 .map(str::trim)
426 .filter(|value| !value.is_empty())
427 .map(str::to_owned)
428 .collect();
429 if recipients.is_empty() {
430 return Ok(None);
431 }
432 Self::configured(recipients)
433 .map(Some)
434 .map_err(|_| "KHIVE_EMAIL_SEND_ALLOWED_RECIPIENTS contains an invalid address".into())
435 }
436
437 fn maintainer(raw: Option<&str>, explicit_was_set: bool) -> Result<Self, String> {
438 let Some(raw) = raw else {
439 if explicit_was_set {
440 return Err(
441 "configured outbound email policy must contain at least one address".into(),
442 );
443 }
444 return Ok(Self::default());
445 };
446 Self::configured(vec![Self::maintainer_primary(raw)?])
447 }
448
449 fn maintainer_primary(raw: &str) -> Result<String, String> {
453 let addresses = raw
454 .split(',')
455 .map(str::trim)
456 .filter(|value| !value.is_empty())
457 .map(|value| {
458 khive_types::email_address::normalize_email_recipient(value).ok_or_else(|| {
459 "KHIVE_EMAIL_MAINTAINER_ADDRESS contains an invalid address".to_string()
460 })
461 })
462 .collect::<Result<Vec<_>, _>>()?;
463 let Some(primary) = addresses.into_iter().next() else {
464 return Err("KHIVE_EMAIL_MAINTAINER_ADDRESS must contain at least one address".into());
465 };
466 Ok(primary)
467 }
468}
469
470fn parse_external_senders(raw: &str) -> Result<Vec<String>, String> {
471 raw.split(',')
472 .map(str::trim)
473 .map(|actor_id| {
474 if actor_id.is_empty() {
475 return Err("KHIVE_EMAIL_EXTERNAL_SENDERS contains an empty actor id".to_string());
476 }
477 khive_types::Namespace::parse(actor_id).map_err(|_| {
478 format!("KHIVE_EMAIL_EXTERNAL_SENDERS contains an invalid actor id {actor_id:?}")
479 })?;
480 Ok(actor_id.to_string())
481 })
482 .collect()
483}
484
485fn parse_hex_key(name: &str, raw: &str) -> Result<Vec<u8>, String> {
488 let value = raw.trim().to_ascii_lowercase();
489 if !value.len().is_multiple_of(2) || !khive_types::is_lowercase_hex(&value) {
490 return Err(format!(
491 "{name} must be hex-encoded key material of at least {MIN_KEY_BYTES} bytes"
492 ));
493 }
494 let mut bytes = Vec::with_capacity(value.len() / 2);
495 for pair in value.as_bytes().chunks_exact(2) {
496 let high = (pair[0] as char)
497 .to_digit(16)
498 .expect("validated lowercase hex");
499 let low = (pair[1] as char)
500 .to_digit(16)
501 .expect("validated lowercase hex");
502 bytes.push((high * 16 + low) as u8);
503 }
504 if bytes.len() < MIN_KEY_BYTES {
505 return Err(format!(
506 "{name} must be hex-encoded key material of at least {MIN_KEY_BYTES} bytes"
507 ));
508 }
509 Ok(bytes)
510}
511
512fn parse_token_keys(raw: &str) -> Result<BTreeMap<u8, Arc<[u8]>>, String> {
515 const NAME: &str = "KHIVE_EMAIL_UNSUBSCRIBE_TOKEN_KEYS";
516 let mut keys = BTreeMap::new();
517 for pair in raw.split(',') {
518 let pair = pair.trim();
519 if pair.is_empty() {
520 return Err(format!("{NAME} contains an empty `id:key` pair"));
521 }
522 let Some((id_text, key_text)) = pair.split_once(':') else {
523 return Err(format!("{NAME} entries must be `id:key` pairs"));
524 };
525 let id = id_text
526 .trim()
527 .parse::<u16>()
528 .ok()
529 .filter(|id| *id <= u8::MAX as u16)
530 .ok_or_else(|| format!("{NAME} key ids must be decimal integers 0-255"))?
531 as u8;
532 if keys.contains_key(&id) {
533 return Err(format!("{NAME} contains a duplicate key id"));
534 }
535 let key = parse_hex_key(NAME, key_text)?;
536 keys.insert(id, Arc::from(key.as_slice()));
537 }
538 if keys.is_empty() {
539 return Err(format!("{NAME} must contain at least one `id:key` pair"));
540 }
541 Ok(keys)
542}
543
544fn read_external_footer(path: Option<&str>) -> Result<Arc<str>, Arc<str>> {
548 let Some(path) = path else {
549 return Err(Arc::from("KHIVE_EMAIL_EXTERNAL_FOOTER_FILE is not set"));
550 };
551 let text = std::fs::read_to_string(path)
552 .map_err(|_| Arc::from("KHIVE_EMAIL_EXTERNAL_FOOTER_FILE could not be read"))?;
553 if !text.contains(UNSUBSCRIBE_PLACEHOLDER) {
554 return Err(Arc::from(
555 "KHIVE_EMAIL_EXTERNAL_FOOTER_FILE does not contain {unsubscribe}",
556 ));
557 }
558 Ok(Arc::from(text))
559}
560
561fn encode_unsubscribe_token(
566 key_id: u8,
567 key: &[u8],
568 recipient_key: &[u8; RECIPIENT_KEY_BYTES],
569) -> String {
570 let mut token = Vec::with_capacity(UNSUBSCRIBE_TOKEN_BYTES);
571 token.push(UNSUBSCRIBE_TOKEN_VERSION);
572 token.push(key_id);
573 token.extend_from_slice(recipient_key);
574 let tag = hmac_sha256(key, &token);
575 token.extend_from_slice(&tag[..UNSUBSCRIBE_TAG_BYTES]);
576 URL_SAFE_NO_PAD.encode(token)
577}
578
579fn hex_lower(bytes: &[u8]) -> String {
580 const DIGITS: &[u8; 16] = b"0123456789abcdef";
581 let mut out = String::with_capacity(bytes.len() * 2);
582 for byte in bytes {
583 out.push(DIGITS[(byte >> 4) as usize] as char);
584 out.push(DIGITS[(byte & 0x0f) as usize] as char);
585 }
586 out
587}
588
589fn constant_time_eq(left: &[u8], right: &[u8]) -> bool {
591 if left.len() != right.len() {
592 return false;
593 }
594 let mut diff = 0u8;
595 for (a, b) in left.iter().zip(right.iter()) {
596 diff |= a ^ b;
597 }
598 diff == 0
599}
600
601#[cfg(test)]
602mod tests {
603 use super::*;
604
605 #[test]
606 fn explicit_policy_and_requests_share_maintainer_normalization() {
607 let policy =
608 OutboundEmailPolicy::explicit(Some(" , First@Example.com , second@example.com,,"))
609 .unwrap()
610 .unwrap();
611 assert!(policy.allows("First@Example.com"));
612 assert!(policy.allows("second@example.com"));
613 assert!(policy.allows("first@example.com"));
614 assert!(policy.allows(" First@Example.com "));
615 assert!(policy.allows("First <FIRST@EXAMPLE.COM>"));
616 assert!(!policy.allows("other@example.com"));
617 assert!(!policy.allows("not-an-address"));
618 assert!(policy.is_configured());
619 assert_eq!(
620 policy.recipients.as_deref().unwrap(),
621 ["first@example.com", "second@example.com"]
622 );
623 assert!(OutboundEmailPolicy::explicit(Some(" , , "))
624 .unwrap()
625 .is_none());
626 assert!(OutboundEmailPolicy::explicit(Some("not-an-address")).is_err());
627 }
628
629 #[test]
630 fn fallback_validates_all_maintainers_and_allows_only_normalized_primary() {
631 let policy = OutboundEmailPolicy::maintainer(
632 Some("Owner <Primary@Example.com>, second@example.com"),
633 false,
634 )
635 .unwrap();
636 assert!(policy.allows("primary@example.com"));
637 assert!(policy.allows("Primary@Example.com"));
638 assert!(policy.allows("Owner <PRIMARY@EXAMPLE.COM>"));
639 assert!(!policy.allows("second@example.com"));
640 assert!(
641 OutboundEmailPolicy::maintainer(Some("primary@example.com,invalid"), false).is_err()
642 );
643 assert!(OutboundEmailPolicy::maintainer(Some(" , "), false).is_err());
644 assert!(OutboundEmailPolicy::maintainer(None, true).is_err());
645 assert!(
646 OutboundEmailPolicy::maintainer(Some("primary@example.com"), true)
647 .unwrap()
648 .allows("PRIMARY@example.com")
649 );
650 assert!(OutboundEmailPolicy::maintainer(None, false)
651 .unwrap()
652 .allows("backlog@example.com"));
653 assert!(!OutboundEmailPolicy::default().is_configured());
654 let deny_all = OutboundEmailPolicy::configured(vec![]).unwrap();
655 assert!(deny_all.is_configured());
656 assert!(!deny_all.allows("backlog@example.com"));
657 assert!(OutboundEmailPolicy::configured(vec!["invalid".into()]).is_err());
658 }
659
660 #[test]
661 fn present_maintainer_is_validated_beside_an_explicit_list_and_an_unset_one_is_not() {
662 let resolve = OutboundEmailPolicy::resolve;
663 let explicit = Some("allowed@example.com");
664 for maintainer in [
665 "",
666 " , ",
667 "primary@example.com,invalid",
668 "invalid,primary@example.com",
669 ] {
670 assert!(
671 resolve(explicit, Some(maintainer)).is_err(),
672 "maintainer {maintainer:?} beside a valid explicit list must be refused"
673 );
674 }
675 let unset = resolve(explicit, None).unwrap();
676 assert!(unset.is_configured());
677 assert!(unset.allows("ALLOWED@example.com"));
678 assert!(!unset.allows("primary@example.com"));
679 let list = Some("Owner <Primary@Example.com>, second@example.com");
680 let valid = resolve(explicit, list).unwrap();
681 assert!(valid.allows("allowed@example.com"));
682 assert!(!valid.allows("primary@example.com"));
683 let blank = Some(" , ");
684 let fallback = resolve(blank, list).unwrap();
685 assert!(fallback.allows("primary@example.com"));
686 assert!(!fallback.allows("second@example.com"));
687 assert!(resolve(blank, None).is_err());
688 assert!(resolve(Some("not-an-address"), None).is_err());
689 assert!(resolve(None, Some("primary@example.com,invalid")).is_err());
690 assert!(!resolve(None, None).unwrap().is_configured());
691 }
692
693 #[test]
694 fn comparison_folds_ascii_case_only() {
695 let policy = OutboundEmailPolicy::configured(vec!["kevin@example.com".into()]).unwrap();
696 assert!(!policy.allows("\u{212A}evin@example.com"));
699 assert!(policy.allows("KEVIN@Example.COM"));
700 assert!(policy.allows("kevin@example.com"));
701 }
702
703 #[test]
704 fn configured_entries_and_requests_share_one_normalization() {
705 let policy =
708 OutboundEmailPolicy::configured(vec!["\u{212A}evin@Example.COM".into()]).unwrap();
709 assert_eq!(
710 policy.recipients.as_deref().unwrap(),
711 ["\u{212A}evin@example.com"]
712 );
713 assert!(policy.allows("\u{212A}evin@EXAMPLE.com"));
714 assert!(!policy.allows("kevin@example.com"));
715 let accented =
716 OutboundEmailPolicy::maintainer(Some("\u{C9}mile@Example.com"), false).unwrap();
717 assert!(accented.allows("\u{C9}mile@example.COM"));
718 assert!(!accented.allows("\u{E9}mile@example.com"));
719 }
720
721 #[test]
722 fn suppression_matching_checks_exact_address_domain_and_recipient_key() {
723 let address = [EmailSuppression {
724 scope: EmailSuppressionScope::Outbound,
725 kind: EmailSuppressionKind::Address,
726 value: "a@example.com".into(),
727 }];
728 assert!(outbound_email_is_suppressed(
729 "a@example.com",
730 None,
731 &address
732 ));
733 assert!(!outbound_email_is_suppressed(
734 "b@example.com",
735 None,
736 &address
737 ));
738
739 let entries = [
740 EmailSuppression {
741 scope: EmailSuppressionScope::Outbound,
742 kind: EmailSuppressionKind::Address,
743 value: "a@example.com".into(),
744 },
745 EmailSuppression {
746 scope: EmailSuppressionScope::Outbound,
747 kind: EmailSuppressionKind::Domain,
748 value: "x.com".into(),
749 },
750 EmailSuppression {
751 scope: EmailSuppressionScope::Outbound,
752 kind: EmailSuppressionKind::RecipientKey,
753 value: "key-1".into(),
754 },
755 EmailSuppression {
756 scope: EmailSuppressionScope::Inbound,
757 kind: EmailSuppressionKind::Address,
758 value: "b@example.com".into(),
759 },
760 ];
761
762 assert!(outbound_email_is_suppressed(
763 "a@example.com",
764 None,
765 &entries
766 ));
767 assert!(outbound_email_is_suppressed("b@x.com", None, &entries));
768 assert!(!outbound_email_is_suppressed("b@sub.x.com", None, &entries));
769 assert!(outbound_email_is_suppressed(
770 "c@example.com",
771 Some("key-1"),
772 &entries
773 ));
774 assert!(!outbound_email_is_suppressed(
775 "c@example.com",
776 Some("key-2"),
777 &entries
778 ));
779 assert!(!outbound_email_is_suppressed(
780 "b@example.com",
781 None,
782 &entries
783 ));
784 }
785
786 #[test]
787 fn inbound_matching_checks_exact_address_and_domain_and_ignores_recipient_keys() {
788 let entries = [
789 EmailSuppression {
790 scope: EmailSuppressionScope::Inbound,
791 kind: EmailSuppressionKind::Address,
792 value: "a@example.com".into(),
793 },
794 EmailSuppression {
795 scope: EmailSuppressionScope::Inbound,
796 kind: EmailSuppressionKind::Domain,
797 value: "blocked.com".into(),
798 },
799 EmailSuppression {
800 scope: EmailSuppressionScope::Inbound,
801 kind: EmailSuppressionKind::RecipientKey,
802 value: "key-1".into(),
803 },
804 EmailSuppression {
806 scope: EmailSuppressionScope::Outbound,
807 kind: EmailSuppressionKind::Address,
808 value: "c@example.com".into(),
809 },
810 ];
811
812 assert!(inbound_email_is_suppressed("a@example.com", &entries));
813 assert!(!inbound_email_is_suppressed("b@example.com", &entries));
814 assert!(inbound_email_is_suppressed("z@blocked.com", &entries));
815 assert!(!inbound_email_is_suppressed("z@sub.blocked.com", &entries));
816 assert!(!inbound_email_is_suppressed("c@example.com", &entries));
817 assert!(!inbound_email_is_suppressed("key-1", &entries));
818 }
819
820 #[test]
821 fn cloned_policy_keeps_the_same_immutable_recipient_set() {
822 let policy = OutboundEmailPolicy::configured(vec!["allowed@example.com".into()]).unwrap();
823 let clone = policy.clone();
824 assert!(Arc::ptr_eq(
825 policy.recipients.as_ref().unwrap(),
826 clone.recipients.as_ref().unwrap()
827 ));
828 }
829
830 fn rk() -> String {
831 "cc".repeat(32) }
833
834 fn token_keys_hex() -> String {
835 format!("0:{},1:{}", "11".repeat(32), "22".repeat(32))
836 }
837
838 fn enabled() -> OutboundEmailPolicy {
839 OutboundEmailPolicy::configured_for_tests_with(
840 vec!["allowed@example.com".into()],
841 vec!["actor:sender".into()],
842 5,
843 &rk(),
844 &token_keys_hex(),
845 Ok("Reply to unsubscribe: {unsubscribe}"),
846 )
847 .unwrap()
848 }
849
850 fn unhex(hex: &str) -> Vec<u8> {
851 hex.as_bytes()
852 .chunks_exact(2)
853 .map(|pair| {
854 let high = (pair[0] as char).to_digit(16).unwrap();
855 let low = (pair[1] as char).to_digit(16).unwrap();
856 (high * 16 + low) as u8
857 })
858 .collect()
859 }
860
861 #[test]
862 fn hmac_sha256_matches_rfc_4231_vectors() {
863 let case1 = hmac_sha256(&[0x0b; 20], b"Hi There");
864 assert_eq!(
865 hex_lower(&case1),
866 "b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7"
867 );
868 let case2 = hmac_sha256(b"Jefe", b"what do ya want for nothing?");
869 assert_eq!(
870 hex_lower(&case2),
871 "5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843"
872 );
873 let case6 = hmac_sha256(
874 &[0xaa; 131],
875 b"Test Using Larger Than Block-Size Key - Hash Key First",
876 );
877 assert_eq!(
878 hex_lower(&case6),
879 "60e431591ee0b67f0d8a26aacbf5b77f8e0bc6213728c5140546040f0ee37f54"
880 );
881 }
882
883 #[test]
884 fn key_configuration_refuses_malformed_values_when_senders_are_set() {
885 let missing_rk = OutboundEmailPolicy::resolve_with_external(
887 None,
888 Some("maintainer@example.com"),
889 Some("actor:sender"),
890 Some("5"),
891 None,
892 Some(&token_keys_hex()),
893 None,
894 )
895 .err()
896 .expect("start-up policy is refused");
897 assert!(
898 missing_rk.contains("KHIVE_EMAIL_UNSUBSCRIBE_RK_KEY"),
899 "{missing_rk}"
900 );
901
902 let missing_tokens = OutboundEmailPolicy::resolve_with_external(
904 None,
905 Some("maintainer@example.com"),
906 Some("actor:sender"),
907 Some("5"),
908 Some(&rk()),
909 None,
910 None,
911 )
912 .err()
913 .expect("start-up policy is refused");
914 assert!(
915 missing_tokens.contains("KHIVE_EMAIL_UNSUBSCRIBE_TOKEN_KEYS"),
916 "{missing_tokens}"
917 );
918
919 for (rk, tokens, needle) in [
920 (
921 rk(),
922 "0:nothex".to_string(),
923 "KHIVE_EMAIL_UNSUBSCRIBE_TOKEN_KEYS",
924 ),
925 (rk(), "0".to_string(), "KHIVE_EMAIL_UNSUBSCRIBE_TOKEN_KEYS"),
926 (
927 rk(),
928 "0:abcd".to_string(),
929 "KHIVE_EMAIL_UNSUBSCRIBE_TOKEN_KEYS",
930 ),
931 (
932 rk(),
933 format!("0:{},0:{}", "11".repeat(32), "22".repeat(32)),
934 "KHIVE_EMAIL_UNSUBSCRIBE_TOKEN_KEYS",
935 ),
936 (
937 rk(),
938 format!("256:{}", "11".repeat(32)),
939 "KHIVE_EMAIL_UNSUBSCRIBE_TOKEN_KEYS",
940 ),
941 ] {
942 let error = OutboundEmailPolicy::resolve_with_external(
943 None,
944 Some("maintainer@example.com"),
945 Some("actor:sender"),
946 Some("5"),
947 Some(rk.as_str()),
948 Some(tokens.as_str()),
949 None,
950 )
951 .err()
952 .expect("start-up policy is refused");
953 assert!(error.contains(needle), "{error}");
954 assert!(
955 !error.contains(&"11".repeat(32)),
956 "key material leaked: {error}"
957 );
958 }
959
960 for rk in ["cc".repeat(31), "cc".repeat(16), "zz".repeat(32)] {
962 let error = OutboundEmailPolicy::resolve_with_external(
963 None,
964 Some("maintainer@example.com"),
965 Some("actor:sender"),
966 Some("5"),
967 Some(&rk),
968 Some(&token_keys_hex()),
969 None,
970 )
971 .err()
972 .expect("start-up policy is refused");
973 assert!(error.contains("KHIVE_EMAIL_UNSUBSCRIBE_RK_KEY"), "{error}");
974 assert!(!error.contains(&rk), "key material leaked: {error}");
975 }
976 }
977
978 #[test]
979 fn key_variables_are_ignored_when_senders_are_unset() {
980 let policy = OutboundEmailPolicy::resolve_with_external(
982 None,
983 Some("maintainer@example.com"),
984 None,
985 None,
986 Some("not hex"),
987 Some("garbage"),
988 Some("/nonexistent/footer"),
989 )
990 .unwrap();
991 assert!(!policy.has_external_senders());
992 assert!(policy.recipient_key("a@example.com").is_none());
993 assert!(policy.mint_unsubscribe_token("a@example.com").is_none());
994 assert!(policy.external_footer().is_none());
995 }
996
997 #[test]
998 fn recipient_key_is_normalized_hex_and_stable_across_case() {
999 let policy = enabled();
1000 let key = policy.recipient_key("person@example.com").unwrap();
1001 assert_eq!(key.len(), 64);
1002 assert!(khive_types::is_lowercase_hex(&key));
1003 let other_case =
1005 khive_types::email_address::normalize_email_recipient("Person@Example.COM").unwrap();
1006 assert_eq!(key, policy.recipient_key(&other_case).unwrap());
1007 let secret = unhex(&rk());
1008 assert_eq!(key, hex_lower(&hmac_sha256(&secret, b"person@example.com")));
1009 }
1010
1011 #[test]
1012 fn token_round_trips_and_rejects_tampering() {
1013 let policy = enabled();
1014 let token = policy.mint_unsubscribe_token("person@example.com").unwrap();
1015 let recipient_key = policy.recipient_key("person@example.com").unwrap();
1016 assert_eq!(
1017 policy.verify_unsubscribe_token(&token).as_deref(),
1018 Some(recipient_key.as_str())
1019 );
1020
1021 let mut bytes = URL_SAFE_NO_PAD.decode(&token).unwrap();
1023 let last = bytes.len() - 1;
1024 bytes[last] ^= 0x01;
1025 assert!(policy
1026 .verify_unsubscribe_token(&URL_SAFE_NO_PAD.encode(&bytes))
1027 .is_none());
1028
1029 let mut bytes = URL_SAFE_NO_PAD.decode(&token).unwrap();
1031 bytes[0] = 0x02;
1032 assert!(policy
1033 .verify_unsubscribe_token(&URL_SAFE_NO_PAD.encode(&bytes))
1034 .is_none());
1035
1036 let mut bytes = URL_SAFE_NO_PAD.decode(&token).unwrap();
1038 bytes[1] = 9;
1039 assert!(policy
1040 .verify_unsubscribe_token(&URL_SAFE_NO_PAD.encode(&bytes))
1041 .is_none());
1042
1043 assert!(policy.verify_unsubscribe_token("AAAA").is_none());
1045 }
1046
1047 const VECTOR_KEY_ID: u8 = 3;
1051 const VECTOR_TOKEN_KEY_HEX: &str =
1052 "efaaa78a85da8a505a5278b5ad7d202a8b5028bb0b279c7a01f410c0ff84c09d";
1053 const VECTOR_RECIPIENT_KEY_HEX: &str =
1054 "cd5ac2457bd4442f53a23cfa9972cc3cdf3d0f1bcf1722bd5503ab20acfa3930";
1055 const VECTOR_TOKEN: &str =
1056 "AQPNWsJFe9REL1OiPPqZcsw83z0PG88XIr1VA6sgrPo5MFRThmznJxzUr1otDEzmcaE";
1057 const VECTOR_TOKEN_FLIPPED_TAG: &str =
1058 "AQPNWsJFe9REL1OiPPqZcsw83z0PG88XIr1VA6sgrPo5MFRThmznJxzUr1otDEzmcaA";
1059
1060 #[test]
1061 fn shared_vector_mints_and_verifies_byte_for_byte() {
1062 let recipient_key: [u8; RECIPIENT_KEY_BYTES] = unhex(VECTOR_RECIPIENT_KEY_HEX)
1063 .try_into()
1064 .expect("the vector recipient key is 32 bytes");
1065 assert_eq!(
1066 encode_unsubscribe_token(VECTOR_KEY_ID, &unhex(VECTOR_TOKEN_KEY_HEX), &recipient_key),
1067 VECTOR_TOKEN,
1068 "minting the vector inputs must reproduce the vector token"
1069 );
1070
1071 let policy = OutboundEmailPolicy::configured_for_tests_with(
1072 vec!["allowed@example.com".into()],
1073 vec!["actor:sender".into()],
1074 5,
1075 &rk(),
1076 &format!("{VECTOR_KEY_ID}:{VECTOR_TOKEN_KEY_HEX}"),
1077 Ok("Reply to unsubscribe: {unsubscribe}"),
1078 )
1079 .unwrap();
1080 assert_eq!(
1081 policy.verify_unsubscribe_token(VECTOR_TOKEN).as_deref(),
1082 Some(VECTOR_RECIPIENT_KEY_HEX),
1083 "the vector token verifies to the vector recipient key"
1084 );
1085 assert!(
1086 policy
1087 .verify_unsubscribe_token(VECTOR_TOKEN_FLIPPED_TAG)
1088 .is_none(),
1089 "the vector token with one tag bit flipped does not verify"
1090 );
1091 }
1092
1093 #[test]
1094 fn retired_key_ids_keep_verifying_and_the_highest_id_mints() {
1095 let policy = enabled();
1096 let token_high = policy.mint_unsubscribe_token("person@example.com").unwrap();
1098 let decoded = URL_SAFE_NO_PAD.decode(&token_high).unwrap();
1099 assert_eq!(decoded[1], 1, "minting must use the highest configured id");
1100 assert!(policy.verify_unsubscribe_token(&token_high).is_some());
1101
1102 let retired = OutboundEmailPolicy::configured_for_tests_with(
1104 vec!["allowed@example.com".into()],
1105 vec!["actor:sender".into()],
1106 5,
1107 &rk(),
1108 &token_keys_hex(),
1109 Ok("footer {unsubscribe}"),
1110 )
1111 .unwrap();
1112 let mut body = Vec::new();
1114 body.push(0x01u8);
1115 body.push(0u8);
1116 body.extend_from_slice(&unhex(
1117 &retired.recipient_key("person@example.com").unwrap(),
1118 ));
1119 let tag = hmac_sha256(&unhex(&"11".repeat(32)), &body);
1120 body.extend_from_slice(&tag[..16]);
1121 let old_token = URL_SAFE_NO_PAD.encode(&body);
1122 assert_eq!(
1123 retired.verify_unsubscribe_token(&old_token).as_deref(),
1124 Some(
1125 retired
1126 .recipient_key("person@example.com")
1127 .unwrap()
1128 .as_str()
1129 )
1130 );
1131 }
1132
1133 #[test]
1134 fn footer_state_is_recorded_without_a_start_up_error() {
1135 let policy = OutboundEmailPolicy::configured_for_tests_with(
1136 vec!["allowed@example.com".into()],
1137 vec!["actor:sender".into()],
1138 5,
1139 &rk(),
1140 &token_keys_hex(),
1141 Err("KHIVE_EMAIL_EXTERNAL_FOOTER_FILE is not set"),
1142 )
1143 .unwrap();
1144 assert_eq!(
1145 policy.external_footer(),
1146 Some(Err("KHIVE_EMAIL_EXTERNAL_FOOTER_FILE is not set"))
1147 );
1148 }
1149
1150 #[test]
1151 fn footer_file_must_exist_be_readable_and_carry_the_placeholder() {
1152 let missing = read_external_footer(Some("/nonexistent/khive-footer"));
1153 assert!(missing.is_err());
1154 let dir = std::env::temp_dir();
1155 let without = dir.join("khive-footer-without-placeholder.txt");
1156 std::fs::write(&without, "no placeholder here").unwrap();
1157 assert!(read_external_footer(without.to_str()).is_err());
1158 let with = dir.join("khive-footer-with-placeholder.txt");
1159 std::fs::write(&with, "Contact us.\n{unsubscribe}\n").unwrap();
1160 assert_eq!(
1161 read_external_footer(with.to_str()).unwrap().as_ref(),
1162 "Contact us.\n{unsubscribe}\n"
1163 );
1164 assert!(read_external_footer(None).is_err());
1165 }
1166}