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
20/// Boot-resolved recipient policy shared by comm admission and email delivery.
21/// An absent policy permits queuing without claiming that delivery is configured.
22#[derive(Clone)]
23pub struct OutboundEmailPolicy {
24    recipients: Option<Arc<[String]>>,
25    external_senders: Option<Arc<[String]>>,
26    external_daily_cap: Option<u64>,
27    /// The recipient-key secret. Present only when external senders are configured; the derived
28    /// key for an address is `HMAC-SHA256(K_rk, normalized address)` in lowercase hex.
29    recipient_key_secret: Option<Arc<[u8]>>,
30    /// Token keys by key id, ascending. Every retired id stays present so a link in an old
31    /// message keeps verifying; the highest id mints new tokens.
32    token_keys: Arc<BTreeMap<u8, Arc<[u8]>>>,
33    /// The operator's external footer text, or the reason it is unavailable. `None` while
34    /// external senders are unset, in which case no footer is ever consulted.
35    external_footer: Option<Result<Arc<str>, Arc<str>>>,
36    external_admission: Arc<tokio::sync::Mutex<()>>,
37}
38
39impl Default for OutboundEmailPolicy {
40    fn default() -> Self {
41        Self {
42            recipients: None,
43            external_senders: None,
44            external_daily_cap: None,
45            recipient_key_secret: None,
46            token_keys: Arc::default(),
47            external_footer: None,
48            external_admission: Arc::new(tokio::sync::Mutex::new(())),
49        }
50    }
51}
52
53/// HMAC-SHA256 (RFC 2104) over `sha2::Sha256`.
54///
55/// The 64-byte block is fixed for SHA-256. A key longer than the block is hashed first; a
56/// shorter one is zero-padded to the block. Returns the full 32-byte MAC.
57fn hmac_sha256(key: &[u8], data: &[u8]) -> [u8; 32] {
58    const BLOCK: usize = 64;
59    let mut block = [0u8; BLOCK];
60    if key.len() > BLOCK {
61        block[..32].copy_from_slice(&Sha256::digest(key));
62    } else {
63        block[..key.len()].copy_from_slice(key);
64    }
65    let mut inner_pad = [0x36u8; BLOCK];
66    let mut outer_pad = [0x5cu8; BLOCK];
67    for index in 0..BLOCK {
68        inner_pad[index] ^= block[index];
69        outer_pad[index] ^= block[index];
70    }
71    let mut inner = Sha256::new();
72    inner.update(inner_pad);
73    inner.update(data);
74    let inner_digest = inner.finalize();
75    let mut outer = Sha256::new();
76    outer.update(outer_pad);
77    outer.update(inner_digest);
78    let mut mac = [0u8; 32];
79    mac.copy_from_slice(&outer.finalize());
80    mac
81}
82
83/// The direction stored on one email suppression entry.
84#[derive(Clone, Copy, Debug, PartialEq, Eq)]
85pub enum EmailSuppressionScope {
86    Outbound,
87    Inbound,
88}
89
90/// The identity form stored on one email suppression entry.
91#[derive(Clone, Copy, Debug, PartialEq, Eq)]
92pub enum EmailSuppressionKind {
93    Address,
94    Domain,
95    RecipientKey,
96}
97
98/// The matching fields needed by outbound admission and delivery.
99#[derive(Clone, Debug, PartialEq, Eq)]
100pub struct EmailSuppression {
101    pub scope: EmailSuppressionScope,
102    pub kind: EmailSuppressionKind,
103    pub value: String,
104}
105
106/// Check one already-normalized recipient against the suppression rows supplied by the caller.
107///
108/// Address normalization is shared with [`OutboundEmailPolicy`]; callers must use
109/// `khive_types::email_address::normalize_email_recipient` before calling this function.
110/// Domain comparison is exact, and recipient keys compare byte-for-byte.
111pub fn outbound_email_is_suppressed(
112    normalized_recipient: &str,
113    recipient_key: Option<&str>,
114    entries: &[EmailSuppression],
115) -> bool {
116    let recipient_domain = normalized_recipient
117        .rsplit_once('@')
118        .map(|(_, domain)| domain);
119    entries.iter().any(|entry| {
120        if entry.scope != EmailSuppressionScope::Outbound {
121            return false;
122        }
123        match entry.kind {
124            EmailSuppressionKind::Address => entry.value == normalized_recipient,
125            EmailSuppressionKind::Domain => recipient_domain == Some(entry.value.as_str()),
126            EmailSuppressionKind::RecipientKey => recipient_key == Some(entry.value.as_str()),
127        }
128    })
129}
130
131impl OutboundEmailPolicy {
132    /// Install a normalized recipient set; an explicitly empty set denies all recipients.
133    pub fn configured(recipients: Vec<String>) -> Result<Self, String> {
134        let recipients = recipients
135            .iter()
136            .map(|value| {
137                khive_types::email_address::normalize_email_recipient(value)
138                    .ok_or_else(|| "outbound email policy contains an invalid address".to_string())
139            })
140            .collect::<Result<Vec<_>, _>>()?;
141        Ok(Self {
142            recipients: Some(recipients.into()),
143            ..Self::default()
144        })
145    }
146
147    pub fn is_configured(&self) -> bool {
148        self.recipients.is_some()
149    }
150
151    pub fn allows(&self, recipient: &str) -> bool {
152        self.recipients.as_ref().is_none_or(|recipients| {
153            khive_types::email_address::normalize_email_recipient(recipient)
154                .is_some_and(|recipient| recipients.iter().any(|allowed| allowed == &recipient))
155        })
156    }
157
158    pub fn allows_external_sender(&self, actor_id: &str) -> bool {
159        self.external_senders
160            .as_ref()
161            .is_some_and(|senders| senders.iter().any(|sender| sender == actor_id))
162    }
163
164    pub fn has_external_senders(&self) -> bool {
165        self.external_senders.is_some()
166    }
167
168    pub fn external_daily_cap(&self) -> Option<u64> {
169        self.external_daily_cap
170    }
171
172    pub async fn lock_external_admission(&self) -> tokio::sync::OwnedMutexGuard<()> {
173        Arc::clone(&self.external_admission).lock_owned().await
174    }
175
176    /// The recipient key a `recipient_key` suppression entry matches, derived on demand as
177    /// `HMAC-SHA256(K_rk, normalized address)` in lowercase hex; the raw bytes are never stored.
178    /// `None` when no recipient-key secret is configured (external senders unset).
179    pub fn recipient_key(&self, normalized_recipient: &str) -> Option<String> {
180        self.recipient_key_secret
181            .as_deref()
182            .map(|secret| hex_lower(&hmac_sha256(secret, normalized_recipient.as_bytes())))
183    }
184
185    /// The recipient key's raw 32 bytes, used as the token body.
186    fn recipient_key_bytes(&self, normalized_recipient: &str) -> Option<[u8; RECIPIENT_KEY_BYTES]> {
187        self.recipient_key_secret
188            .as_deref()
189            .map(|secret| hmac_sha256(secret, normalized_recipient.as_bytes()))
190    }
191
192    /// The footer text for external messages, or the reason it is unavailable. `None` while
193    /// external senders are unset.
194    pub fn external_footer(&self) -> Option<Result<&str, &str>> {
195        self.external_footer.as_ref().map(|value| {
196            value
197                .as_ref()
198                .map(|text| &**text)
199                .map_err(|reason| &**reason)
200        })
201    }
202
203    /// Mint the unsubscribe token for a normalized external recipient, or `None` when the
204    /// recipient-key secret or any token key is unavailable. The highest configured key id mints.
205    pub fn mint_unsubscribe_token(&self, normalized_recipient: &str) -> Option<String> {
206        let recipient_key = self.recipient_key_bytes(normalized_recipient)?;
207        let (&key_id, key) = self.token_keys.iter().next_back()?;
208        let mut body = Vec::with_capacity(2 + RECIPIENT_KEY_BYTES);
209        body.push(UNSUBSCRIBE_TOKEN_VERSION);
210        body.push(key_id);
211        body.extend_from_slice(&recipient_key);
212        let tag = hmac_sha256(key, &body);
213        let mut token = body;
214        token.extend_from_slice(&tag[..UNSUBSCRIBE_TAG_BYTES]);
215        Some(URL_SAFE_NO_PAD.encode(token))
216    }
217
218    /// Verify an unsubscribe token and return the recipient key text it identifies, or `None`
219    /// on any malformed input or tag mismatch. Retired key ids keep verifying.
220    pub fn verify_unsubscribe_token(&self, token: &str) -> Option<String> {
221        let decoded = URL_SAFE_NO_PAD.decode(token).ok()?;
222        if decoded.len() != UNSUBSCRIBE_TOKEN_BYTES {
223            return None;
224        }
225        if decoded[0] != UNSUBSCRIBE_TOKEN_VERSION {
226            return None;
227        }
228        let key_id = decoded[1];
229        let key = self.token_keys.get(&key_id)?;
230        let (body, tag) = decoded.split_at(decoded.len() - UNSUBSCRIBE_TAG_BYTES);
231        let expected = hmac_sha256(key, body);
232        if !constant_time_eq(&expected[..UNSUBSCRIBE_TAG_BYTES], tag) {
233            return None;
234        }
235        let recipient_key = &body[2..];
236        Some(hex_lower(recipient_key))
237    }
238
239    /// Read only public policy inputs, without loading a connector or credentials.
240    ///
241    /// The unsubscribe key variables and the footer file are read only when external senders
242    /// are configured; while the senders variable is unset none of them is consulted.
243    pub fn from_env() -> Result<Self, String> {
244        fn read(name: &str) -> Result<Option<String>, String> {
245            match std::env::var(name) {
246                Ok(value) => Ok(Some(value)),
247                Err(std::env::VarError::NotPresent) => Ok(None),
248                Err(std::env::VarError::NotUnicode(_)) => {
249                    Err(format!("{name} must contain valid Unicode text"))
250                }
251            }
252        }
253        let external_senders = read("KHIVE_EMAIL_EXTERNAL_SENDERS")?;
254        let external_daily_cap = read("KHIVE_EMAIL_EXTERNAL_DAILY_CAP")?;
255        let explicit = read("KHIVE_EMAIL_SEND_ALLOWED_RECIPIENTS")?;
256        let maintainer = read("KHIVE_EMAIL_MAINTAINER_ADDRESS")?;
257        let (recipient_key_secret, token_keys) = if external_senders.is_some() {
258            (
259                read("KHIVE_EMAIL_UNSUBSCRIBE_RK_KEY")?,
260                read("KHIVE_EMAIL_UNSUBSCRIBE_TOKEN_KEYS")?,
261            )
262        } else {
263            (None, None)
264        };
265        let footer_file = if external_senders.is_some() {
266            read("KHIVE_EMAIL_EXTERNAL_FOOTER_FILE")?
267        } else {
268            None
269        };
270        Self::resolve_with_external(
271            explicit.as_deref(),
272            maintainer.as_deref(),
273            external_senders.as_deref(),
274            external_daily_cap.as_deref(),
275            recipient_key_secret.as_deref(),
276            token_keys.as_deref(),
277            footer_file.as_deref(),
278        )
279    }
280
281    #[cfg(test)]
282    fn resolve(explicit: Option<&str>, maintainer: Option<&str>) -> Result<Self, String> {
283        Self::resolve_with_external(explicit, maintainer, None, None, None, None, None)
284    }
285
286    /// Build a fully enabled policy for debug-build tests: recipients, the external sender
287    /// set, the daily cap, fixed test key material and a footer carrying `{unsubscribe}`.
288    #[cfg(any(test, debug_assertions))]
289    #[doc(hidden)]
290    pub fn configured_for_tests(
291        recipients: Vec<String>,
292        external_senders: Vec<String>,
293        daily_cap: u64,
294    ) -> Result<Self, String> {
295        let token_keys = format!("0:{},1:{}", "11".repeat(32), "22".repeat(32));
296        Self::configured_for_tests_with(
297            recipients,
298            external_senders,
299            daily_cap,
300            &"cc".repeat(32),
301            &token_keys,
302            Ok("Reply to unsubscribe: {unsubscribe}"),
303        )
304    }
305
306    /// Build an enabled policy with explicit key material and footer state for tests that need
307    /// to exercise configuration or footer unavailability.
308    #[cfg(any(test, debug_assertions))]
309    #[doc(hidden)]
310    #[allow(clippy::too_many_arguments)]
311    pub fn configured_for_tests_with(
312        recipients: Vec<String>,
313        external_senders: Vec<String>,
314        daily_cap: u64,
315        recipient_key_secret_hex: &str,
316        token_keys: &str,
317        footer: Result<&str, &str>,
318    ) -> Result<Self, String> {
319        if daily_cap == 0 {
320            return Err("KHIVE_EMAIL_EXTERNAL_DAILY_CAP must be a positive integer".into());
321        }
322        let external_senders = parse_external_senders(&external_senders.join(","))?;
323        if external_senders.is_empty() {
324            return Err("KHIVE_EMAIL_EXTERNAL_SENDERS must contain at least one actor id".into());
325        }
326        let mut policy = Self::configured(recipients)?;
327        policy.external_senders = Some(external_senders.into());
328        policy.external_daily_cap = Some(daily_cap);
329        let secret = parse_hex_key("KHIVE_EMAIL_UNSUBSCRIBE_RK_KEY", recipient_key_secret_hex)?;
330        policy.recipient_key_secret = Some(Arc::from(secret.as_slice()));
331        policy.token_keys = Arc::new(parse_token_keys(token_keys)?);
332        policy.external_footer = Some(footer.map(Arc::from).map_err(Arc::from));
333        Ok(policy)
334    }
335
336    /// Replace the footer state on a test policy (available/unavailable) without touching keys.
337    #[cfg(any(test, debug_assertions))]
338    #[doc(hidden)]
339    pub fn with_external_footer_for_tests(mut self, footer: Result<&str, &str>) -> Self {
340        self.external_footer = Some(footer.map(Arc::from).map_err(Arc::from));
341        self
342    }
343
344    #[allow(clippy::too_many_arguments)]
345    fn resolve_with_external(
346        explicit: Option<&str>,
347        maintainer: Option<&str>,
348        external_senders: Option<&str>,
349        external_daily_cap: Option<&str>,
350        recipient_key_secret: Option<&str>,
351        token_keys: Option<&str>,
352        footer_file: Option<&str>,
353    ) -> Result<Self, String> {
354        let senders = external_senders.map(parse_external_senders).transpose()?;
355        let cap = match external_daily_cap {
356            Some(value) => {
357                let parsed = value.parse::<u64>().map_err(|_| {
358                    "KHIVE_EMAIL_EXTERNAL_DAILY_CAP must be a positive integer".to_string()
359                })?;
360                if parsed == 0 {
361                    return Err("KHIVE_EMAIL_EXTERNAL_DAILY_CAP must be a positive integer".into());
362                }
363                Some(parsed)
364            }
365            None if senders.is_some() => {
366                return Err("KHIVE_EMAIL_EXTERNAL_DAILY_CAP is required when KHIVE_EMAIL_EXTERNAL_SENDERS is set".into());
367            }
368            None => None,
369        };
370        let mut policy = Self::resolve_recipients(explicit, maintainer)?;
371        policy.external_daily_cap = cap;
372        if let Some(senders) = senders {
373            policy.external_senders = Some(senders.into());
374            let recipient_key_secret = parse_hex_key(
375                "KHIVE_EMAIL_UNSUBSCRIBE_RK_KEY",
376                recipient_key_secret.ok_or_else(|| {
377                    "KHIVE_EMAIL_UNSUBSCRIBE_RK_KEY is required when KHIVE_EMAIL_EXTERNAL_SENDERS is set".to_string()
378                })?,
379            )?;
380            let token_keys = parse_token_keys(token_keys.ok_or_else(|| {
381                "KHIVE_EMAIL_UNSUBSCRIBE_TOKEN_KEYS is required when KHIVE_EMAIL_EXTERNAL_SENDERS is set".to_string()
382            })?)?;
383            policy.recipient_key_secret = Some(Arc::from(recipient_key_secret.as_slice()));
384            policy.token_keys = Arc::new(token_keys);
385            policy.external_footer = Some(read_external_footer(footer_file));
386        }
387        Ok(policy)
388    }
389
390    fn resolve_recipients(
391        explicit: Option<&str>,
392        maintainer: Option<&str>,
393    ) -> Result<Self, String> {
394        if let Some(policy) = Self::explicit(explicit)? {
395            // A present maintainer value is read the way the email connector reads it at
396            // start, so a value the connector would refuse is refused here too, even
397            // when the explicit list supplies the recipients. An unset one is not an error.
398            if let Some(raw) = maintainer {
399                Self::maintainer_primary(raw)?;
400            }
401            return Ok(policy);
402        }
403        Self::maintainer(maintainer, explicit.is_some())
404    }
405
406    fn explicit(raw: Option<&str>) -> Result<Option<Self>, String> {
407        let recipients: Vec<String> = raw
408            .unwrap_or_default()
409            .split(',')
410            .map(str::trim)
411            .filter(|value| !value.is_empty())
412            .map(str::to_owned)
413            .collect();
414        if recipients.is_empty() {
415            return Ok(None);
416        }
417        Self::configured(recipients)
418            .map(Some)
419            .map_err(|_| "KHIVE_EMAIL_SEND_ALLOWED_RECIPIENTS contains an invalid address".into())
420    }
421
422    fn maintainer(raw: Option<&str>, explicit_was_set: bool) -> Result<Self, String> {
423        let Some(raw) = raw else {
424            if explicit_was_set {
425                return Err(
426                    "configured outbound email policy must contain at least one address".into(),
427                );
428            }
429            return Ok(Self::default());
430        };
431        Self::configured(vec![Self::maintainer_primary(raw)?])
432    }
433
434    /// Validate a present maintainer value and return its first (primary) address:
435    /// split on commas, trim, drop empty values, every remaining value must parse
436    /// and at least one must remain.
437    fn maintainer_primary(raw: &str) -> Result<String, String> {
438        let addresses = raw
439            .split(',')
440            .map(str::trim)
441            .filter(|value| !value.is_empty())
442            .map(|value| {
443                khive_types::email_address::normalize_email_recipient(value).ok_or_else(|| {
444                    "KHIVE_EMAIL_MAINTAINER_ADDRESS contains an invalid address".to_string()
445                })
446            })
447            .collect::<Result<Vec<_>, _>>()?;
448        let Some(primary) = addresses.into_iter().next() else {
449            return Err("KHIVE_EMAIL_MAINTAINER_ADDRESS must contain at least one address".into());
450        };
451        Ok(primary)
452    }
453}
454
455fn parse_external_senders(raw: &str) -> Result<Vec<String>, String> {
456    raw.split(',')
457        .map(str::trim)
458        .map(|actor_id| {
459            if actor_id.is_empty() {
460                return Err("KHIVE_EMAIL_EXTERNAL_SENDERS contains an empty actor id".to_string());
461            }
462            khive_types::Namespace::parse(actor_id).map_err(|_| {
463                format!("KHIVE_EMAIL_EXTERNAL_SENDERS contains an invalid actor id {actor_id:?}")
464            })?;
465            Ok(actor_id.to_string())
466        })
467        .collect()
468}
469
470/// Decode a hex key for `name`, requiring at least [`MIN_KEY_BYTES`] bytes. The value is never
471/// echoed in the error.
472fn parse_hex_key(name: &str, raw: &str) -> Result<Vec<u8>, String> {
473    let value = raw.trim().to_ascii_lowercase();
474    if !value.len().is_multiple_of(2) || !khive_types::is_lowercase_hex(&value) {
475        return Err(format!(
476            "{name} must be hex-encoded key material of at least {MIN_KEY_BYTES} bytes"
477        ));
478    }
479    let mut bytes = Vec::with_capacity(value.len() / 2);
480    for pair in value.as_bytes().chunks_exact(2) {
481        let high = (pair[0] as char)
482            .to_digit(16)
483            .expect("validated lowercase hex");
484        let low = (pair[1] as char)
485            .to_digit(16)
486            .expect("validated lowercase hex");
487        bytes.push((high * 16 + low) as u8);
488    }
489    if bytes.len() < MIN_KEY_BYTES {
490        return Err(format!(
491            "{name} must be hex-encoded key material of at least {MIN_KEY_BYTES} bytes"
492        ));
493    }
494    Ok(bytes)
495}
496
497/// Parse `KHIVE_EMAIL_UNSUBSCRIBE_TOKEN_KEYS`: comma-separated `id:key` pairs, `id` a decimal
498/// integer 0-255, no duplicate ids, key material hex of at least [`MIN_KEY_BYTES`] bytes.
499fn parse_token_keys(raw: &str) -> Result<BTreeMap<u8, Arc<[u8]>>, String> {
500    const NAME: &str = "KHIVE_EMAIL_UNSUBSCRIBE_TOKEN_KEYS";
501    let mut keys = BTreeMap::new();
502    for pair in raw.split(',') {
503        let pair = pair.trim();
504        if pair.is_empty() {
505            return Err(format!("{NAME} contains an empty `id:key` pair"));
506        }
507        let Some((id_text, key_text)) = pair.split_once(':') else {
508            return Err(format!("{NAME} entries must be `id:key` pairs"));
509        };
510        let id = id_text
511            .trim()
512            .parse::<u16>()
513            .ok()
514            .filter(|id| *id <= u8::MAX as u16)
515            .ok_or_else(|| format!("{NAME} key ids must be decimal integers 0-255"))?
516            as u8;
517        if keys.contains_key(&id) {
518            return Err(format!("{NAME} contains a duplicate key id"));
519        }
520        let key = parse_hex_key(NAME, key_text)?;
521        keys.insert(id, Arc::from(key.as_slice()));
522    }
523    if keys.is_empty() {
524        return Err(format!("{NAME} must contain at least one `id:key` pair"));
525    }
526    Ok(keys)
527}
528
529/// Read the operator's external footer once at start-up. A missing, unreadable or
530/// placeholder-less footer is not a start-up error; it is recorded as an unavailability reason
531/// that refuses external admission.
532fn read_external_footer(path: Option<&str>) -> Result<Arc<str>, Arc<str>> {
533    let Some(path) = path else {
534        return Err(Arc::from("KHIVE_EMAIL_EXTERNAL_FOOTER_FILE is not set"));
535    };
536    let text = std::fs::read_to_string(path)
537        .map_err(|_| Arc::from("KHIVE_EMAIL_EXTERNAL_FOOTER_FILE could not be read"))?;
538    if !text.contains(UNSUBSCRIBE_PLACEHOLDER) {
539        return Err(Arc::from(
540            "KHIVE_EMAIL_EXTERNAL_FOOTER_FILE does not contain {unsubscribe}",
541        ));
542    }
543    Ok(Arc::from(text))
544}
545
546/// Lowercase hex of a byte slice (no `hex` crate dependency).
547fn hex_lower(bytes: &[u8]) -> String {
548    const DIGITS: &[u8; 16] = b"0123456789abcdef";
549    let mut out = String::with_capacity(bytes.len() * 2);
550    for byte in bytes {
551        out.push(DIGITS[(byte >> 4) as usize] as char);
552        out.push(DIGITS[(byte & 0x0f) as usize] as char);
553    }
554    out
555}
556
557/// Constant-time comparison over two equal-length byte slices (XOR-fold, no early return).
558fn constant_time_eq(left: &[u8], right: &[u8]) -> bool {
559    if left.len() != right.len() {
560        return false;
561    }
562    let mut diff = 0u8;
563    for (a, b) in left.iter().zip(right.iter()) {
564        diff |= a ^ b;
565    }
566    diff == 0
567}
568
569#[cfg(test)]
570mod tests {
571    use super::*;
572
573    #[test]
574    fn explicit_policy_and_requests_share_maintainer_normalization() {
575        let policy =
576            OutboundEmailPolicy::explicit(Some(" , First@Example.com , second@example.com,,"))
577                .unwrap()
578                .unwrap();
579        assert!(policy.allows("First@Example.com"));
580        assert!(policy.allows("second@example.com"));
581        assert!(policy.allows("first@example.com"));
582        assert!(policy.allows(" First@Example.com "));
583        assert!(policy.allows("First <FIRST@EXAMPLE.COM>"));
584        assert!(!policy.allows("other@example.com"));
585        assert!(!policy.allows("not-an-address"));
586        assert!(policy.is_configured());
587        assert_eq!(
588            policy.recipients.as_deref().unwrap(),
589            ["first@example.com", "second@example.com"]
590        );
591        assert!(OutboundEmailPolicy::explicit(Some(" , , "))
592            .unwrap()
593            .is_none());
594        assert!(OutboundEmailPolicy::explicit(Some("not-an-address")).is_err());
595    }
596
597    #[test]
598    fn fallback_validates_all_maintainers_and_allows_only_normalized_primary() {
599        let policy = OutboundEmailPolicy::maintainer(
600            Some("Owner <Primary@Example.com>, second@example.com"),
601            false,
602        )
603        .unwrap();
604        assert!(policy.allows("primary@example.com"));
605        assert!(policy.allows("Primary@Example.com"));
606        assert!(policy.allows("Owner <PRIMARY@EXAMPLE.COM>"));
607        assert!(!policy.allows("second@example.com"));
608        assert!(
609            OutboundEmailPolicy::maintainer(Some("primary@example.com,invalid"), false).is_err()
610        );
611        assert!(OutboundEmailPolicy::maintainer(Some(" , "), false).is_err());
612        assert!(OutboundEmailPolicy::maintainer(None, true).is_err());
613        assert!(
614            OutboundEmailPolicy::maintainer(Some("primary@example.com"), true)
615                .unwrap()
616                .allows("PRIMARY@example.com")
617        );
618        assert!(OutboundEmailPolicy::maintainer(None, false)
619            .unwrap()
620            .allows("backlog@example.com"));
621        assert!(!OutboundEmailPolicy::default().is_configured());
622        let deny_all = OutboundEmailPolicy::configured(vec![]).unwrap();
623        assert!(deny_all.is_configured());
624        assert!(!deny_all.allows("backlog@example.com"));
625        assert!(OutboundEmailPolicy::configured(vec!["invalid".into()]).is_err());
626    }
627
628    #[test]
629    fn present_maintainer_is_validated_beside_an_explicit_list_and_an_unset_one_is_not() {
630        let resolve = OutboundEmailPolicy::resolve;
631        let explicit = Some("allowed@example.com");
632        for maintainer in [
633            "",
634            " , ",
635            "primary@example.com,invalid",
636            "invalid,primary@example.com",
637        ] {
638            assert!(
639                resolve(explicit, Some(maintainer)).is_err(),
640                "maintainer {maintainer:?} beside a valid explicit list must be refused"
641            );
642        }
643        let unset = resolve(explicit, None).unwrap();
644        assert!(unset.is_configured());
645        assert!(unset.allows("ALLOWED@example.com"));
646        assert!(!unset.allows("primary@example.com"));
647        let list = Some("Owner <Primary@Example.com>, second@example.com");
648        let valid = resolve(explicit, list).unwrap();
649        assert!(valid.allows("allowed@example.com"));
650        assert!(!valid.allows("primary@example.com"));
651        let blank = Some(" , ");
652        let fallback = resolve(blank, list).unwrap();
653        assert!(fallback.allows("primary@example.com"));
654        assert!(!fallback.allows("second@example.com"));
655        assert!(resolve(blank, None).is_err());
656        assert!(resolve(Some("not-an-address"), None).is_err());
657        assert!(resolve(None, Some("primary@example.com,invalid")).is_err());
658        assert!(!resolve(None, None).unwrap().is_configured());
659    }
660
661    #[test]
662    fn comparison_folds_ascii_case_only() {
663        let policy = OutboundEmailPolicy::configured(vec!["kevin@example.com".into()]).unwrap();
664        // U+212A KELVIN SIGN lowercases to ASCII `k` under Unicode rules; it is a different
665        // address.
666        assert!(!policy.allows("\u{212A}evin@example.com"));
667        assert!(policy.allows("KEVIN@Example.COM"));
668        assert!(policy.allows("kevin@example.com"));
669    }
670
671    #[test]
672    fn configured_entries_and_requests_share_one_normalization() {
673        // The entry and the request pass through the same function, so a non-ASCII letter in
674        // an entry matches only its exact spelling while ASCII case still folds on both sides.
675        let policy =
676            OutboundEmailPolicy::configured(vec!["\u{212A}evin@Example.COM".into()]).unwrap();
677        assert_eq!(
678            policy.recipients.as_deref().unwrap(),
679            ["\u{212A}evin@example.com"]
680        );
681        assert!(policy.allows("\u{212A}evin@EXAMPLE.com"));
682        assert!(!policy.allows("kevin@example.com"));
683        let accented =
684            OutboundEmailPolicy::maintainer(Some("\u{C9}mile@Example.com"), false).unwrap();
685        assert!(accented.allows("\u{C9}mile@example.COM"));
686        assert!(!accented.allows("\u{E9}mile@example.com"));
687    }
688
689    #[test]
690    fn suppression_matching_checks_exact_address_domain_and_recipient_key() {
691        let address = [EmailSuppression {
692            scope: EmailSuppressionScope::Outbound,
693            kind: EmailSuppressionKind::Address,
694            value: "a@example.com".into(),
695        }];
696        assert!(outbound_email_is_suppressed(
697            "a@example.com",
698            None,
699            &address
700        ));
701        assert!(!outbound_email_is_suppressed(
702            "b@example.com",
703            None,
704            &address
705        ));
706
707        let entries = [
708            EmailSuppression {
709                scope: EmailSuppressionScope::Outbound,
710                kind: EmailSuppressionKind::Address,
711                value: "a@example.com".into(),
712            },
713            EmailSuppression {
714                scope: EmailSuppressionScope::Outbound,
715                kind: EmailSuppressionKind::Domain,
716                value: "x.com".into(),
717            },
718            EmailSuppression {
719                scope: EmailSuppressionScope::Outbound,
720                kind: EmailSuppressionKind::RecipientKey,
721                value: "key-1".into(),
722            },
723            EmailSuppression {
724                scope: EmailSuppressionScope::Inbound,
725                kind: EmailSuppressionKind::Address,
726                value: "b@example.com".into(),
727            },
728        ];
729
730        assert!(outbound_email_is_suppressed(
731            "a@example.com",
732            None,
733            &entries
734        ));
735        assert!(outbound_email_is_suppressed("b@x.com", None, &entries));
736        assert!(!outbound_email_is_suppressed("b@sub.x.com", None, &entries));
737        assert!(outbound_email_is_suppressed(
738            "c@example.com",
739            Some("key-1"),
740            &entries
741        ));
742        assert!(!outbound_email_is_suppressed(
743            "c@example.com",
744            Some("key-2"),
745            &entries
746        ));
747        assert!(!outbound_email_is_suppressed(
748            "b@example.com",
749            None,
750            &entries
751        ));
752    }
753
754    #[test]
755    fn cloned_policy_keeps_the_same_immutable_recipient_set() {
756        let policy = OutboundEmailPolicy::configured(vec!["allowed@example.com".into()]).unwrap();
757        let clone = policy.clone();
758        assert!(Arc::ptr_eq(
759            policy.recipients.as_ref().unwrap(),
760            clone.recipients.as_ref().unwrap()
761        ));
762    }
763
764    fn rk() -> String {
765        "cc".repeat(32) // 32 bytes of 0xcc
766    }
767
768    fn token_keys_hex() -> String {
769        format!("0:{},1:{}", "11".repeat(32), "22".repeat(32))
770    }
771
772    fn enabled() -> OutboundEmailPolicy {
773        OutboundEmailPolicy::configured_for_tests_with(
774            vec!["allowed@example.com".into()],
775            vec!["actor:sender".into()],
776            5,
777            &rk(),
778            &token_keys_hex(),
779            Ok("Reply to unsubscribe: {unsubscribe}"),
780        )
781        .unwrap()
782    }
783
784    fn unhex(hex: &str) -> Vec<u8> {
785        hex.as_bytes()
786            .chunks_exact(2)
787            .map(|pair| {
788                let high = (pair[0] as char).to_digit(16).unwrap();
789                let low = (pair[1] as char).to_digit(16).unwrap();
790                (high * 16 + low) as u8
791            })
792            .collect()
793    }
794
795    #[test]
796    fn hmac_sha256_matches_rfc_4231_vectors() {
797        let case1 = hmac_sha256(&[0x0b; 20], b"Hi There");
798        assert_eq!(
799            hex_lower(&case1),
800            "b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7"
801        );
802        let case2 = hmac_sha256(b"Jefe", b"what do ya want for nothing?");
803        assert_eq!(
804            hex_lower(&case2),
805            "5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843"
806        );
807        let case6 = hmac_sha256(
808            &[0xaa; 131],
809            b"Test Using Larger Than Block-Size Key - Hash Key First",
810        );
811        assert_eq!(
812            hex_lower(&case6),
813            "60e431591ee0b67f0d8a26aacbf5b77f8e0bc6213728c5140546040f0ee37f54"
814        );
815    }
816
817    #[test]
818    fn key_configuration_refuses_malformed_values_when_senders_are_set() {
819        // Missing recipient-key secret.
820        let missing_rk = OutboundEmailPolicy::resolve_with_external(
821            None,
822            Some("maintainer@example.com"),
823            Some("actor:sender"),
824            Some("5"),
825            None,
826            Some(&token_keys_hex()),
827            None,
828        )
829        .err()
830        .expect("start-up policy is refused");
831        assert!(
832            missing_rk.contains("KHIVE_EMAIL_UNSUBSCRIBE_RK_KEY"),
833            "{missing_rk}"
834        );
835
836        // Missing token keys.
837        let missing_tokens = OutboundEmailPolicy::resolve_with_external(
838            None,
839            Some("maintainer@example.com"),
840            Some("actor:sender"),
841            Some("5"),
842            Some(&rk()),
843            None,
844            None,
845        )
846        .err()
847        .expect("start-up policy is refused");
848        assert!(
849            missing_tokens.contains("KHIVE_EMAIL_UNSUBSCRIBE_TOKEN_KEYS"),
850            "{missing_tokens}"
851        );
852
853        for (rk, tokens, needle) in [
854            (
855                rk(),
856                "0:nothex".to_string(),
857                "KHIVE_EMAIL_UNSUBSCRIBE_TOKEN_KEYS",
858            ),
859            (rk(), "0".to_string(), "KHIVE_EMAIL_UNSUBSCRIBE_TOKEN_KEYS"),
860            (
861                rk(),
862                "0:abcd".to_string(),
863                "KHIVE_EMAIL_UNSUBSCRIBE_TOKEN_KEYS",
864            ),
865            (
866                rk(),
867                format!("0:{},0:{}", "11".repeat(32), "22".repeat(32)),
868                "KHIVE_EMAIL_UNSUBSCRIBE_TOKEN_KEYS",
869            ),
870            (
871                rk(),
872                format!("256:{}", "11".repeat(32)),
873                "KHIVE_EMAIL_UNSUBSCRIBE_TOKEN_KEYS",
874            ),
875        ] {
876            let error = OutboundEmailPolicy::resolve_with_external(
877                None,
878                Some("maintainer@example.com"),
879                Some("actor:sender"),
880                Some("5"),
881                Some(rk.as_str()),
882                Some(tokens.as_str()),
883                None,
884            )
885            .err()
886            .expect("start-up policy is refused");
887            assert!(error.contains(needle), "{error}");
888            assert!(
889                !error.contains(&"11".repeat(32)),
890                "key material leaked: {error}"
891            );
892        }
893
894        // Short recipient-key secret (31 bytes) and non-hex secret.
895        for rk in ["cc".repeat(31), "cc".repeat(16), "zz".repeat(32)] {
896            let error = OutboundEmailPolicy::resolve_with_external(
897                None,
898                Some("maintainer@example.com"),
899                Some("actor:sender"),
900                Some("5"),
901                Some(&rk),
902                Some(&token_keys_hex()),
903                None,
904            )
905            .err()
906            .expect("start-up policy is refused");
907            assert!(error.contains("KHIVE_EMAIL_UNSUBSCRIBE_RK_KEY"), "{error}");
908            assert!(!error.contains(&rk), "key material leaked: {error}");
909        }
910    }
911
912    #[test]
913    fn key_variables_are_ignored_when_senders_are_unset() {
914        // A malformed key value is never read while the senders variable is unset.
915        let policy = OutboundEmailPolicy::resolve_with_external(
916            None,
917            Some("maintainer@example.com"),
918            None,
919            None,
920            Some("not hex"),
921            Some("garbage"),
922            Some("/nonexistent/footer"),
923        )
924        .unwrap();
925        assert!(!policy.has_external_senders());
926        assert!(policy.recipient_key("a@example.com").is_none());
927        assert!(policy.mint_unsubscribe_token("a@example.com").is_none());
928        assert!(policy.external_footer().is_none());
929    }
930
931    #[test]
932    fn recipient_key_is_normalized_hex_and_stable_across_case() {
933        let policy = enabled();
934        let key = policy.recipient_key("person@example.com").unwrap();
935        assert_eq!(key.len(), 64);
936        assert!(khive_types::is_lowercase_hex(&key));
937        // Callers pass a normalized address (ADR-200 ยง5.1); normalization folds ASCII case.
938        let other_case =
939            khive_types::email_address::normalize_email_recipient("Person@Example.COM").unwrap();
940        assert_eq!(key, policy.recipient_key(&other_case).unwrap());
941        let secret = unhex(&rk());
942        assert_eq!(key, hex_lower(&hmac_sha256(&secret, b"person@example.com")));
943    }
944
945    #[test]
946    fn token_round_trips_and_rejects_tampering() {
947        let policy = enabled();
948        let token = policy.mint_unsubscribe_token("person@example.com").unwrap();
949        let recipient_key = policy.recipient_key("person@example.com").unwrap();
950        assert_eq!(
951            policy.verify_unsubscribe_token(&token).as_deref(),
952            Some(recipient_key.as_str())
953        );
954
955        // A flipped tag byte fails.
956        let mut bytes = URL_SAFE_NO_PAD.decode(&token).unwrap();
957        let last = bytes.len() - 1;
958        bytes[last] ^= 0x01;
959        assert!(policy
960            .verify_unsubscribe_token(&URL_SAFE_NO_PAD.encode(&bytes))
961            .is_none());
962
963        // A wrong version byte fails.
964        let mut bytes = URL_SAFE_NO_PAD.decode(&token).unwrap();
965        bytes[0] = 0x02;
966        assert!(policy
967            .verify_unsubscribe_token(&URL_SAFE_NO_PAD.encode(&bytes))
968            .is_none());
969
970        // An unknown key id fails.
971        let mut bytes = URL_SAFE_NO_PAD.decode(&token).unwrap();
972        bytes[1] = 9;
973        assert!(policy
974            .verify_unsubscribe_token(&URL_SAFE_NO_PAD.encode(&bytes))
975            .is_none());
976
977        // A truncated token fails.
978        assert!(policy.verify_unsubscribe_token("AAAA").is_none());
979    }
980
981    #[test]
982    fn retired_key_ids_keep_verifying_and_the_highest_id_mints() {
983        let policy = enabled();
984        // A token minted under the higher id (1) verifies.
985        let token_high = policy.mint_unsubscribe_token("person@example.com").unwrap();
986        let decoded = URL_SAFE_NO_PAD.decode(&token_high).unwrap();
987        assert_eq!(decoded[1], 1, "minting must use the highest configured id");
988        assert!(policy.verify_unsubscribe_token(&token_high).is_some());
989
990        // Rebuild with a retired id 0 kept alongside the higher id 1.
991        let retired = OutboundEmailPolicy::configured_for_tests_with(
992            vec!["allowed@example.com".into()],
993            vec!["actor:sender".into()],
994            5,
995            &rk(),
996            &token_keys_hex(),
997            Ok("footer {unsubscribe}"),
998        )
999        .unwrap();
1000        // A token that was minted under id 0 before rotation still verifies after id 1 exists.
1001        let mut body = Vec::new();
1002        body.push(0x01u8);
1003        body.push(0u8);
1004        body.extend_from_slice(&unhex(
1005            &retired.recipient_key("person@example.com").unwrap(),
1006        ));
1007        let tag = hmac_sha256(&unhex(&"11".repeat(32)), &body);
1008        body.extend_from_slice(&tag[..16]);
1009        let old_token = URL_SAFE_NO_PAD.encode(&body);
1010        assert_eq!(
1011            retired.verify_unsubscribe_token(&old_token).as_deref(),
1012            Some(
1013                retired
1014                    .recipient_key("person@example.com")
1015                    .unwrap()
1016                    .as_str()
1017            )
1018        );
1019    }
1020
1021    #[test]
1022    fn footer_state_is_recorded_without_a_start_up_error() {
1023        let policy = OutboundEmailPolicy::configured_for_tests_with(
1024            vec!["allowed@example.com".into()],
1025            vec!["actor:sender".into()],
1026            5,
1027            &rk(),
1028            &token_keys_hex(),
1029            Err("KHIVE_EMAIL_EXTERNAL_FOOTER_FILE is not set"),
1030        )
1031        .unwrap();
1032        assert_eq!(
1033            policy.external_footer(),
1034            Some(Err("KHIVE_EMAIL_EXTERNAL_FOOTER_FILE is not set"))
1035        );
1036    }
1037
1038    #[test]
1039    fn footer_file_must_exist_be_readable_and_carry_the_placeholder() {
1040        let missing = read_external_footer(Some("/nonexistent/khive-footer"));
1041        assert!(missing.is_err());
1042        let dir = std::env::temp_dir();
1043        let without = dir.join("khive-footer-without-placeholder.txt");
1044        std::fs::write(&without, "no placeholder here").unwrap();
1045        assert!(read_external_footer(without.to_str()).is_err());
1046        let with = dir.join("khive-footer-with-placeholder.txt");
1047        std::fs::write(&with, "Contact us.\n{unsubscribe}\n").unwrap();
1048        assert_eq!(
1049            read_external_footer(with.to_str()).unwrap().as_ref(),
1050            "Contact us.\n{unsubscribe}\n"
1051        );
1052        assert!(read_external_footer(None).is_err());
1053    }
1054}