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
131impl OutboundEmailPolicy {
132 pub fn configured(recipients: Vec<String>) -> Result<Self, String> {
134 let recipients = recipients
135 .iter()
136 .map(|value| {
137 khive_types::email_address::normalize_email_recipient(value)
138 .ok_or_else(|| "outbound email policy contains an invalid address".to_string())
139 })
140 .collect::<Result<Vec<_>, _>>()?;
141 Ok(Self {
142 recipients: Some(recipients.into()),
143 ..Self::default()
144 })
145 }
146
147 pub fn is_configured(&self) -> bool {
148 self.recipients.is_some()
149 }
150
151 pub fn allows(&self, recipient: &str) -> bool {
152 self.recipients.as_ref().is_none_or(|recipients| {
153 khive_types::email_address::normalize_email_recipient(recipient)
154 .is_some_and(|recipient| recipients.iter().any(|allowed| allowed == &recipient))
155 })
156 }
157
158 pub fn allows_external_sender(&self, actor_id: &str) -> bool {
159 self.external_senders
160 .as_ref()
161 .is_some_and(|senders| senders.iter().any(|sender| sender == actor_id))
162 }
163
164 pub fn has_external_senders(&self) -> bool {
165 self.external_senders.is_some()
166 }
167
168 pub fn external_daily_cap(&self) -> Option<u64> {
169 self.external_daily_cap
170 }
171
172 pub async fn lock_external_admission(&self) -> tokio::sync::OwnedMutexGuard<()> {
173 Arc::clone(&self.external_admission).lock_owned().await
174 }
175
176 pub fn recipient_key(&self, normalized_recipient: &str) -> Option<String> {
180 self.recipient_key_secret
181 .as_deref()
182 .map(|secret| hex_lower(&hmac_sha256(secret, normalized_recipient.as_bytes())))
183 }
184
185 fn recipient_key_bytes(&self, normalized_recipient: &str) -> Option<[u8; RECIPIENT_KEY_BYTES]> {
187 self.recipient_key_secret
188 .as_deref()
189 .map(|secret| hmac_sha256(secret, normalized_recipient.as_bytes()))
190 }
191
192 pub fn external_footer(&self) -> Option<Result<&str, &str>> {
195 self.external_footer.as_ref().map(|value| {
196 value
197 .as_ref()
198 .map(|text| &**text)
199 .map_err(|reason| &**reason)
200 })
201 }
202
203 pub fn mint_unsubscribe_token(&self, normalized_recipient: &str) -> Option<String> {
206 let recipient_key = self.recipient_key_bytes(normalized_recipient)?;
207 let (&key_id, key) = self.token_keys.iter().next_back()?;
208 let mut body = Vec::with_capacity(2 + RECIPIENT_KEY_BYTES);
209 body.push(UNSUBSCRIBE_TOKEN_VERSION);
210 body.push(key_id);
211 body.extend_from_slice(&recipient_key);
212 let tag = hmac_sha256(key, &body);
213 let mut token = body;
214 token.extend_from_slice(&tag[..UNSUBSCRIBE_TAG_BYTES]);
215 Some(URL_SAFE_NO_PAD.encode(token))
216 }
217
218 pub fn verify_unsubscribe_token(&self, token: &str) -> Option<String> {
221 let decoded = URL_SAFE_NO_PAD.decode(token).ok()?;
222 if decoded.len() != UNSUBSCRIBE_TOKEN_BYTES {
223 return None;
224 }
225 if decoded[0] != UNSUBSCRIBE_TOKEN_VERSION {
226 return None;
227 }
228 let key_id = decoded[1];
229 let key = self.token_keys.get(&key_id)?;
230 let (body, tag) = decoded.split_at(decoded.len() - UNSUBSCRIBE_TAG_BYTES);
231 let expected = hmac_sha256(key, body);
232 if !constant_time_eq(&expected[..UNSUBSCRIBE_TAG_BYTES], tag) {
233 return None;
234 }
235 let recipient_key = &body[2..];
236 Some(hex_lower(recipient_key))
237 }
238
239 pub fn from_env() -> Result<Self, String> {
244 fn read(name: &str) -> Result<Option<String>, String> {
245 match std::env::var(name) {
246 Ok(value) => Ok(Some(value)),
247 Err(std::env::VarError::NotPresent) => Ok(None),
248 Err(std::env::VarError::NotUnicode(_)) => {
249 Err(format!("{name} must contain valid Unicode text"))
250 }
251 }
252 }
253 let external_senders = read("KHIVE_EMAIL_EXTERNAL_SENDERS")?;
254 let external_daily_cap = read("KHIVE_EMAIL_EXTERNAL_DAILY_CAP")?;
255 let explicit = read("KHIVE_EMAIL_SEND_ALLOWED_RECIPIENTS")?;
256 let maintainer = read("KHIVE_EMAIL_MAINTAINER_ADDRESS")?;
257 let (recipient_key_secret, token_keys) = if external_senders.is_some() {
258 (
259 read("KHIVE_EMAIL_UNSUBSCRIBE_RK_KEY")?,
260 read("KHIVE_EMAIL_UNSUBSCRIBE_TOKEN_KEYS")?,
261 )
262 } else {
263 (None, None)
264 };
265 let footer_file = if external_senders.is_some() {
266 read("KHIVE_EMAIL_EXTERNAL_FOOTER_FILE")?
267 } else {
268 None
269 };
270 Self::resolve_with_external(
271 explicit.as_deref(),
272 maintainer.as_deref(),
273 external_senders.as_deref(),
274 external_daily_cap.as_deref(),
275 recipient_key_secret.as_deref(),
276 token_keys.as_deref(),
277 footer_file.as_deref(),
278 )
279 }
280
281 #[cfg(test)]
282 fn resolve(explicit: Option<&str>, maintainer: Option<&str>) -> Result<Self, String> {
283 Self::resolve_with_external(explicit, maintainer, None, None, None, None, None)
284 }
285
286 #[cfg(any(test, debug_assertions))]
289 #[doc(hidden)]
290 pub fn configured_for_tests(
291 recipients: Vec<String>,
292 external_senders: Vec<String>,
293 daily_cap: u64,
294 ) -> Result<Self, String> {
295 let token_keys = format!("0:{},1:{}", "11".repeat(32), "22".repeat(32));
296 Self::configured_for_tests_with(
297 recipients,
298 external_senders,
299 daily_cap,
300 &"cc".repeat(32),
301 &token_keys,
302 Ok("Reply to unsubscribe: {unsubscribe}"),
303 )
304 }
305
306 #[cfg(any(test, debug_assertions))]
309 #[doc(hidden)]
310 #[allow(clippy::too_many_arguments)]
311 pub fn configured_for_tests_with(
312 recipients: Vec<String>,
313 external_senders: Vec<String>,
314 daily_cap: u64,
315 recipient_key_secret_hex: &str,
316 token_keys: &str,
317 footer: Result<&str, &str>,
318 ) -> Result<Self, String> {
319 if daily_cap == 0 {
320 return Err("KHIVE_EMAIL_EXTERNAL_DAILY_CAP must be a positive integer".into());
321 }
322 let external_senders = parse_external_senders(&external_senders.join(","))?;
323 if external_senders.is_empty() {
324 return Err("KHIVE_EMAIL_EXTERNAL_SENDERS must contain at least one actor id".into());
325 }
326 let mut policy = Self::configured(recipients)?;
327 policy.external_senders = Some(external_senders.into());
328 policy.external_daily_cap = Some(daily_cap);
329 let secret = parse_hex_key("KHIVE_EMAIL_UNSUBSCRIBE_RK_KEY", recipient_key_secret_hex)?;
330 policy.recipient_key_secret = Some(Arc::from(secret.as_slice()));
331 policy.token_keys = Arc::new(parse_token_keys(token_keys)?);
332 policy.external_footer = Some(footer.map(Arc::from).map_err(Arc::from));
333 Ok(policy)
334 }
335
336 #[cfg(any(test, debug_assertions))]
338 #[doc(hidden)]
339 pub fn with_external_footer_for_tests(mut self, footer: Result<&str, &str>) -> Self {
340 self.external_footer = Some(footer.map(Arc::from).map_err(Arc::from));
341 self
342 }
343
344 #[allow(clippy::too_many_arguments)]
345 fn resolve_with_external(
346 explicit: Option<&str>,
347 maintainer: Option<&str>,
348 external_senders: Option<&str>,
349 external_daily_cap: Option<&str>,
350 recipient_key_secret: Option<&str>,
351 token_keys: Option<&str>,
352 footer_file: Option<&str>,
353 ) -> Result<Self, String> {
354 let senders = external_senders.map(parse_external_senders).transpose()?;
355 let cap = match external_daily_cap {
356 Some(value) => {
357 let parsed = value.parse::<u64>().map_err(|_| {
358 "KHIVE_EMAIL_EXTERNAL_DAILY_CAP must be a positive integer".to_string()
359 })?;
360 if parsed == 0 {
361 return Err("KHIVE_EMAIL_EXTERNAL_DAILY_CAP must be a positive integer".into());
362 }
363 Some(parsed)
364 }
365 None if senders.is_some() => {
366 return Err("KHIVE_EMAIL_EXTERNAL_DAILY_CAP is required when KHIVE_EMAIL_EXTERNAL_SENDERS is set".into());
367 }
368 None => None,
369 };
370 let mut policy = Self::resolve_recipients(explicit, maintainer)?;
371 policy.external_daily_cap = cap;
372 if let Some(senders) = senders {
373 policy.external_senders = Some(senders.into());
374 let recipient_key_secret = parse_hex_key(
375 "KHIVE_EMAIL_UNSUBSCRIBE_RK_KEY",
376 recipient_key_secret.ok_or_else(|| {
377 "KHIVE_EMAIL_UNSUBSCRIBE_RK_KEY is required when KHIVE_EMAIL_EXTERNAL_SENDERS is set".to_string()
378 })?,
379 )?;
380 let token_keys = parse_token_keys(token_keys.ok_or_else(|| {
381 "KHIVE_EMAIL_UNSUBSCRIBE_TOKEN_KEYS is required when KHIVE_EMAIL_EXTERNAL_SENDERS is set".to_string()
382 })?)?;
383 policy.recipient_key_secret = Some(Arc::from(recipient_key_secret.as_slice()));
384 policy.token_keys = Arc::new(token_keys);
385 policy.external_footer = Some(read_external_footer(footer_file));
386 }
387 Ok(policy)
388 }
389
390 fn resolve_recipients(
391 explicit: Option<&str>,
392 maintainer: Option<&str>,
393 ) -> Result<Self, String> {
394 if let Some(policy) = Self::explicit(explicit)? {
395 if let Some(raw) = maintainer {
399 Self::maintainer_primary(raw)?;
400 }
401 return Ok(policy);
402 }
403 Self::maintainer(maintainer, explicit.is_some())
404 }
405
406 fn explicit(raw: Option<&str>) -> Result<Option<Self>, String> {
407 let recipients: Vec<String> = raw
408 .unwrap_or_default()
409 .split(',')
410 .map(str::trim)
411 .filter(|value| !value.is_empty())
412 .map(str::to_owned)
413 .collect();
414 if recipients.is_empty() {
415 return Ok(None);
416 }
417 Self::configured(recipients)
418 .map(Some)
419 .map_err(|_| "KHIVE_EMAIL_SEND_ALLOWED_RECIPIENTS contains an invalid address".into())
420 }
421
422 fn maintainer(raw: Option<&str>, explicit_was_set: bool) -> Result<Self, String> {
423 let Some(raw) = raw else {
424 if explicit_was_set {
425 return Err(
426 "configured outbound email policy must contain at least one address".into(),
427 );
428 }
429 return Ok(Self::default());
430 };
431 Self::configured(vec![Self::maintainer_primary(raw)?])
432 }
433
434 fn maintainer_primary(raw: &str) -> Result<String, String> {
438 let addresses = raw
439 .split(',')
440 .map(str::trim)
441 .filter(|value| !value.is_empty())
442 .map(|value| {
443 khive_types::email_address::normalize_email_recipient(value).ok_or_else(|| {
444 "KHIVE_EMAIL_MAINTAINER_ADDRESS contains an invalid address".to_string()
445 })
446 })
447 .collect::<Result<Vec<_>, _>>()?;
448 let Some(primary) = addresses.into_iter().next() else {
449 return Err("KHIVE_EMAIL_MAINTAINER_ADDRESS must contain at least one address".into());
450 };
451 Ok(primary)
452 }
453}
454
455fn parse_external_senders(raw: &str) -> Result<Vec<String>, String> {
456 raw.split(',')
457 .map(str::trim)
458 .map(|actor_id| {
459 if actor_id.is_empty() {
460 return Err("KHIVE_EMAIL_EXTERNAL_SENDERS contains an empty actor id".to_string());
461 }
462 khive_types::Namespace::parse(actor_id).map_err(|_| {
463 format!("KHIVE_EMAIL_EXTERNAL_SENDERS contains an invalid actor id {actor_id:?}")
464 })?;
465 Ok(actor_id.to_string())
466 })
467 .collect()
468}
469
470fn parse_hex_key(name: &str, raw: &str) -> Result<Vec<u8>, String> {
473 let value = raw.trim().to_ascii_lowercase();
474 if !value.len().is_multiple_of(2) || !khive_types::is_lowercase_hex(&value) {
475 return Err(format!(
476 "{name} must be hex-encoded key material of at least {MIN_KEY_BYTES} bytes"
477 ));
478 }
479 let mut bytes = Vec::with_capacity(value.len() / 2);
480 for pair in value.as_bytes().chunks_exact(2) {
481 let high = (pair[0] as char)
482 .to_digit(16)
483 .expect("validated lowercase hex");
484 let low = (pair[1] as char)
485 .to_digit(16)
486 .expect("validated lowercase hex");
487 bytes.push((high * 16 + low) as u8);
488 }
489 if bytes.len() < MIN_KEY_BYTES {
490 return Err(format!(
491 "{name} must be hex-encoded key material of at least {MIN_KEY_BYTES} bytes"
492 ));
493 }
494 Ok(bytes)
495}
496
497fn parse_token_keys(raw: &str) -> Result<BTreeMap<u8, Arc<[u8]>>, String> {
500 const NAME: &str = "KHIVE_EMAIL_UNSUBSCRIBE_TOKEN_KEYS";
501 let mut keys = BTreeMap::new();
502 for pair in raw.split(',') {
503 let pair = pair.trim();
504 if pair.is_empty() {
505 return Err(format!("{NAME} contains an empty `id:key` pair"));
506 }
507 let Some((id_text, key_text)) = pair.split_once(':') else {
508 return Err(format!("{NAME} entries must be `id:key` pairs"));
509 };
510 let id = id_text
511 .trim()
512 .parse::<u16>()
513 .ok()
514 .filter(|id| *id <= u8::MAX as u16)
515 .ok_or_else(|| format!("{NAME} key ids must be decimal integers 0-255"))?
516 as u8;
517 if keys.contains_key(&id) {
518 return Err(format!("{NAME} contains a duplicate key id"));
519 }
520 let key = parse_hex_key(NAME, key_text)?;
521 keys.insert(id, Arc::from(key.as_slice()));
522 }
523 if keys.is_empty() {
524 return Err(format!("{NAME} must contain at least one `id:key` pair"));
525 }
526 Ok(keys)
527}
528
529fn read_external_footer(path: Option<&str>) -> Result<Arc<str>, Arc<str>> {
533 let Some(path) = path else {
534 return Err(Arc::from("KHIVE_EMAIL_EXTERNAL_FOOTER_FILE is not set"));
535 };
536 let text = std::fs::read_to_string(path)
537 .map_err(|_| Arc::from("KHIVE_EMAIL_EXTERNAL_FOOTER_FILE could not be read"))?;
538 if !text.contains(UNSUBSCRIBE_PLACEHOLDER) {
539 return Err(Arc::from(
540 "KHIVE_EMAIL_EXTERNAL_FOOTER_FILE does not contain {unsubscribe}",
541 ));
542 }
543 Ok(Arc::from(text))
544}
545
546fn hex_lower(bytes: &[u8]) -> String {
548 const DIGITS: &[u8; 16] = b"0123456789abcdef";
549 let mut out = String::with_capacity(bytes.len() * 2);
550 for byte in bytes {
551 out.push(DIGITS[(byte >> 4) as usize] as char);
552 out.push(DIGITS[(byte & 0x0f) as usize] as char);
553 }
554 out
555}
556
557fn constant_time_eq(left: &[u8], right: &[u8]) -> bool {
559 if left.len() != right.len() {
560 return false;
561 }
562 let mut diff = 0u8;
563 for (a, b) in left.iter().zip(right.iter()) {
564 diff |= a ^ b;
565 }
566 diff == 0
567}
568
569#[cfg(test)]
570mod tests {
571 use super::*;
572
573 #[test]
574 fn explicit_policy_and_requests_share_maintainer_normalization() {
575 let policy =
576 OutboundEmailPolicy::explicit(Some(" , First@Example.com , second@example.com,,"))
577 .unwrap()
578 .unwrap();
579 assert!(policy.allows("First@Example.com"));
580 assert!(policy.allows("second@example.com"));
581 assert!(policy.allows("first@example.com"));
582 assert!(policy.allows(" First@Example.com "));
583 assert!(policy.allows("First <FIRST@EXAMPLE.COM>"));
584 assert!(!policy.allows("other@example.com"));
585 assert!(!policy.allows("not-an-address"));
586 assert!(policy.is_configured());
587 assert_eq!(
588 policy.recipients.as_deref().unwrap(),
589 ["first@example.com", "second@example.com"]
590 );
591 assert!(OutboundEmailPolicy::explicit(Some(" , , "))
592 .unwrap()
593 .is_none());
594 assert!(OutboundEmailPolicy::explicit(Some("not-an-address")).is_err());
595 }
596
597 #[test]
598 fn fallback_validates_all_maintainers_and_allows_only_normalized_primary() {
599 let policy = OutboundEmailPolicy::maintainer(
600 Some("Owner <Primary@Example.com>, second@example.com"),
601 false,
602 )
603 .unwrap();
604 assert!(policy.allows("primary@example.com"));
605 assert!(policy.allows("Primary@Example.com"));
606 assert!(policy.allows("Owner <PRIMARY@EXAMPLE.COM>"));
607 assert!(!policy.allows("second@example.com"));
608 assert!(
609 OutboundEmailPolicy::maintainer(Some("primary@example.com,invalid"), false).is_err()
610 );
611 assert!(OutboundEmailPolicy::maintainer(Some(" , "), false).is_err());
612 assert!(OutboundEmailPolicy::maintainer(None, true).is_err());
613 assert!(
614 OutboundEmailPolicy::maintainer(Some("primary@example.com"), true)
615 .unwrap()
616 .allows("PRIMARY@example.com")
617 );
618 assert!(OutboundEmailPolicy::maintainer(None, false)
619 .unwrap()
620 .allows("backlog@example.com"));
621 assert!(!OutboundEmailPolicy::default().is_configured());
622 let deny_all = OutboundEmailPolicy::configured(vec![]).unwrap();
623 assert!(deny_all.is_configured());
624 assert!(!deny_all.allows("backlog@example.com"));
625 assert!(OutboundEmailPolicy::configured(vec!["invalid".into()]).is_err());
626 }
627
628 #[test]
629 fn present_maintainer_is_validated_beside_an_explicit_list_and_an_unset_one_is_not() {
630 let resolve = OutboundEmailPolicy::resolve;
631 let explicit = Some("allowed@example.com");
632 for maintainer in [
633 "",
634 " , ",
635 "primary@example.com,invalid",
636 "invalid,primary@example.com",
637 ] {
638 assert!(
639 resolve(explicit, Some(maintainer)).is_err(),
640 "maintainer {maintainer:?} beside a valid explicit list must be refused"
641 );
642 }
643 let unset = resolve(explicit, None).unwrap();
644 assert!(unset.is_configured());
645 assert!(unset.allows("ALLOWED@example.com"));
646 assert!(!unset.allows("primary@example.com"));
647 let list = Some("Owner <Primary@Example.com>, second@example.com");
648 let valid = resolve(explicit, list).unwrap();
649 assert!(valid.allows("allowed@example.com"));
650 assert!(!valid.allows("primary@example.com"));
651 let blank = Some(" , ");
652 let fallback = resolve(blank, list).unwrap();
653 assert!(fallback.allows("primary@example.com"));
654 assert!(!fallback.allows("second@example.com"));
655 assert!(resolve(blank, None).is_err());
656 assert!(resolve(Some("not-an-address"), None).is_err());
657 assert!(resolve(None, Some("primary@example.com,invalid")).is_err());
658 assert!(!resolve(None, None).unwrap().is_configured());
659 }
660
661 #[test]
662 fn comparison_folds_ascii_case_only() {
663 let policy = OutboundEmailPolicy::configured(vec!["kevin@example.com".into()]).unwrap();
664 assert!(!policy.allows("\u{212A}evin@example.com"));
667 assert!(policy.allows("KEVIN@Example.COM"));
668 assert!(policy.allows("kevin@example.com"));
669 }
670
671 #[test]
672 fn configured_entries_and_requests_share_one_normalization() {
673 let policy =
676 OutboundEmailPolicy::configured(vec!["\u{212A}evin@Example.COM".into()]).unwrap();
677 assert_eq!(
678 policy.recipients.as_deref().unwrap(),
679 ["\u{212A}evin@example.com"]
680 );
681 assert!(policy.allows("\u{212A}evin@EXAMPLE.com"));
682 assert!(!policy.allows("kevin@example.com"));
683 let accented =
684 OutboundEmailPolicy::maintainer(Some("\u{C9}mile@Example.com"), false).unwrap();
685 assert!(accented.allows("\u{C9}mile@example.COM"));
686 assert!(!accented.allows("\u{E9}mile@example.com"));
687 }
688
689 #[test]
690 fn suppression_matching_checks_exact_address_domain_and_recipient_key() {
691 let address = [EmailSuppression {
692 scope: EmailSuppressionScope::Outbound,
693 kind: EmailSuppressionKind::Address,
694 value: "a@example.com".into(),
695 }];
696 assert!(outbound_email_is_suppressed(
697 "a@example.com",
698 None,
699 &address
700 ));
701 assert!(!outbound_email_is_suppressed(
702 "b@example.com",
703 None,
704 &address
705 ));
706
707 let entries = [
708 EmailSuppression {
709 scope: EmailSuppressionScope::Outbound,
710 kind: EmailSuppressionKind::Address,
711 value: "a@example.com".into(),
712 },
713 EmailSuppression {
714 scope: EmailSuppressionScope::Outbound,
715 kind: EmailSuppressionKind::Domain,
716 value: "x.com".into(),
717 },
718 EmailSuppression {
719 scope: EmailSuppressionScope::Outbound,
720 kind: EmailSuppressionKind::RecipientKey,
721 value: "key-1".into(),
722 },
723 EmailSuppression {
724 scope: EmailSuppressionScope::Inbound,
725 kind: EmailSuppressionKind::Address,
726 value: "b@example.com".into(),
727 },
728 ];
729
730 assert!(outbound_email_is_suppressed(
731 "a@example.com",
732 None,
733 &entries
734 ));
735 assert!(outbound_email_is_suppressed("b@x.com", None, &entries));
736 assert!(!outbound_email_is_suppressed("b@sub.x.com", None, &entries));
737 assert!(outbound_email_is_suppressed(
738 "c@example.com",
739 Some("key-1"),
740 &entries
741 ));
742 assert!(!outbound_email_is_suppressed(
743 "c@example.com",
744 Some("key-2"),
745 &entries
746 ));
747 assert!(!outbound_email_is_suppressed(
748 "b@example.com",
749 None,
750 &entries
751 ));
752 }
753
754 #[test]
755 fn cloned_policy_keeps_the_same_immutable_recipient_set() {
756 let policy = OutboundEmailPolicy::configured(vec!["allowed@example.com".into()]).unwrap();
757 let clone = policy.clone();
758 assert!(Arc::ptr_eq(
759 policy.recipients.as_ref().unwrap(),
760 clone.recipients.as_ref().unwrap()
761 ));
762 }
763
764 fn rk() -> String {
765 "cc".repeat(32) }
767
768 fn token_keys_hex() -> String {
769 format!("0:{},1:{}", "11".repeat(32), "22".repeat(32))
770 }
771
772 fn enabled() -> OutboundEmailPolicy {
773 OutboundEmailPolicy::configured_for_tests_with(
774 vec!["allowed@example.com".into()],
775 vec!["actor:sender".into()],
776 5,
777 &rk(),
778 &token_keys_hex(),
779 Ok("Reply to unsubscribe: {unsubscribe}"),
780 )
781 .unwrap()
782 }
783
784 fn unhex(hex: &str) -> Vec<u8> {
785 hex.as_bytes()
786 .chunks_exact(2)
787 .map(|pair| {
788 let high = (pair[0] as char).to_digit(16).unwrap();
789 let low = (pair[1] as char).to_digit(16).unwrap();
790 (high * 16 + low) as u8
791 })
792 .collect()
793 }
794
795 #[test]
796 fn hmac_sha256_matches_rfc_4231_vectors() {
797 let case1 = hmac_sha256(&[0x0b; 20], b"Hi There");
798 assert_eq!(
799 hex_lower(&case1),
800 "b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7"
801 );
802 let case2 = hmac_sha256(b"Jefe", b"what do ya want for nothing?");
803 assert_eq!(
804 hex_lower(&case2),
805 "5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843"
806 );
807 let case6 = hmac_sha256(
808 &[0xaa; 131],
809 b"Test Using Larger Than Block-Size Key - Hash Key First",
810 );
811 assert_eq!(
812 hex_lower(&case6),
813 "60e431591ee0b67f0d8a26aacbf5b77f8e0bc6213728c5140546040f0ee37f54"
814 );
815 }
816
817 #[test]
818 fn key_configuration_refuses_malformed_values_when_senders_are_set() {
819 let missing_rk = OutboundEmailPolicy::resolve_with_external(
821 None,
822 Some("maintainer@example.com"),
823 Some("actor:sender"),
824 Some("5"),
825 None,
826 Some(&token_keys_hex()),
827 None,
828 )
829 .err()
830 .expect("start-up policy is refused");
831 assert!(
832 missing_rk.contains("KHIVE_EMAIL_UNSUBSCRIBE_RK_KEY"),
833 "{missing_rk}"
834 );
835
836 let missing_tokens = OutboundEmailPolicy::resolve_with_external(
838 None,
839 Some("maintainer@example.com"),
840 Some("actor:sender"),
841 Some("5"),
842 Some(&rk()),
843 None,
844 None,
845 )
846 .err()
847 .expect("start-up policy is refused");
848 assert!(
849 missing_tokens.contains("KHIVE_EMAIL_UNSUBSCRIBE_TOKEN_KEYS"),
850 "{missing_tokens}"
851 );
852
853 for (rk, tokens, needle) in [
854 (
855 rk(),
856 "0:nothex".to_string(),
857 "KHIVE_EMAIL_UNSUBSCRIBE_TOKEN_KEYS",
858 ),
859 (rk(), "0".to_string(), "KHIVE_EMAIL_UNSUBSCRIBE_TOKEN_KEYS"),
860 (
861 rk(),
862 "0:abcd".to_string(),
863 "KHIVE_EMAIL_UNSUBSCRIBE_TOKEN_KEYS",
864 ),
865 (
866 rk(),
867 format!("0:{},0:{}", "11".repeat(32), "22".repeat(32)),
868 "KHIVE_EMAIL_UNSUBSCRIBE_TOKEN_KEYS",
869 ),
870 (
871 rk(),
872 format!("256:{}", "11".repeat(32)),
873 "KHIVE_EMAIL_UNSUBSCRIBE_TOKEN_KEYS",
874 ),
875 ] {
876 let error = OutboundEmailPolicy::resolve_with_external(
877 None,
878 Some("maintainer@example.com"),
879 Some("actor:sender"),
880 Some("5"),
881 Some(rk.as_str()),
882 Some(tokens.as_str()),
883 None,
884 )
885 .err()
886 .expect("start-up policy is refused");
887 assert!(error.contains(needle), "{error}");
888 assert!(
889 !error.contains(&"11".repeat(32)),
890 "key material leaked: {error}"
891 );
892 }
893
894 for rk in ["cc".repeat(31), "cc".repeat(16), "zz".repeat(32)] {
896 let error = OutboundEmailPolicy::resolve_with_external(
897 None,
898 Some("maintainer@example.com"),
899 Some("actor:sender"),
900 Some("5"),
901 Some(&rk),
902 Some(&token_keys_hex()),
903 None,
904 )
905 .err()
906 .expect("start-up policy is refused");
907 assert!(error.contains("KHIVE_EMAIL_UNSUBSCRIBE_RK_KEY"), "{error}");
908 assert!(!error.contains(&rk), "key material leaked: {error}");
909 }
910 }
911
912 #[test]
913 fn key_variables_are_ignored_when_senders_are_unset() {
914 let policy = OutboundEmailPolicy::resolve_with_external(
916 None,
917 Some("maintainer@example.com"),
918 None,
919 None,
920 Some("not hex"),
921 Some("garbage"),
922 Some("/nonexistent/footer"),
923 )
924 .unwrap();
925 assert!(!policy.has_external_senders());
926 assert!(policy.recipient_key("a@example.com").is_none());
927 assert!(policy.mint_unsubscribe_token("a@example.com").is_none());
928 assert!(policy.external_footer().is_none());
929 }
930
931 #[test]
932 fn recipient_key_is_normalized_hex_and_stable_across_case() {
933 let policy = enabled();
934 let key = policy.recipient_key("person@example.com").unwrap();
935 assert_eq!(key.len(), 64);
936 assert!(khive_types::is_lowercase_hex(&key));
937 let other_case =
939 khive_types::email_address::normalize_email_recipient("Person@Example.COM").unwrap();
940 assert_eq!(key, policy.recipient_key(&other_case).unwrap());
941 let secret = unhex(&rk());
942 assert_eq!(key, hex_lower(&hmac_sha256(&secret, b"person@example.com")));
943 }
944
945 #[test]
946 fn token_round_trips_and_rejects_tampering() {
947 let policy = enabled();
948 let token = policy.mint_unsubscribe_token("person@example.com").unwrap();
949 let recipient_key = policy.recipient_key("person@example.com").unwrap();
950 assert_eq!(
951 policy.verify_unsubscribe_token(&token).as_deref(),
952 Some(recipient_key.as_str())
953 );
954
955 let mut bytes = URL_SAFE_NO_PAD.decode(&token).unwrap();
957 let last = bytes.len() - 1;
958 bytes[last] ^= 0x01;
959 assert!(policy
960 .verify_unsubscribe_token(&URL_SAFE_NO_PAD.encode(&bytes))
961 .is_none());
962
963 let mut bytes = URL_SAFE_NO_PAD.decode(&token).unwrap();
965 bytes[0] = 0x02;
966 assert!(policy
967 .verify_unsubscribe_token(&URL_SAFE_NO_PAD.encode(&bytes))
968 .is_none());
969
970 let mut bytes = URL_SAFE_NO_PAD.decode(&token).unwrap();
972 bytes[1] = 9;
973 assert!(policy
974 .verify_unsubscribe_token(&URL_SAFE_NO_PAD.encode(&bytes))
975 .is_none());
976
977 assert!(policy.verify_unsubscribe_token("AAAA").is_none());
979 }
980
981 #[test]
982 fn retired_key_ids_keep_verifying_and_the_highest_id_mints() {
983 let policy = enabled();
984 let token_high = policy.mint_unsubscribe_token("person@example.com").unwrap();
986 let decoded = URL_SAFE_NO_PAD.decode(&token_high).unwrap();
987 assert_eq!(decoded[1], 1, "minting must use the highest configured id");
988 assert!(policy.verify_unsubscribe_token(&token_high).is_some());
989
990 let retired = OutboundEmailPolicy::configured_for_tests_with(
992 vec!["allowed@example.com".into()],
993 vec!["actor:sender".into()],
994 5,
995 &rk(),
996 &token_keys_hex(),
997 Ok("footer {unsubscribe}"),
998 )
999 .unwrap();
1000 let mut body = Vec::new();
1002 body.push(0x01u8);
1003 body.push(0u8);
1004 body.extend_from_slice(&unhex(
1005 &retired.recipient_key("person@example.com").unwrap(),
1006 ));
1007 let tag = hmac_sha256(&unhex(&"11".repeat(32)), &body);
1008 body.extend_from_slice(&tag[..16]);
1009 let old_token = URL_SAFE_NO_PAD.encode(&body);
1010 assert_eq!(
1011 retired.verify_unsubscribe_token(&old_token).as_deref(),
1012 Some(
1013 retired
1014 .recipient_key("person@example.com")
1015 .unwrap()
1016 .as_str()
1017 )
1018 );
1019 }
1020
1021 #[test]
1022 fn footer_state_is_recorded_without_a_start_up_error() {
1023 let policy = OutboundEmailPolicy::configured_for_tests_with(
1024 vec!["allowed@example.com".into()],
1025 vec!["actor:sender".into()],
1026 5,
1027 &rk(),
1028 &token_keys_hex(),
1029 Err("KHIVE_EMAIL_EXTERNAL_FOOTER_FILE is not set"),
1030 )
1031 .unwrap();
1032 assert_eq!(
1033 policy.external_footer(),
1034 Some(Err("KHIVE_EMAIL_EXTERNAL_FOOTER_FILE is not set"))
1035 );
1036 }
1037
1038 #[test]
1039 fn footer_file_must_exist_be_readable_and_carry_the_placeholder() {
1040 let missing = read_external_footer(Some("/nonexistent/khive-footer"));
1041 assert!(missing.is_err());
1042 let dir = std::env::temp_dir();
1043 let without = dir.join("khive-footer-without-placeholder.txt");
1044 std::fs::write(&without, "no placeholder here").unwrap();
1045 assert!(read_external_footer(without.to_str()).is_err());
1046 let with = dir.join("khive-footer-with-placeholder.txt");
1047 std::fs::write(&with, "Contact us.\n{unsubscribe}\n").unwrap();
1048 assert_eq!(
1049 read_external_footer(with.to_str()).unwrap().as_ref(),
1050 "Contact us.\n{unsubscribe}\n"
1051 );
1052 assert!(read_external_footer(None).is_err());
1053 }
1054}