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
20const MIRROR_URL_ENV: &str = "KHIVE_EMAIL_SUPPRESSION_MIRROR_URL";
21const MIRROR_TOKEN_ENV: &str = "KHIVE_EMAIL_SUPPRESSION_MIRROR_TOKEN";
22const MIRROR_MAX_AGE_ENV: &str = "KHIVE_EMAIL_SUPPRESSION_MIRROR_MAX_AGE_SECS";
23
24#[derive(Clone)]
28pub struct SuppressionMirror {
29 url: Arc<str>,
30 credential: Arc<str>,
31 max_age: std::time::Duration,
32}
33
34impl SuppressionMirror {
35 pub fn url(&self) -> &str {
37 &self.url
38 }
39
40 pub fn credential(&self) -> &str {
42 &self.credential
43 }
44
45 pub fn max_age(&self) -> std::time::Duration {
47 self.max_age
48 }
49}
50
51#[derive(Clone)]
54pub struct OutboundEmailPolicy {
55 recipients: Option<Arc<[String]>>,
56 external_senders: Option<Arc<[String]>>,
57 external_daily_cap: Option<u64>,
58 recipient_key_secret: Option<Arc<[u8]>>,
61 token_keys: Arc<BTreeMap<u8, Arc<[u8]>>>,
64 external_footer: Option<Result<Arc<str>, Arc<str>>>,
67 suppression_mirror: Option<SuppressionMirror>,
70 external_admission: Arc<tokio::sync::Mutex<()>>,
71}
72
73impl Default for OutboundEmailPolicy {
74 fn default() -> Self {
75 Self {
76 recipients: None,
77 external_senders: None,
78 external_daily_cap: None,
79 recipient_key_secret: None,
80 token_keys: Arc::default(),
81 external_footer: None,
82 suppression_mirror: None,
83 external_admission: Arc::new(tokio::sync::Mutex::new(())),
84 }
85 }
86}
87
88fn hmac_sha256(key: &[u8], data: &[u8]) -> [u8; 32] {
93 const BLOCK: usize = 64;
94 let mut block = [0u8; BLOCK];
95 if key.len() > BLOCK {
96 block[..32].copy_from_slice(&Sha256::digest(key));
97 } else {
98 block[..key.len()].copy_from_slice(key);
99 }
100 let mut inner_pad = [0x36u8; BLOCK];
101 let mut outer_pad = [0x5cu8; BLOCK];
102 for index in 0..BLOCK {
103 inner_pad[index] ^= block[index];
104 outer_pad[index] ^= block[index];
105 }
106 let mut inner = Sha256::new();
107 inner.update(inner_pad);
108 inner.update(data);
109 let inner_digest = inner.finalize();
110 let mut outer = Sha256::new();
111 outer.update(outer_pad);
112 outer.update(inner_digest);
113 let mut mac = [0u8; 32];
114 mac.copy_from_slice(&outer.finalize());
115 mac
116}
117
118#[derive(Clone, Copy, Debug, PartialEq, Eq)]
120pub enum EmailSuppressionScope {
121 Outbound,
122 Inbound,
123}
124
125#[derive(Clone, Copy, Debug, PartialEq, Eq)]
127pub enum EmailSuppressionKind {
128 Address,
129 Domain,
130 RecipientKey,
131}
132
133#[derive(Clone, Debug, PartialEq, Eq)]
135pub struct EmailSuppression {
136 pub scope: EmailSuppressionScope,
137 pub kind: EmailSuppressionKind,
138 pub value: String,
139}
140
141pub fn outbound_email_is_suppressed(
147 normalized_recipient: &str,
148 recipient_key: Option<&str>,
149 entries: &[EmailSuppression],
150) -> bool {
151 let recipient_domain = normalized_recipient
152 .rsplit_once('@')
153 .map(|(_, domain)| domain);
154 entries.iter().any(|entry| {
155 if entry.scope != EmailSuppressionScope::Outbound {
156 return false;
157 }
158 match entry.kind {
159 EmailSuppressionKind::Address => entry.value == normalized_recipient,
160 EmailSuppressionKind::Domain => recipient_domain == Some(entry.value.as_str()),
161 EmailSuppressionKind::RecipientKey => recipient_key == Some(entry.value.as_str()),
162 }
163 })
164}
165
166pub fn inbound_email_is_suppressed(normalized_sender: &str, entries: &[EmailSuppression]) -> bool {
175 let sender_domain = normalized_sender.rsplit_once('@').map(|(_, domain)| domain);
176 entries.iter().any(|entry| {
177 if entry.scope != EmailSuppressionScope::Inbound {
178 return false;
179 }
180 match entry.kind {
181 EmailSuppressionKind::Address => entry.value == normalized_sender,
182 EmailSuppressionKind::Domain => sender_domain == Some(entry.value.as_str()),
183 EmailSuppressionKind::RecipientKey => false,
184 }
185 })
186}
187
188impl OutboundEmailPolicy {
189 pub fn configured(recipients: Vec<String>) -> Result<Self, String> {
191 let recipients = recipients
192 .iter()
193 .map(|value| {
194 khive_types::email_address::normalize_email_recipient(value)
195 .ok_or_else(|| "outbound email policy contains an invalid address".to_string())
196 })
197 .collect::<Result<Vec<_>, _>>()?;
198 Ok(Self {
199 recipients: Some(recipients.into()),
200 ..Self::default()
201 })
202 }
203
204 pub fn is_configured(&self) -> bool {
205 self.recipients.is_some()
206 }
207
208 pub fn allows(&self, recipient: &str) -> bool {
209 self.recipients.as_ref().is_none_or(|recipients| {
210 khive_types::email_address::normalize_email_recipient(recipient)
211 .is_some_and(|recipient| recipients.iter().any(|allowed| allowed == &recipient))
212 })
213 }
214
215 pub fn allows_external_sender(&self, actor_id: &str) -> bool {
216 self.external_senders
217 .as_ref()
218 .is_some_and(|senders| senders.iter().any(|sender| sender == actor_id))
219 }
220
221 pub fn has_external_senders(&self) -> bool {
222 self.external_senders.is_some()
223 }
224
225 pub fn external_daily_cap(&self) -> Option<u64> {
226 self.external_daily_cap
227 }
228
229 pub async fn lock_external_admission(&self) -> tokio::sync::OwnedMutexGuard<()> {
230 Arc::clone(&self.external_admission).lock_owned().await
231 }
232
233 pub fn recipient_key(&self, normalized_recipient: &str) -> Option<String> {
237 self.recipient_key_secret
238 .as_deref()
239 .map(|secret| hex_lower(&hmac_sha256(secret, normalized_recipient.as_bytes())))
240 }
241
242 fn recipient_key_bytes(&self, normalized_recipient: &str) -> Option<[u8; RECIPIENT_KEY_BYTES]> {
244 self.recipient_key_secret
245 .as_deref()
246 .map(|secret| hmac_sha256(secret, normalized_recipient.as_bytes()))
247 }
248
249 pub fn external_footer(&self) -> Option<Result<&str, &str>> {
252 self.external_footer.as_ref().map(|value| {
253 value
254 .as_ref()
255 .map(|text| &**text)
256 .map_err(|reason| &**reason)
257 })
258 }
259
260 pub fn suppression_mirror(&self) -> Option<&SuppressionMirror> {
262 self.suppression_mirror.as_ref()
263 }
264
265 pub fn mint_unsubscribe_token(&self, normalized_recipient: &str) -> Option<String> {
268 let recipient_key = self.recipient_key_bytes(normalized_recipient)?;
269 let (&key_id, key) = self.token_keys.iter().next_back()?;
270 Some(encode_unsubscribe_token(key_id, key, &recipient_key))
271 }
272
273 pub fn verify_unsubscribe_token(&self, token: &str) -> Option<String> {
276 let decoded = URL_SAFE_NO_PAD.decode(token).ok()?;
277 if decoded.len() != UNSUBSCRIBE_TOKEN_BYTES {
278 return None;
279 }
280 if decoded[0] != UNSUBSCRIBE_TOKEN_VERSION {
281 return None;
282 }
283 let key_id = decoded[1];
284 let key = self.token_keys.get(&key_id)?;
285 let (body, tag) = decoded.split_at(decoded.len() - UNSUBSCRIBE_TAG_BYTES);
286 let expected = hmac_sha256(key, body);
287 if !constant_time_eq(&expected[..UNSUBSCRIBE_TAG_BYTES], tag) {
288 return None;
289 }
290 let recipient_key = &body[2..];
291 Some(hex_lower(recipient_key))
292 }
293
294 pub fn from_env() -> Result<Self, String> {
300 fn read(name: &str) -> Result<Option<String>, String> {
301 match std::env::var(name) {
302 Ok(value) => Ok(Some(value)),
303 Err(std::env::VarError::NotPresent) => Ok(None),
304 Err(std::env::VarError::NotUnicode(_)) => {
305 Err(format!("{name} must contain valid Unicode text"))
306 }
307 }
308 }
309 let external_senders = read("KHIVE_EMAIL_EXTERNAL_SENDERS")?;
310 let external_daily_cap = read("KHIVE_EMAIL_EXTERNAL_DAILY_CAP")?;
311 let explicit = read("KHIVE_EMAIL_SEND_ALLOWED_RECIPIENTS")?;
312 let maintainer = read("KHIVE_EMAIL_MAINTAINER_ADDRESS")?;
313 let (recipient_key_secret, token_keys) = if external_senders.is_some() {
314 (
315 read("KHIVE_EMAIL_UNSUBSCRIBE_RK_KEY")?,
316 read("KHIVE_EMAIL_UNSUBSCRIBE_TOKEN_KEYS")?,
317 )
318 } else {
319 (None, None)
320 };
321 let footer_file = if external_senders.is_some() {
322 read("KHIVE_EMAIL_EXTERNAL_FOOTER_FILE")?
323 } else {
324 None
325 };
326 let (mirror_url, mirror_token, mirror_max_age) = if external_senders.is_some() {
327 (
328 read(MIRROR_URL_ENV)?,
329 read(MIRROR_TOKEN_ENV)?,
330 read(MIRROR_MAX_AGE_ENV)?,
331 )
332 } else {
333 (None, None, None)
334 };
335 let mut policy = Self::resolve_with_external(
336 explicit.as_deref(),
337 maintainer.as_deref(),
338 external_senders.as_deref(),
339 external_daily_cap.as_deref(),
340 recipient_key_secret.as_deref(),
341 token_keys.as_deref(),
342 footer_file.as_deref(),
343 )?;
344 policy.suppression_mirror = parse_suppression_mirror(
345 mirror_url.as_deref(),
346 mirror_token.as_deref(),
347 mirror_max_age.as_deref(),
348 )?;
349 Ok(policy)
350 }
351
352 #[cfg(test)]
353 fn resolve(explicit: Option<&str>, maintainer: Option<&str>) -> Result<Self, String> {
354 Self::resolve_with_external(explicit, maintainer, None, None, None, None, None)
355 }
356
357 #[cfg(any(test, debug_assertions))]
360 #[doc(hidden)]
361 pub fn configured_for_tests(
362 recipients: Vec<String>,
363 external_senders: Vec<String>,
364 daily_cap: u64,
365 ) -> Result<Self, String> {
366 let token_keys = format!("0:{},1:{}", "11".repeat(32), "22".repeat(32));
367 Self::configured_for_tests_with(
368 recipients,
369 external_senders,
370 daily_cap,
371 &"cc".repeat(32),
372 &token_keys,
373 Ok("Reply to unsubscribe: {unsubscribe}"),
374 )
375 }
376
377 #[cfg(any(test, debug_assertions))]
380 #[doc(hidden)]
381 #[allow(clippy::too_many_arguments)]
382 pub fn configured_for_tests_with(
383 recipients: Vec<String>,
384 external_senders: Vec<String>,
385 daily_cap: u64,
386 recipient_key_secret_hex: &str,
387 token_keys: &str,
388 footer: Result<&str, &str>,
389 ) -> Result<Self, String> {
390 if daily_cap == 0 {
391 return Err("KHIVE_EMAIL_EXTERNAL_DAILY_CAP must be a positive integer".into());
392 }
393 let external_senders = parse_external_senders(&external_senders.join(","))?;
394 if external_senders.is_empty() {
395 return Err("KHIVE_EMAIL_EXTERNAL_SENDERS must contain at least one actor id".into());
396 }
397 let mut policy = Self::configured(recipients)?;
398 policy.external_senders = Some(external_senders.into());
399 policy.external_daily_cap = Some(daily_cap);
400 let secret = parse_hex_key("KHIVE_EMAIL_UNSUBSCRIBE_RK_KEY", recipient_key_secret_hex)?;
401 policy.recipient_key_secret = Some(Arc::from(secret.as_slice()));
402 policy.token_keys = Arc::new(parse_token_keys(token_keys)?);
403 policy.external_footer = Some(footer.map(Arc::from).map_err(Arc::from));
404 Ok(policy)
405 }
406
407 #[cfg(any(test, debug_assertions))]
409 #[doc(hidden)]
410 pub fn with_external_footer_for_tests(mut self, footer: Result<&str, &str>) -> Self {
411 self.external_footer = Some(footer.map(Arc::from).map_err(Arc::from));
412 self
413 }
414
415 #[cfg(any(test, debug_assertions))]
418 #[doc(hidden)]
419 pub fn with_suppression_mirror_for_tests(
420 mut self,
421 url: &str,
422 credential: &str,
423 max_age: std::time::Duration,
424 ) -> Self {
425 self.suppression_mirror = Some(SuppressionMirror {
426 url: Arc::from(url),
427 credential: Arc::from(credential),
428 max_age,
429 });
430 self
431 }
432
433 #[allow(clippy::too_many_arguments)]
434 fn resolve_with_external(
435 explicit: Option<&str>,
436 maintainer: Option<&str>,
437 external_senders: Option<&str>,
438 external_daily_cap: Option<&str>,
439 recipient_key_secret: Option<&str>,
440 token_keys: Option<&str>,
441 footer_file: Option<&str>,
442 ) -> Result<Self, String> {
443 let senders = external_senders.map(parse_external_senders).transpose()?;
444 let cap = match external_daily_cap {
445 Some(value) => {
446 let parsed = value.parse::<u64>().map_err(|_| {
447 "KHIVE_EMAIL_EXTERNAL_DAILY_CAP must be a positive integer".to_string()
448 })?;
449 if parsed == 0 {
450 return Err("KHIVE_EMAIL_EXTERNAL_DAILY_CAP must be a positive integer".into());
451 }
452 Some(parsed)
453 }
454 None if senders.is_some() => {
455 return Err("KHIVE_EMAIL_EXTERNAL_DAILY_CAP is required when KHIVE_EMAIL_EXTERNAL_SENDERS is set".into());
456 }
457 None => None,
458 };
459 let mut policy = Self::resolve_recipients(explicit, maintainer)?;
460 policy.external_daily_cap = cap;
461 if let Some(senders) = senders {
462 policy.external_senders = Some(senders.into());
463 let recipient_key_secret = parse_hex_key(
464 "KHIVE_EMAIL_UNSUBSCRIBE_RK_KEY",
465 recipient_key_secret.ok_or_else(|| {
466 "KHIVE_EMAIL_UNSUBSCRIBE_RK_KEY is required when KHIVE_EMAIL_EXTERNAL_SENDERS is set".to_string()
467 })?,
468 )?;
469 let token_keys = parse_token_keys(token_keys.ok_or_else(|| {
470 "KHIVE_EMAIL_UNSUBSCRIBE_TOKEN_KEYS is required when KHIVE_EMAIL_EXTERNAL_SENDERS is set".to_string()
471 })?)?;
472 policy.recipient_key_secret = Some(Arc::from(recipient_key_secret.as_slice()));
473 policy.token_keys = Arc::new(token_keys);
474 policy.external_footer = Some(read_external_footer(footer_file));
475 }
476 Ok(policy)
477 }
478
479 fn resolve_recipients(
480 explicit: Option<&str>,
481 maintainer: Option<&str>,
482 ) -> Result<Self, String> {
483 if let Some(policy) = Self::explicit(explicit)? {
484 if let Some(raw) = maintainer {
488 Self::maintainer_primary(raw)?;
489 }
490 return Ok(policy);
491 }
492 Self::maintainer(maintainer, explicit.is_some())
493 }
494
495 fn explicit(raw: Option<&str>) -> Result<Option<Self>, String> {
496 let recipients: Vec<String> = raw
497 .unwrap_or_default()
498 .split(',')
499 .map(str::trim)
500 .filter(|value| !value.is_empty())
501 .map(str::to_owned)
502 .collect();
503 if recipients.is_empty() {
504 return Ok(None);
505 }
506 Self::configured(recipients)
507 .map(Some)
508 .map_err(|_| "KHIVE_EMAIL_SEND_ALLOWED_RECIPIENTS contains an invalid address".into())
509 }
510
511 fn maintainer(raw: Option<&str>, explicit_was_set: bool) -> Result<Self, String> {
512 let Some(raw) = raw else {
513 if explicit_was_set {
514 return Err(
515 "configured outbound email policy must contain at least one address".into(),
516 );
517 }
518 return Ok(Self::default());
519 };
520 Self::configured(vec![Self::maintainer_primary(raw)?])
521 }
522
523 fn maintainer_primary(raw: &str) -> Result<String, String> {
527 let addresses = raw
528 .split(',')
529 .map(str::trim)
530 .filter(|value| !value.is_empty())
531 .map(|value| {
532 khive_types::email_address::normalize_email_recipient(value).ok_or_else(|| {
533 "KHIVE_EMAIL_MAINTAINER_ADDRESS contains an invalid address".to_string()
534 })
535 })
536 .collect::<Result<Vec<_>, _>>()?;
537 let Some(primary) = addresses.into_iter().next() else {
538 return Err("KHIVE_EMAIL_MAINTAINER_ADDRESS must contain at least one address".into());
539 };
540 Ok(primary)
541 }
542}
543
544fn parse_suppression_mirror(
549 url: Option<&str>,
550 credential: Option<&str>,
551 max_age: Option<&str>,
552) -> Result<Option<SuppressionMirror>, String> {
553 let Some(url) = url else {
554 if credential.is_some() {
555 return Err(format!(
556 "{MIRROR_TOKEN_ENV} is set but {MIRROR_URL_ENV} is not"
557 ));
558 }
559 if max_age.is_some() {
560 return Err(format!(
561 "{MIRROR_MAX_AGE_ENV} is set but {MIRROR_URL_ENV} is not"
562 ));
563 }
564 return Ok(None);
565 };
566 let host = url
567 .strip_prefix("https://")
568 .ok_or_else(|| format!("{MIRROR_URL_ENV} must be an https:// URL"))?;
569 if host.is_empty()
570 || host.starts_with('/')
571 || url.chars().any(|c| c.is_whitespace() || c.is_control())
572 {
573 return Err(format!(
574 "{MIRROR_URL_ENV} must be an https:// URL with a host"
575 ));
576 }
577 let credential = credential
578 .ok_or_else(|| format!("{MIRROR_TOKEN_ENV} is required when {MIRROR_URL_ENV} is set"))?;
579 if credential.is_empty() || !credential.bytes().all(|byte| byte.is_ascii_graphic()) {
580 return Err(format!(
581 "{MIRROR_TOKEN_ENV} must be non-empty printable ASCII without spaces"
582 ));
583 }
584 let max_age = max_age
585 .ok_or_else(|| format!("{MIRROR_MAX_AGE_ENV} is required when {MIRROR_URL_ENV} is set"))?;
586 let seconds = max_age
587 .parse::<u64>()
588 .ok()
589 .filter(|seconds| *seconds > 0)
590 .ok_or_else(|| format!("{MIRROR_MAX_AGE_ENV} must be a positive integer"))?;
591 Ok(Some(SuppressionMirror {
592 url: Arc::from(url),
593 credential: Arc::from(credential),
594 max_age: std::time::Duration::from_secs(seconds),
595 }))
596}
597
598fn parse_external_senders(raw: &str) -> Result<Vec<String>, String> {
599 raw.split(',')
600 .map(str::trim)
601 .map(|actor_id| {
602 if actor_id.is_empty() {
603 return Err("KHIVE_EMAIL_EXTERNAL_SENDERS contains an empty actor id".to_string());
604 }
605 khive_types::Namespace::parse(actor_id).map_err(|_| {
606 format!("KHIVE_EMAIL_EXTERNAL_SENDERS contains an invalid actor id {actor_id:?}")
607 })?;
608 Ok(actor_id.to_string())
609 })
610 .collect()
611}
612
613fn parse_hex_key(name: &str, raw: &str) -> Result<Vec<u8>, String> {
616 let value = raw.trim().to_ascii_lowercase();
617 if !value.len().is_multiple_of(2) || !khive_types::is_lowercase_hex(&value) {
618 return Err(format!(
619 "{name} must be hex-encoded key material of at least {MIN_KEY_BYTES} bytes"
620 ));
621 }
622 let mut bytes = Vec::with_capacity(value.len() / 2);
623 for pair in value.as_bytes().chunks_exact(2) {
624 let high = (pair[0] as char)
625 .to_digit(16)
626 .expect("validated lowercase hex");
627 let low = (pair[1] as char)
628 .to_digit(16)
629 .expect("validated lowercase hex");
630 bytes.push((high * 16 + low) as u8);
631 }
632 if bytes.len() < MIN_KEY_BYTES {
633 return Err(format!(
634 "{name} must be hex-encoded key material of at least {MIN_KEY_BYTES} bytes"
635 ));
636 }
637 Ok(bytes)
638}
639
640fn parse_token_keys(raw: &str) -> Result<BTreeMap<u8, Arc<[u8]>>, String> {
643 const NAME: &str = "KHIVE_EMAIL_UNSUBSCRIBE_TOKEN_KEYS";
644 let mut keys = BTreeMap::new();
645 for pair in raw.split(',') {
646 let pair = pair.trim();
647 if pair.is_empty() {
648 return Err(format!("{NAME} contains an empty `id:key` pair"));
649 }
650 let Some((id_text, key_text)) = pair.split_once(':') else {
651 return Err(format!("{NAME} entries must be `id:key` pairs"));
652 };
653 let id = id_text
654 .trim()
655 .parse::<u16>()
656 .ok()
657 .filter(|id| *id <= u8::MAX as u16)
658 .ok_or_else(|| format!("{NAME} key ids must be decimal integers 0-255"))?
659 as u8;
660 if keys.contains_key(&id) {
661 return Err(format!("{NAME} contains a duplicate key id"));
662 }
663 let key = parse_hex_key(NAME, key_text)?;
664 keys.insert(id, Arc::from(key.as_slice()));
665 }
666 if keys.is_empty() {
667 return Err(format!("{NAME} must contain at least one `id:key` pair"));
668 }
669 Ok(keys)
670}
671
672fn read_external_footer(path: Option<&str>) -> Result<Arc<str>, Arc<str>> {
676 let Some(path) = path else {
677 return Err(Arc::from("KHIVE_EMAIL_EXTERNAL_FOOTER_FILE is not set"));
678 };
679 let text = std::fs::read_to_string(path)
680 .map_err(|_| Arc::from("KHIVE_EMAIL_EXTERNAL_FOOTER_FILE could not be read"))?;
681 if !text.contains(UNSUBSCRIBE_PLACEHOLDER) {
682 return Err(Arc::from(
683 "KHIVE_EMAIL_EXTERNAL_FOOTER_FILE does not contain {unsubscribe}",
684 ));
685 }
686 Ok(Arc::from(text))
687}
688
689fn encode_unsubscribe_token(
694 key_id: u8,
695 key: &[u8],
696 recipient_key: &[u8; RECIPIENT_KEY_BYTES],
697) -> String {
698 let mut token = Vec::with_capacity(UNSUBSCRIBE_TOKEN_BYTES);
699 token.push(UNSUBSCRIBE_TOKEN_VERSION);
700 token.push(key_id);
701 token.extend_from_slice(recipient_key);
702 let tag = hmac_sha256(key, &token);
703 token.extend_from_slice(&tag[..UNSUBSCRIBE_TAG_BYTES]);
704 URL_SAFE_NO_PAD.encode(token)
705}
706
707fn hex_lower(bytes: &[u8]) -> String {
708 const DIGITS: &[u8; 16] = b"0123456789abcdef";
709 let mut out = String::with_capacity(bytes.len() * 2);
710 for byte in bytes {
711 out.push(DIGITS[(byte >> 4) as usize] as char);
712 out.push(DIGITS[(byte & 0x0f) as usize] as char);
713 }
714 out
715}
716
717fn constant_time_eq(left: &[u8], right: &[u8]) -> bool {
719 if left.len() != right.len() {
720 return false;
721 }
722 let mut diff = 0u8;
723 for (a, b) in left.iter().zip(right.iter()) {
724 diff |= a ^ b;
725 }
726 diff == 0
727}
728
729#[cfg(test)]
730mod tests {
731 use super::*;
732
733 #[test]
734 fn explicit_policy_and_requests_share_maintainer_normalization() {
735 let policy =
736 OutboundEmailPolicy::explicit(Some(" , First@Example.com , second@example.com,,"))
737 .unwrap()
738 .unwrap();
739 assert!(policy.allows("First@Example.com"));
740 assert!(policy.allows("second@example.com"));
741 assert!(policy.allows("first@example.com"));
742 assert!(policy.allows(" First@Example.com "));
743 assert!(policy.allows("First <FIRST@EXAMPLE.COM>"));
744 assert!(!policy.allows("other@example.com"));
745 assert!(!policy.allows("not-an-address"));
746 assert!(policy.is_configured());
747 assert_eq!(
748 policy.recipients.as_deref().unwrap(),
749 ["first@example.com", "second@example.com"]
750 );
751 assert!(OutboundEmailPolicy::explicit(Some(" , , "))
752 .unwrap()
753 .is_none());
754 assert!(OutboundEmailPolicy::explicit(Some("not-an-address")).is_err());
755 }
756
757 #[test]
758 fn fallback_validates_all_maintainers_and_allows_only_normalized_primary() {
759 let policy = OutboundEmailPolicy::maintainer(
760 Some("Owner <Primary@Example.com>, second@example.com"),
761 false,
762 )
763 .unwrap();
764 assert!(policy.allows("primary@example.com"));
765 assert!(policy.allows("Primary@Example.com"));
766 assert!(policy.allows("Owner <PRIMARY@EXAMPLE.COM>"));
767 assert!(!policy.allows("second@example.com"));
768 assert!(
769 OutboundEmailPolicy::maintainer(Some("primary@example.com,invalid"), false).is_err()
770 );
771 assert!(OutboundEmailPolicy::maintainer(Some(" , "), false).is_err());
772 assert!(OutboundEmailPolicy::maintainer(None, true).is_err());
773 assert!(
774 OutboundEmailPolicy::maintainer(Some("primary@example.com"), true)
775 .unwrap()
776 .allows("PRIMARY@example.com")
777 );
778 assert!(OutboundEmailPolicy::maintainer(None, false)
779 .unwrap()
780 .allows("backlog@example.com"));
781 assert!(!OutboundEmailPolicy::default().is_configured());
782 let deny_all = OutboundEmailPolicy::configured(vec![]).unwrap();
783 assert!(deny_all.is_configured());
784 assert!(!deny_all.allows("backlog@example.com"));
785 assert!(OutboundEmailPolicy::configured(vec!["invalid".into()]).is_err());
786 }
787
788 #[test]
789 fn present_maintainer_is_validated_beside_an_explicit_list_and_an_unset_one_is_not() {
790 let resolve = OutboundEmailPolicy::resolve;
791 let explicit = Some("allowed@example.com");
792 for maintainer in [
793 "",
794 " , ",
795 "primary@example.com,invalid",
796 "invalid,primary@example.com",
797 ] {
798 assert!(
799 resolve(explicit, Some(maintainer)).is_err(),
800 "maintainer {maintainer:?} beside a valid explicit list must be refused"
801 );
802 }
803 let unset = resolve(explicit, None).unwrap();
804 assert!(unset.is_configured());
805 assert!(unset.allows("ALLOWED@example.com"));
806 assert!(!unset.allows("primary@example.com"));
807 let list = Some("Owner <Primary@Example.com>, second@example.com");
808 let valid = resolve(explicit, list).unwrap();
809 assert!(valid.allows("allowed@example.com"));
810 assert!(!valid.allows("primary@example.com"));
811 let blank = Some(" , ");
812 let fallback = resolve(blank, list).unwrap();
813 assert!(fallback.allows("primary@example.com"));
814 assert!(!fallback.allows("second@example.com"));
815 assert!(resolve(blank, None).is_err());
816 assert!(resolve(Some("not-an-address"), None).is_err());
817 assert!(resolve(None, Some("primary@example.com,invalid")).is_err());
818 assert!(!resolve(None, None).unwrap().is_configured());
819 }
820
821 #[test]
822 fn comparison_folds_ascii_case_only() {
823 let policy = OutboundEmailPolicy::configured(vec!["kevin@example.com".into()]).unwrap();
824 assert!(!policy.allows("\u{212A}evin@example.com"));
827 assert!(policy.allows("KEVIN@Example.COM"));
828 assert!(policy.allows("kevin@example.com"));
829 }
830
831 #[test]
832 fn configured_entries_and_requests_share_one_normalization() {
833 let policy =
836 OutboundEmailPolicy::configured(vec!["\u{212A}evin@Example.COM".into()]).unwrap();
837 assert_eq!(
838 policy.recipients.as_deref().unwrap(),
839 ["\u{212A}evin@example.com"]
840 );
841 assert!(policy.allows("\u{212A}evin@EXAMPLE.com"));
842 assert!(!policy.allows("kevin@example.com"));
843 let accented =
844 OutboundEmailPolicy::maintainer(Some("\u{C9}mile@Example.com"), false).unwrap();
845 assert!(accented.allows("\u{C9}mile@example.COM"));
846 assert!(!accented.allows("\u{E9}mile@example.com"));
847 }
848
849 #[test]
850 fn suppression_matching_checks_exact_address_domain_and_recipient_key() {
851 let address = [EmailSuppression {
852 scope: EmailSuppressionScope::Outbound,
853 kind: EmailSuppressionKind::Address,
854 value: "a@example.com".into(),
855 }];
856 assert!(outbound_email_is_suppressed(
857 "a@example.com",
858 None,
859 &address
860 ));
861 assert!(!outbound_email_is_suppressed(
862 "b@example.com",
863 None,
864 &address
865 ));
866
867 let entries = [
868 EmailSuppression {
869 scope: EmailSuppressionScope::Outbound,
870 kind: EmailSuppressionKind::Address,
871 value: "a@example.com".into(),
872 },
873 EmailSuppression {
874 scope: EmailSuppressionScope::Outbound,
875 kind: EmailSuppressionKind::Domain,
876 value: "x.com".into(),
877 },
878 EmailSuppression {
879 scope: EmailSuppressionScope::Outbound,
880 kind: EmailSuppressionKind::RecipientKey,
881 value: "key-1".into(),
882 },
883 EmailSuppression {
884 scope: EmailSuppressionScope::Inbound,
885 kind: EmailSuppressionKind::Address,
886 value: "b@example.com".into(),
887 },
888 ];
889
890 assert!(outbound_email_is_suppressed(
891 "a@example.com",
892 None,
893 &entries
894 ));
895 assert!(outbound_email_is_suppressed("b@x.com", None, &entries));
896 assert!(!outbound_email_is_suppressed("b@sub.x.com", None, &entries));
897 assert!(outbound_email_is_suppressed(
898 "c@example.com",
899 Some("key-1"),
900 &entries
901 ));
902 assert!(!outbound_email_is_suppressed(
903 "c@example.com",
904 Some("key-2"),
905 &entries
906 ));
907 assert!(!outbound_email_is_suppressed(
908 "b@example.com",
909 None,
910 &entries
911 ));
912 }
913
914 #[test]
915 fn inbound_matching_checks_exact_address_and_domain_and_ignores_recipient_keys() {
916 let entries = [
917 EmailSuppression {
918 scope: EmailSuppressionScope::Inbound,
919 kind: EmailSuppressionKind::Address,
920 value: "a@example.com".into(),
921 },
922 EmailSuppression {
923 scope: EmailSuppressionScope::Inbound,
924 kind: EmailSuppressionKind::Domain,
925 value: "blocked.com".into(),
926 },
927 EmailSuppression {
928 scope: EmailSuppressionScope::Inbound,
929 kind: EmailSuppressionKind::RecipientKey,
930 value: "key-1".into(),
931 },
932 EmailSuppression {
934 scope: EmailSuppressionScope::Outbound,
935 kind: EmailSuppressionKind::Address,
936 value: "c@example.com".into(),
937 },
938 ];
939
940 assert!(inbound_email_is_suppressed("a@example.com", &entries));
941 assert!(!inbound_email_is_suppressed("b@example.com", &entries));
942 assert!(inbound_email_is_suppressed("z@blocked.com", &entries));
943 assert!(!inbound_email_is_suppressed("z@sub.blocked.com", &entries));
944 assert!(!inbound_email_is_suppressed("c@example.com", &entries));
945 assert!(!inbound_email_is_suppressed("key-1", &entries));
946 }
947
948 #[test]
949 fn cloned_policy_keeps_the_same_immutable_recipient_set() {
950 let policy = OutboundEmailPolicy::configured(vec!["allowed@example.com".into()]).unwrap();
951 let clone = policy.clone();
952 assert!(Arc::ptr_eq(
953 policy.recipients.as_ref().unwrap(),
954 clone.recipients.as_ref().unwrap()
955 ));
956 }
957
958 fn rk() -> String {
959 "cc".repeat(32) }
961
962 fn token_keys_hex() -> String {
963 format!("0:{},1:{}", "11".repeat(32), "22".repeat(32))
964 }
965
966 fn enabled() -> OutboundEmailPolicy {
967 OutboundEmailPolicy::configured_for_tests_with(
968 vec!["allowed@example.com".into()],
969 vec!["actor:sender".into()],
970 5,
971 &rk(),
972 &token_keys_hex(),
973 Ok("Reply to unsubscribe: {unsubscribe}"),
974 )
975 .unwrap()
976 }
977
978 fn unhex(hex: &str) -> Vec<u8> {
979 hex.as_bytes()
980 .chunks_exact(2)
981 .map(|pair| {
982 let high = (pair[0] as char).to_digit(16).unwrap();
983 let low = (pair[1] as char).to_digit(16).unwrap();
984 (high * 16 + low) as u8
985 })
986 .collect()
987 }
988
989 #[test]
990 fn hmac_sha256_matches_rfc_4231_vectors() {
991 let case1 = hmac_sha256(&[0x0b; 20], b"Hi There");
992 assert_eq!(
993 hex_lower(&case1),
994 "b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7"
995 );
996 let case2 = hmac_sha256(b"Jefe", b"what do ya want for nothing?");
997 assert_eq!(
998 hex_lower(&case2),
999 "5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843"
1000 );
1001 let case6 = hmac_sha256(
1002 &[0xaa; 131],
1003 b"Test Using Larger Than Block-Size Key - Hash Key First",
1004 );
1005 assert_eq!(
1006 hex_lower(&case6),
1007 "60e431591ee0b67f0d8a26aacbf5b77f8e0bc6213728c5140546040f0ee37f54"
1008 );
1009 }
1010
1011 #[test]
1012 fn key_configuration_refuses_malformed_values_when_senders_are_set() {
1013 let missing_rk = OutboundEmailPolicy::resolve_with_external(
1015 None,
1016 Some("maintainer@example.com"),
1017 Some("actor:sender"),
1018 Some("5"),
1019 None,
1020 Some(&token_keys_hex()),
1021 None,
1022 )
1023 .err()
1024 .expect("start-up policy is refused");
1025 assert!(
1026 missing_rk.contains("KHIVE_EMAIL_UNSUBSCRIBE_RK_KEY"),
1027 "{missing_rk}"
1028 );
1029
1030 let missing_tokens = OutboundEmailPolicy::resolve_with_external(
1032 None,
1033 Some("maintainer@example.com"),
1034 Some("actor:sender"),
1035 Some("5"),
1036 Some(&rk()),
1037 None,
1038 None,
1039 )
1040 .err()
1041 .expect("start-up policy is refused");
1042 assert!(
1043 missing_tokens.contains("KHIVE_EMAIL_UNSUBSCRIBE_TOKEN_KEYS"),
1044 "{missing_tokens}"
1045 );
1046
1047 for (rk, tokens, needle) in [
1048 (
1049 rk(),
1050 "0:nothex".to_string(),
1051 "KHIVE_EMAIL_UNSUBSCRIBE_TOKEN_KEYS",
1052 ),
1053 (rk(), "0".to_string(), "KHIVE_EMAIL_UNSUBSCRIBE_TOKEN_KEYS"),
1054 (
1055 rk(),
1056 "0:abcd".to_string(),
1057 "KHIVE_EMAIL_UNSUBSCRIBE_TOKEN_KEYS",
1058 ),
1059 (
1060 rk(),
1061 format!("0:{},0:{}", "11".repeat(32), "22".repeat(32)),
1062 "KHIVE_EMAIL_UNSUBSCRIBE_TOKEN_KEYS",
1063 ),
1064 (
1065 rk(),
1066 format!("256:{}", "11".repeat(32)),
1067 "KHIVE_EMAIL_UNSUBSCRIBE_TOKEN_KEYS",
1068 ),
1069 ] {
1070 let error = OutboundEmailPolicy::resolve_with_external(
1071 None,
1072 Some("maintainer@example.com"),
1073 Some("actor:sender"),
1074 Some("5"),
1075 Some(rk.as_str()),
1076 Some(tokens.as_str()),
1077 None,
1078 )
1079 .err()
1080 .expect("start-up policy is refused");
1081 assert!(error.contains(needle), "{error}");
1082 assert!(
1083 !error.contains(&"11".repeat(32)),
1084 "key material leaked: {error}"
1085 );
1086 }
1087
1088 for rk in ["cc".repeat(31), "cc".repeat(16), "zz".repeat(32)] {
1090 let error = OutboundEmailPolicy::resolve_with_external(
1091 None,
1092 Some("maintainer@example.com"),
1093 Some("actor:sender"),
1094 Some("5"),
1095 Some(&rk),
1096 Some(&token_keys_hex()),
1097 None,
1098 )
1099 .err()
1100 .expect("start-up policy is refused");
1101 assert!(error.contains("KHIVE_EMAIL_UNSUBSCRIBE_RK_KEY"), "{error}");
1102 assert!(!error.contains(&rk), "key material leaked: {error}");
1103 }
1104 }
1105
1106 #[test]
1107 fn key_variables_are_ignored_when_senders_are_unset() {
1108 let policy = OutboundEmailPolicy::resolve_with_external(
1110 None,
1111 Some("maintainer@example.com"),
1112 None,
1113 None,
1114 Some("not hex"),
1115 Some("garbage"),
1116 Some("/nonexistent/footer"),
1117 )
1118 .unwrap();
1119 assert!(!policy.has_external_senders());
1120 assert!(policy.recipient_key("a@example.com").is_none());
1121 assert!(policy.mint_unsubscribe_token("a@example.com").is_none());
1122 assert!(policy.external_footer().is_none());
1123 }
1124
1125 #[test]
1126 fn recipient_key_is_normalized_hex_and_stable_across_case() {
1127 let policy = enabled();
1128 let key = policy.recipient_key("person@example.com").unwrap();
1129 assert_eq!(key.len(), 64);
1130 assert!(khive_types::is_lowercase_hex(&key));
1131 let other_case =
1133 khive_types::email_address::normalize_email_recipient("Person@Example.COM").unwrap();
1134 assert_eq!(key, policy.recipient_key(&other_case).unwrap());
1135 let secret = unhex(&rk());
1136 assert_eq!(key, hex_lower(&hmac_sha256(&secret, b"person@example.com")));
1137 }
1138
1139 #[test]
1140 fn token_round_trips_and_rejects_tampering() {
1141 let policy = enabled();
1142 let token = policy.mint_unsubscribe_token("person@example.com").unwrap();
1143 let recipient_key = policy.recipient_key("person@example.com").unwrap();
1144 assert_eq!(
1145 policy.verify_unsubscribe_token(&token).as_deref(),
1146 Some(recipient_key.as_str())
1147 );
1148
1149 let mut bytes = URL_SAFE_NO_PAD.decode(&token).unwrap();
1151 let last = bytes.len() - 1;
1152 bytes[last] ^= 0x01;
1153 assert!(policy
1154 .verify_unsubscribe_token(&URL_SAFE_NO_PAD.encode(&bytes))
1155 .is_none());
1156
1157 let mut bytes = URL_SAFE_NO_PAD.decode(&token).unwrap();
1159 bytes[0] = 0x02;
1160 assert!(policy
1161 .verify_unsubscribe_token(&URL_SAFE_NO_PAD.encode(&bytes))
1162 .is_none());
1163
1164 let mut bytes = URL_SAFE_NO_PAD.decode(&token).unwrap();
1166 bytes[1] = 9;
1167 assert!(policy
1168 .verify_unsubscribe_token(&URL_SAFE_NO_PAD.encode(&bytes))
1169 .is_none());
1170
1171 assert!(policy.verify_unsubscribe_token("AAAA").is_none());
1173 }
1174
1175 const VECTOR_KEY_ID: u8 = 3;
1179 const VECTOR_TOKEN_KEY_HEX: &str =
1180 "efaaa78a85da8a505a5278b5ad7d202a8b5028bb0b279c7a01f410c0ff84c09d";
1181 const VECTOR_RECIPIENT_KEY_HEX: &str =
1182 "cd5ac2457bd4442f53a23cfa9972cc3cdf3d0f1bcf1722bd5503ab20acfa3930";
1183 const VECTOR_TOKEN: &str =
1184 "AQPNWsJFe9REL1OiPPqZcsw83z0PG88XIr1VA6sgrPo5MFRThmznJxzUr1otDEzmcaE";
1185 const VECTOR_TOKEN_FLIPPED_TAG: &str =
1186 "AQPNWsJFe9REL1OiPPqZcsw83z0PG88XIr1VA6sgrPo5MFRThmznJxzUr1otDEzmcaA";
1187
1188 #[test]
1189 fn shared_vector_mints_and_verifies_byte_for_byte() {
1190 let recipient_key: [u8; RECIPIENT_KEY_BYTES] = unhex(VECTOR_RECIPIENT_KEY_HEX)
1191 .try_into()
1192 .expect("the vector recipient key is 32 bytes");
1193 assert_eq!(
1194 encode_unsubscribe_token(VECTOR_KEY_ID, &unhex(VECTOR_TOKEN_KEY_HEX), &recipient_key),
1195 VECTOR_TOKEN,
1196 "minting the vector inputs must reproduce the vector token"
1197 );
1198
1199 let policy = OutboundEmailPolicy::configured_for_tests_with(
1200 vec!["allowed@example.com".into()],
1201 vec!["actor:sender".into()],
1202 5,
1203 &rk(),
1204 &format!("{VECTOR_KEY_ID}:{VECTOR_TOKEN_KEY_HEX}"),
1205 Ok("Reply to unsubscribe: {unsubscribe}"),
1206 )
1207 .unwrap();
1208 assert_eq!(
1209 policy.verify_unsubscribe_token(VECTOR_TOKEN).as_deref(),
1210 Some(VECTOR_RECIPIENT_KEY_HEX),
1211 "the vector token verifies to the vector recipient key"
1212 );
1213 assert!(
1214 policy
1215 .verify_unsubscribe_token(VECTOR_TOKEN_FLIPPED_TAG)
1216 .is_none(),
1217 "the vector token with one tag bit flipped does not verify"
1218 );
1219 }
1220
1221 #[test]
1222 fn retired_key_ids_keep_verifying_and_the_highest_id_mints() {
1223 let policy = enabled();
1224 let token_high = policy.mint_unsubscribe_token("person@example.com").unwrap();
1226 let decoded = URL_SAFE_NO_PAD.decode(&token_high).unwrap();
1227 assert_eq!(decoded[1], 1, "minting must use the highest configured id");
1228 assert!(policy.verify_unsubscribe_token(&token_high).is_some());
1229
1230 let retired = OutboundEmailPolicy::configured_for_tests_with(
1232 vec!["allowed@example.com".into()],
1233 vec!["actor:sender".into()],
1234 5,
1235 &rk(),
1236 &token_keys_hex(),
1237 Ok("footer {unsubscribe}"),
1238 )
1239 .unwrap();
1240 let mut body = Vec::new();
1242 body.push(0x01u8);
1243 body.push(0u8);
1244 body.extend_from_slice(&unhex(
1245 &retired.recipient_key("person@example.com").unwrap(),
1246 ));
1247 let tag = hmac_sha256(&unhex(&"11".repeat(32)), &body);
1248 body.extend_from_slice(&tag[..16]);
1249 let old_token = URL_SAFE_NO_PAD.encode(&body);
1250 assert_eq!(
1251 retired.verify_unsubscribe_token(&old_token).as_deref(),
1252 Some(
1253 retired
1254 .recipient_key("person@example.com")
1255 .unwrap()
1256 .as_str()
1257 )
1258 );
1259 }
1260
1261 #[test]
1262 fn footer_state_is_recorded_without_a_start_up_error() {
1263 let policy = OutboundEmailPolicy::configured_for_tests_with(
1264 vec!["allowed@example.com".into()],
1265 vec!["actor:sender".into()],
1266 5,
1267 &rk(),
1268 &token_keys_hex(),
1269 Err("KHIVE_EMAIL_EXTERNAL_FOOTER_FILE is not set"),
1270 )
1271 .unwrap();
1272 assert_eq!(
1273 policy.external_footer(),
1274 Some(Err("KHIVE_EMAIL_EXTERNAL_FOOTER_FILE is not set"))
1275 );
1276 }
1277
1278 #[test]
1279 fn footer_file_must_exist_be_readable_and_carry_the_placeholder() {
1280 let missing = read_external_footer(Some("/nonexistent/khive-footer"));
1281 assert!(missing.is_err());
1282 let dir = std::env::temp_dir();
1283 let without = dir.join("khive-footer-without-placeholder.txt");
1284 std::fs::write(&without, "no placeholder here").unwrap();
1285 assert!(read_external_footer(without.to_str()).is_err());
1286 let with = dir.join("khive-footer-with-placeholder.txt");
1287 std::fs::write(&with, "Contact us.\n{unsubscribe}\n").unwrap();
1288 assert_eq!(
1289 read_external_footer(with.to_str()).unwrap().as_ref(),
1290 "Contact us.\n{unsubscribe}\n"
1291 );
1292 assert!(read_external_footer(None).is_err());
1293 }
1294
1295 #[test]
1296 fn suppression_mirror_variables_parse_as_one_unit() {
1297 let token = "mirror-read-credential-value";
1298 assert!(parse_suppression_mirror(None, None, None)
1299 .unwrap()
1300 .is_none());
1301 let mirror = parse_suppression_mirror(
1302 Some("https://mirror.example.com/admin/email-suppression"),
1303 Some(token),
1304 Some("900"),
1305 )
1306 .unwrap()
1307 .expect("a complete mirror configuration parses");
1308 assert_eq!(
1309 mirror.url(),
1310 "https://mirror.example.com/admin/email-suppression"
1311 );
1312 assert_eq!(mirror.credential(), token);
1313 assert_eq!(mirror.max_age(), std::time::Duration::from_secs(900));
1314
1315 let url = Some("https://mirror.example.com/x");
1316 for (url, credential, max_age, needle) in [
1317 (
1318 None,
1319 Some(token),
1320 None,
1321 "KHIVE_EMAIL_SUPPRESSION_MIRROR_TOKEN is set but",
1322 ),
1323 (
1324 None,
1325 None,
1326 Some("60"),
1327 "KHIVE_EMAIL_SUPPRESSION_MIRROR_MAX_AGE_SECS is set but",
1328 ),
1329 (
1330 url,
1331 None,
1332 Some("60"),
1333 "KHIVE_EMAIL_SUPPRESSION_MIRROR_TOKEN is required",
1334 ),
1335 (
1336 url,
1337 Some(token),
1338 None,
1339 "KHIVE_EMAIL_SUPPRESSION_MIRROR_MAX_AGE_SECS is required",
1340 ),
1341 (url, Some(token), Some("0"), "must be a positive integer"),
1342 (url, Some(token), Some("-5"), "must be a positive integer"),
1343 (url, Some(token), Some("ten"), "must be a positive integer"),
1344 (
1345 url,
1346 Some(""),
1347 Some("60"),
1348 "must be non-empty printable ASCII",
1349 ),
1350 (
1351 url,
1352 Some("two words"),
1353 Some("60"),
1354 "must be non-empty printable ASCII",
1355 ),
1356 (
1357 Some("http://mirror.example.com/x"),
1358 Some(token),
1359 Some("60"),
1360 "must be an https:// URL",
1361 ),
1362 (
1363 Some("HTTPS://mirror.example.com/x"),
1364 Some(token),
1365 Some("60"),
1366 "must be an https:// URL",
1367 ),
1368 (Some("https://"), Some(token), Some("60"), "with a host"),
1369 (
1370 Some("https:///path"),
1371 Some(token),
1372 Some("60"),
1373 "with a host",
1374 ),
1375 (
1376 Some("https://mirror.example.com/a b"),
1377 Some(token),
1378 Some("60"),
1379 "with a host",
1380 ),
1381 ] {
1382 let error = parse_suppression_mirror(url, credential, max_age)
1383 .err()
1384 .expect("a partial or malformed mirror configuration is refused");
1385 assert!(error.contains(needle), "{error}");
1386 assert!(!error.contains(token), "credential leaked: {error}");
1387 }
1388 }
1389
1390 #[test]
1391 fn suppression_mirror_is_off_unless_configured() {
1392 assert!(enabled().suppression_mirror().is_none());
1393 assert!(OutboundEmailPolicy::default()
1394 .suppression_mirror()
1395 .is_none());
1396 }
1397}