1use std::collections::HashSet;
4use std::fmt;
5
6use crate::error::ValidationError;
7
8#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
23pub struct EmailAddress(String);
24
25const PLACEHOLDERS: [&str; 3] = ["REDACTED", "NOT DISCLOSED", "PLEASE QUERY"];
27
28const WITHHELD: [&str; 1] = ["removed@lacnic.net"];
37
38impl EmailAddress {
39 pub fn new(value: impl Into<String>) -> Result<Self, ValidationError> {
47 let value = value.into().trim().to_owned();
48
49 let upper = value.to_uppercase();
50 if PLACEHOLDERS.iter().any(|p| upper.contains(p)) {
51 return Err(ValidationError::RedactedEmail { value });
52 }
53
54 let lower = value.to_lowercase();
55 if WITHHELD.contains(&lower.as_str()) {
56 return Err(ValidationError::WithheldEmail { value });
57 }
58
59 let problem = Self::problem(&value);
60 if let Some(problem) = problem {
61 return Err(ValidationError::InvalidEmail { value, problem });
62 }
63
64 Ok(Self(value))
65 }
66
67 fn problem(value: &str) -> Option<&'static str> {
72 if value.is_empty() {
73 return Some("it is empty");
74 }
75 if value.chars().any(|c| c.is_whitespace() || c.is_control()) {
76 return Some("it has a space or a control character");
77 }
78
79 let mut parts = value.split('@');
80 let (Some(local), Some(domain), None) = (parts.next(), parts.next(), parts.next()) else {
81 return Some("it must have one \"@\"");
82 };
83
84 if local.is_empty() {
85 return Some("there is nothing before the \"@\"");
86 }
87 if domain.is_empty() {
88 return Some("there is nothing after the \"@\"");
89 }
90 if !domain.contains('.') {
91 return Some("the part after the \"@\" is not a domain name");
92 }
93
94 None
95 }
96
97 pub fn as_str(&self) -> &str {
99 &self.0
100 }
101}
102
103impl fmt::Display for EmailAddress {
104 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
105 f.write_str(&self.0)
106 }
107}
108
109#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
114pub enum Scope {
115 Network,
117 Registrar,
119 Domain,
121}
122
123#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
125pub enum Source {
126 Rdap {
128 server: String,
130 },
131 Abusix,
133 AbuseNet,
135 Rfc2142,
137}
138
139impl Source {
140 fn rank(&self) -> u8 {
147 match self {
148 Source::Rdap { .. } => 0,
149 Source::Abusix => 1,
150 Source::AbuseNet => 2,
151 Source::Rfc2142 => 3,
152 }
153 }
154}
155
156#[derive(Clone, Debug, PartialEq, Eq, Hash)]
158pub struct Contact {
159 pub email: EmailAddress,
161 pub scope: Scope,
163 pub source: Source,
165}
166
167pub fn rank(mut contacts: Vec<Contact>) -> Vec<Contact> {
172 contacts.sort_by(|a, b| {
173 a.source
174 .rank()
175 .cmp(&b.source.rank())
176 .then_with(|| a.scope.cmp(&b.scope))
177 .then_with(|| a.email.cmp(&b.email))
178 });
179 let mut seen = HashSet::new();
183 contacts.retain(|contact| seen.insert((contact.email.clone(), contact.scope)));
184 contacts
185}
186
187#[cfg(test)]
188mod tests {
189 use super::*;
190
191 fn contact(email: &str, scope: Scope, source: Source) -> Contact {
192 Contact {
193 email: EmailAddress::new(email).unwrap(),
194 scope,
195 source,
196 }
197 }
198
199 #[test]
200 fn accepts_a_plain_address() {
201 assert_eq!(
202 EmailAddress::new("network-abuse@google.com")
203 .unwrap()
204 .as_str(),
205 "network-abuse@google.com"
206 );
207 }
208
209 #[test]
210 fn trims_surrounding_space() {
211 assert_eq!(
212 EmailAddress::new(" abuse@example.com\n").unwrap().as_str(),
213 "abuse@example.com"
214 );
215 }
216
217 #[test]
218 fn rejects_registry_placeholders() {
219 for value in [
220 "DATA REDACTED",
221 "REDACTED FOR PRIVACY",
222 "Not Disclosed",
223 "please query the RDDS service of the Registrar of Record",
224 ] {
225 assert!(
226 matches!(
227 EmailAddress::new(value),
228 Err(ValidationError::RedactedEmail { .. })
229 ),
230 "expected {value:?} to be rejected as a placeholder"
231 );
232 }
233 }
234
235 #[test]
236 fn rejects_the_address_a_source_sends_when_it_has_no_contact() {
237 for value in ["removed@lacnic.net", "REMOVED@LACNIC.NET"] {
238 assert!(
239 matches!(
240 EmailAddress::new(value),
241 Err(ValidationError::WithheldEmail { .. })
242 ),
243 "expected {value:?} to be rejected"
244 );
245 }
246 }
247
248 #[test]
249 fn keeps_a_real_address_at_the_same_domain() {
250 assert!(EmailAddress::new("ipadmin@lacnic.net").is_ok());
251 }
252
253 #[test]
254 fn rejects_values_that_are_not_addresses() {
255 for value in [
256 "",
257 "abuse",
258 "abuse@",
259 "@example.com",
260 "a@b@c.com",
261 "abuse@localhost",
262 ] {
263 assert!(
264 matches!(
265 EmailAddress::new(value),
266 Err(ValidationError::InvalidEmail { .. })
267 ),
268 "expected {value:?} to be rejected"
269 );
270 }
271 }
272
273 #[test]
274 fn rank_puts_rdap_first_and_rfc2142_last() {
275 let ranked = rank(vec![
276 contact("abuse@example.com", Scope::Domain, Source::Rfc2142),
277 contact("noc@example.com", Scope::Network, Source::Abusix),
278 contact(
279 "registrar-abuse@example.com",
280 Scope::Registrar,
281 Source::Rdap {
282 server: "rdap.example.com".to_owned(),
283 },
284 ),
285 ]);
286
287 let got: Vec<&str> = ranked.iter().map(|c| c.email.as_str()).collect();
288
289 assert_eq!(
290 got,
291 [
292 "registrar-abuse@example.com",
293 "noc@example.com",
294 "abuse@example.com"
295 ]
296 );
297 }
298
299 #[test]
300 fn rank_drops_the_same_address_in_the_same_scope() {
301 let ranked = rank(vec![
302 contact("abuse@example.com", Scope::Network, Source::Abusix),
303 contact("abuse@example.com", Scope::Network, Source::AbuseNet),
304 ]);
305
306 assert_eq!(ranked.len(), 1);
307 assert_eq!(ranked[0].source, Source::Abusix);
308 }
309
310 #[test]
311 fn rank_drops_a_repeat_with_another_address_between() {
312 let rdap = || Source::Rdap {
313 server: "rdap.example".to_owned(),
314 };
315 let ranked = rank(vec![
316 contact("abuse@example.com", Scope::Network, Source::Abusix),
317 contact("abuse@example.com", Scope::Network, rdap()),
318 contact("noc@example.com", Scope::Network, rdap()),
319 ]);
320
321 assert_eq!(
322 ranked,
323 [
324 contact("abuse@example.com", Scope::Network, rdap()),
325 contact("noc@example.com", Scope::Network, rdap()),
326 ]
327 );
328 }
329
330 #[test]
331 fn rank_keeps_the_same_address_in_a_different_scope() {
332 let ranked = rank(vec![
333 contact("abuse@example.com", Scope::Network, Source::Abusix),
334 contact("abuse@example.com", Scope::Domain, Source::Rfc2142),
335 ]);
336
337 assert_eq!(ranked.len(), 2);
338 }
339}