Skip to main content

khive_runtime/
outbound_email_policy.rs

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
7/// The unsubscribe token format version carried as the first token byte.
8const UNSUBSCRIBE_TOKEN_VERSION: u8 = 0x01;
9/// A raw recipient key is one SHA-256 digest.
10const RECIPIENT_KEY_BYTES: usize = 32;
11/// The truncated HMAC tag length carried by a token.
12const UNSUBSCRIBE_TAG_BYTES: usize = 16;
13/// A token is `0x01 || key_id || recipient_key || tag`, base64url (no padding).
14const UNSUBSCRIBE_TOKEN_BYTES: usize = 1 + 1 + RECIPIENT_KEY_BYTES + UNSUBSCRIBE_TAG_BYTES;
15/// The placeholder the operator's footer must carry for the mailto instruction.
16const UNSUBSCRIBE_PLACEHOLDER: &str = "{unsubscribe}";
17/// A recipient-key secret and every token key must decode to at least this many bytes.
18const 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/// The remote suppression mirror (ADR-200 §6): the URL that answers the complete set of
25/// suppressed recipient keys, the bearer credential that reads it, and how old the last
26/// successful refresh may be before external admission refuses.
27#[derive(Clone)]
28pub struct SuppressionMirror {
29    url: Arc<str>,
30    credential: Arc<str>,
31    max_age: std::time::Duration,
32}
33
34impl SuppressionMirror {
35    /// The URL the delivery component reads the complete remote set from.
36    pub fn url(&self) -> &str {
37        &self.url
38    }
39
40    /// The bearer credential for the read. It is never written to a log or an error.
41    pub fn credential(&self) -> &str {
42        &self.credential
43    }
44
45    /// The age past which the last successful refresh no longer admits an external message.
46    pub fn max_age(&self) -> std::time::Duration {
47        self.max_age
48    }
49}
50
51/// Boot-resolved recipient policy shared by comm admission and email delivery.
52/// An absent policy permits queuing without claiming that delivery is configured.
53#[derive(Clone)]
54pub struct OutboundEmailPolicy {
55    recipients: Option<Arc<[String]>>,
56    external_senders: Option<Arc<[String]>>,
57    external_daily_cap: Option<u64>,
58    /// The recipient-key secret. Present only when external senders are configured; the derived
59    /// key for an address is `HMAC-SHA256(K_rk, normalized address)` in lowercase hex.
60    recipient_key_secret: Option<Arc<[u8]>>,
61    /// Token keys by key id, ascending. Every retired id stays present so a link in an old
62    /// message keeps verifying; the highest id mints new tokens.
63    token_keys: Arc<BTreeMap<u8, Arc<[u8]>>>,
64    /// The operator's external footer text, or the reason it is unavailable. `None` while
65    /// external senders are unset, in which case no footer is ever consulted.
66    external_footer: Option<Result<Arc<str>, Arc<str>>>,
67    /// The remote suppression mirror. `None` when it is not configured or external senders are
68    /// unset.
69    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
88/// HMAC-SHA256 (RFC 2104) over `sha2::Sha256`.
89///
90/// The 64-byte block is fixed for SHA-256. A key longer than the block is hashed first; a
91/// shorter one is zero-padded to the block. Returns the full 32-byte MAC.
92fn 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/// The direction stored on one email suppression entry.
119#[derive(Clone, Copy, Debug, PartialEq, Eq)]
120pub enum EmailSuppressionScope {
121    Outbound,
122    Inbound,
123}
124
125/// The identity form stored on one email suppression entry.
126#[derive(Clone, Copy, Debug, PartialEq, Eq)]
127pub enum EmailSuppressionKind {
128    Address,
129    Domain,
130    RecipientKey,
131}
132
133/// The matching fields needed by outbound admission and delivery.
134#[derive(Clone, Debug, PartialEq, Eq)]
135pub struct EmailSuppression {
136    pub scope: EmailSuppressionScope,
137    pub kind: EmailSuppressionKind,
138    pub value: String,
139}
140
141/// Check one already-normalized recipient against the suppression rows supplied by the caller.
142///
143/// Address normalization is shared with [`OutboundEmailPolicy`]; callers must use
144/// `khive_types::email_address::normalize_email_recipient` before calling this function.
145/// Domain comparison is exact, and recipient keys compare byte-for-byte.
146pub 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
166/// Check one already-normalized inbound sender address against the suppression rows supplied by
167/// the caller. Only `inbound` entries can match, and only `address` and `domain` kinds can: an
168/// inbound sender has no recipient key. Address comparison is exact after normalization, and
169/// domain comparison is exact with no subdomain wildcard, so a `domain` entry for `example.com`
170/// never blocks `user@sub.example.com`.
171///
172/// Callers must normalize the sender address with
173/// `khive_types::email_address::normalize_email_recipient` before calling this function.
174pub 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    /// Install a normalized recipient set; an explicitly empty set denies all recipients.
190    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    /// The recipient key a `recipient_key` suppression entry matches, derived on demand as
234    /// `HMAC-SHA256(K_rk, normalized address)` in lowercase hex; the raw bytes are never stored.
235    /// `None` when no recipient-key secret is configured (external senders unset).
236    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    /// The recipient key's raw 32 bytes, used as the token body.
243    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    /// The footer text for external messages, or the reason it is unavailable. `None` while
250    /// external senders are unset.
251    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    /// The remote suppression mirror, when one is configured (ADR-200 §6).
261    pub fn suppression_mirror(&self) -> Option<&SuppressionMirror> {
262        self.suppression_mirror.as_ref()
263    }
264
265    /// Mint the unsubscribe token for a normalized external recipient, or `None` when the
266    /// recipient-key secret or any token key is unavailable. The highest configured key id mints.
267    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    /// Verify an unsubscribe token and return the recipient key text it identifies, or `None`
274    /// on any malformed input or tag mismatch. Retired key ids keep verifying.
275    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    /// Read only public policy inputs, without loading a connector or credentials.
295    ///
296    /// The unsubscribe key variables, the footer file and the suppression mirror variables are
297    /// read only when external senders are configured; while the senders variable is unset none
298    /// of them is consulted.
299    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    /// Build a fully enabled policy for debug-build tests: recipients, the external sender
358    /// set, the daily cap, fixed test key material and a footer carrying `{unsubscribe}`.
359    #[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    /// Build an enabled policy with explicit key material and footer state for tests that need
378    /// to exercise configuration or footer unavailability.
379    #[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    /// Replace the footer state on a test policy (available/unavailable) without touching keys.
408    #[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    /// Attach a remote suppression mirror to a test policy. Any URL is accepted, so a test can
416    /// point the mirror at a local listener.
417    #[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            // A present maintainer value is read the way the email connector reads it at
485            // start, so a value the connector would refuse is refused here too, even
486            // when the explicit list supplies the recipients. An unset one is not an error.
487            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    /// Validate a present maintainer value and return its first (primary) address:
524    /// split on commas, trim, drop empty values, every remaining value must parse
525    /// and at least one must remain.
526    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
544/// Parse the remote suppression mirror's variables (ADR-200 §6). All three unset means no
545/// mirror. The URL requires the other two, and either of the other two without the URL is an
546/// error, so a partly configured mirror is refused at start instead of reading as an absent one.
547/// No error repeats the credential.
548fn 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
613/// Decode a hex key for `name`, requiring at least [`MIN_KEY_BYTES`] bytes. The value is never
614/// echoed in the error.
615fn 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
640/// Parse `KHIVE_EMAIL_UNSUBSCRIBE_TOKEN_KEYS`: comma-separated `id:key` pairs, `id` a decimal
641/// integer 0-255, no duplicate ids, key material hex of at least [`MIN_KEY_BYTES`] bytes.
642fn 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
672/// Read the operator's external footer once at start-up. A missing, unreadable or
673/// placeholder-less footer is not a start-up error; it is recorded as an unavailability reason
674/// that refuses external admission.
675fn 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
689/// Lowercase hex of a byte slice (no `hex` crate dependency).
690/// Encode an unsubscribe token: `0x01 || key_id || recipient_key || tag`, where the tag is the
691/// first [`UNSUBSCRIBE_TAG_BYTES`] bytes of `HMAC-SHA256(key, 0x01 || key_id || recipient_key)`,
692/// as base64url without padding.
693fn 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
717/// Constant-time comparison over two equal-length byte slices (XOR-fold, no early return).
718fn 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        // U+212A KELVIN SIGN lowercases to ASCII `k` under Unicode rules; it is a different
825        // address.
826        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        // The entry and the request pass through the same function, so a non-ASCII letter in
834        // an entry matches only its exact spelling while ASCII case still folds on both sides.
835        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            // An outbound-only entry never blocks an inbound sender.
933            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) // 32 bytes of 0xcc
960    }
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        // Missing recipient-key secret.
1014        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        // Missing token keys.
1031        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        // Short recipient-key secret (31 bytes) and non-hex secret.
1089        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        // A malformed key value is never read while the senders variable is unset.
1109        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        // Callers pass a normalized address (ADR-200 §5.1); normalization folds ASCII case.
1132        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        // A flipped tag byte fails.
1150        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        // A wrong version byte fails.
1158        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        // An unknown key id fails.
1165        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        // A truncated token fails.
1172        assert!(policy.verify_unsubscribe_token("AAAA").is_none());
1173    }
1174
1175    // A published test vector for the token format, shared with an independent verifier so that
1176    // a change to how either side mints or verifies fails a suite. The token key is test
1177    // material, never deployment key material.
1178    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        // A token minted under the higher id (1) verifies.
1225        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        // Rebuild with a retired id 0 kept alongside the higher id 1.
1231        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        // A token that was minted under id 0 before rotation still verifies after id 1 exists.
1241        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}