khive_runtime/
email_message_id.rs1use uuid::Uuid;
4
5pub 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 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 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}