Skip to main content

khive_runtime/
email_message_id.rs

1//! Configuration-bound ownership checks for outbound email Message-IDs.
2
3use uuid::Uuid;
4
5/// Explicit former sending domains that remain valid for redelivery and replies.
6pub const HISTORICAL_DOMAINS_ENV: &str = "KHIVE_EMAIL_MESSAGE_ID_HISTORICAL_DOMAINS";
7
8#[derive(Clone, Debug, PartialEq, Eq)]
9pub struct EmailMessageIdDomains {
10    mailbox: String,
11    current: String,
12    historical: Vec<String>,
13}
14
15impl EmailMessageIdDomains {
16    pub fn from_mailbox_and_history(mailbox: &str, historical: &str) -> Result<Self, String> {
17        let current = mailbox.split('@').nth(1).unwrap_or("localhost");
18        validate_domain(current)?;
19        let mut former = Vec::new();
20        for raw in historical
21            .split(',')
22            .map(str::trim)
23            .filter(|s| !s.is_empty())
24        {
25            validate_domain(raw)?;
26            if raw.eq_ignore_ascii_case("localhost") && !current.eq_ignore_ascii_case("localhost") {
27                return Err("localhost may be used only for the selected no-domain mailbox".into());
28            }
29            if raw != current && !former.iter().any(|domain| domain == raw) {
30                former.push(raw.to_string());
31            }
32        }
33        Ok(Self {
34            mailbox: mailbox.to_string(),
35            current: current.to_string(),
36            historical: former,
37        })
38    }
39
40    /// The pack and the channel read the same deployment configuration. A pack
41    /// without an email mailbox has no authority to accept stored email IDs.
42    pub fn from_env() -> Result<Option<Self>, String> {
43        let mailbox =
44            optional_env("KHIVE_EMAIL_MAILBOX")?.or(optional_env("KHIVE_EMAIL_USERNAME")?);
45        let historical = optional_env(HISTORICAL_DOMAINS_ENV)?.unwrap_or_default();
46        match mailbox {
47            Some(mailbox) => Self::from_mailbox_and_history(&mailbox, &historical).map(Some),
48            None if historical.is_empty() => Ok(None),
49            None => Err(format!(
50                "{HISTORICAL_DOMAINS_ENV} requires KHIVE_EMAIL_MAILBOX or KHIVE_EMAIL_USERNAME"
51            )),
52        }
53    }
54
55    pub fn current(&self) -> &str {
56        &self.current
57    }
58
59    pub fn mailbox(&self) -> &str {
60        &self.mailbox
61    }
62
63    pub fn mint(&self, note_id: Uuid) -> String {
64        format!("<{note_id}@{}>", self.current)
65    }
66
67    /// Equality against the row's own canonical UUID is intentional: a
68    /// parseable ID borrowed from another row is not this row's claim.
69    pub fn verify(&self, note_id: Uuid, stored: &str) -> bool {
70        let own_id = note_id.as_hyphenated();
71        std::iter::once(self.current.as_str())
72            .chain(self.historical.iter().map(String::as_str))
73            .any(|domain| stored == format!("<{own_id}@{domain}>"))
74    }
75
76    pub fn verifies_channel_slug(&self, slug: Option<&str>) -> bool {
77        slug.is_none_or(|slug| slug == self.mailbox)
78    }
79}
80
81fn optional_env(name: &str) -> Result<Option<String>, String> {
82    match std::env::var(name) {
83        Ok(value) if !value.is_empty() => Ok(Some(value)),
84        Ok(_) | Err(std::env::VarError::NotPresent) => Ok(None),
85        Err(std::env::VarError::NotUnicode(_)) => {
86            Err(format!("{name} must contain valid Unicode text"))
87        }
88    }
89}
90
91fn validate_domain(domain: &str) -> Result<(), String> {
92    let valid = !domain.is_empty()
93        && domain.len() <= 253
94        && domain.is_ascii()
95        && domain.bytes().all(|byte| {
96            byte.is_ascii_alphabetic() || byte.is_ascii_digit() || byte == b'-' || byte == b'.'
97        })
98        && domain
99            .split('.')
100            .all(|label| !label.is_empty() && !label.starts_with('-') && !label.ends_with('-'));
101    if valid {
102        Ok(())
103    } else {
104        Err(format!(
105            "invalid configured email Message-ID domain {domain:?}"
106        ))
107    }
108}
109
110#[cfg(test)]
111mod tests {
112    use super::EmailMessageIdDomains;
113    use uuid::Uuid;
114
115    #[test]
116    fn message_id_ownership_requires_own_uuid_and_configured_domain() {
117        let domains = EmailMessageIdDomains::from_mailbox_and_history(
118            "sender@current.example",
119            "former.example",
120        )
121        .unwrap();
122        let owner = Uuid::new_v4();
123        let copier = Uuid::new_v4();
124        assert!(domains.verify(owner, &domains.mint(owner)));
125        assert!(domains.verify(owner, &format!("<{owner}@former.example>")));
126        assert!(!domains.verify(copier, &format!("<{owner}@former.example>")));
127        assert!(!domains.verify(owner, &format!("<{owner}@unconfigured.example>")));
128        assert!(!domains.verify(owner, &format!("{owner}@current.example")));
129        assert!(!domains.verify(
130            owner,
131            &format!("<{}@current.example>", owner.to_string().to_uppercase())
132        ));
133        assert!(domains.verifies_channel_slug(Some("sender@current.example")));
134        assert!(!domains.verifies_channel_slug(Some("other@current.example")));
135    }
136
137    #[test]
138    fn localhost_is_only_the_selected_no_domain_mailbox_fallback() {
139        let domains =
140            EmailMessageIdDomains::from_mailbox_and_history("sender@example.com", "").unwrap();
141        let id = Uuid::new_v4();
142        assert!(!domains.verify(id, &format!("<{id}@localhost>")));
143        assert!(
144            EmailMessageIdDomains::from_mailbox_and_history("sender@example.com", "localhost")
145                .is_err()
146        );
147        assert!(
148            EmailMessageIdDomains::from_mailbox_and_history("sender@example.com", "LOCALHOST")
149                .is_err()
150        );
151        let fallback = EmailMessageIdDomains::from_mailbox_and_history("sender", "").unwrap();
152        assert!(fallback.verify(id, &format!("<{id}@localhost>")));
153    }
154
155    #[test]
156    fn configured_domain_spelling_is_preserved_for_replay() {
157        let domains = EmailMessageIdDomains::from_mailbox_and_history(
158            "sender@Current.Example",
159            "Former.Example",
160        )
161        .unwrap();
162        let id = Uuid::new_v4();
163        assert_eq!(domains.mint(id), format!("<{id}@Current.Example>"));
164        assert!(domains.verify(id, &format!("<{id}@Former.Example>")));
165        assert!(!domains.verify(id, &format!("<{id}@former.example>")));
166    }
167}