stepflow_data/value/
email_value.rs

1use std::borrow::{Borrow, Cow};
2use std::str::FromStr;
3use super::{Value, BaseValue, InvalidValue};
4
5
6/// The implementation for an email [`value`](crate::value::Value).
7///
8/// NOTE: this is a really basic e-mail validity check and misses several cases.
9#[derive(Debug, PartialEq, Clone)]
10pub struct EmailValue {
11  val: Cow<'static, str>,
12}
13
14impl EmailValue {
15  pub fn try_new<STR>(val: STR) -> Result<Self, InvalidValue> 
16      where STR: Into<Cow<'static, str>>
17  {
18    let val = val.into();
19    Self::validate(&val)?;
20    Ok(Self { val })
21  }
22
23  pub fn validate(val: &Cow<'static, str>) -> Result<(), InvalidValue> {
24    if val.is_empty() {
25      return Err(InvalidValue::Empty);
26    }
27
28    if extract_login(val).is_none() {
29      return Err(InvalidValue::BadFormat)
30    }
31
32    Ok(())
33  }
34
35  pub fn val(&self) -> &str {
36    self.val.borrow()
37  }
38
39  pub fn boxed(self) -> Box<dyn Value> {
40    Box::new(self)
41  }
42}
43
44fn is_valid_email_local_part_char(c: char) -> bool {
45  if c.is_alphanumeric() {
46    return true;
47  }
48  match c {
49    '!' | '#' | '$' | '%' | '&' | '*' | '+' | '-' | '/' | '=' | '?' | '^' | '_' | '`' | '{' | '|' | '}' | '~' => true,
50    _ => false
51  }
52}
53fn extract_login(input: &str) -> Option<&str> {
54  #[derive(PartialEq, Debug)]
55  enum ExtractState {
56    LoginAnyLocalPartChar,       // login: next char must be valid in the "local-part" of an email
57    LoginAnyLocalPartCharAndDot,
58    Domain
59  }
60
61  let mut end_range = 0;
62  let mut state = ExtractState::LoginAnyLocalPartChar;  // first char must be alphanum
63  let mut login: &str = "";
64  for c in input.chars() {
65    // never valid
66    if c.is_whitespace() {
67      return None;
68    }
69    end_range += 1;
70
71    state = match state {
72      ExtractState::LoginAnyLocalPartChar |
73      ExtractState::LoginAnyLocalPartCharAndDot => {
74        if is_valid_email_local_part_char(c) {
75          ExtractState::LoginAnyLocalPartCharAndDot
76        } else if state == ExtractState::LoginAnyLocalPartCharAndDot && c == '.' {
77          ExtractState::LoginAnyLocalPartChar
78        } else if c == '@' {
79          login = input.get(0..end_range-1)?;
80          if login.chars().last()? == '.' {
81            // look back one char to make sure we don't end in a dot
82            return None;
83          }
84          ExtractState::Domain
85        } else {
86          return None;
87        }
88      }
89      ExtractState::Domain => {
90        match c {
91          '@' => return None,
92          _ => ExtractState::Domain,
93        }
94      }
95    }
96  }
97
98  if login.is_empty() {
99    // this should be impossible
100    None
101  } else {
102    Some(login)
103  }
104}
105
106define_value_impl!(EmailValue);
107
108impl FromStr for EmailValue {
109    type Err = InvalidValue;
110
111    fn from_str(s: &str) -> Result<Self, Self::Err> {
112      EmailValue::try_new(s.to_owned())
113    }
114}
115
116
117#[cfg(test)]
118mod tests {
119  use super::super::InvalidValue;
120  use super::{ extract_login, EmailValue };
121
122  #[test]
123  fn test_extract_valid_email() {
124    // from https://gist.github.com/cjaoude/fd9910626629b53c4d25
125    // FUTURE: we don't handle unicode graphmemes to avoid growing our data segment with unicode tables. it should be an optional features
126    let emails = vec![
127      // valid
128      ("email@example.com", "email"),
129      ("firstname.lastname@example.com", "firstname.lastname"),
130      ("email@subdomain.example.com", "email"),
131      ("firstname+lastname@example.com", "firstname+lastname"),
132      ("email@123.123.123.123", "email"),
133      ("email@[123.123.123.123]", "email"),
134      // ("“email”@example.com", "“email”"),
135      ("1234567890@example.com", "1234567890"),
136      ("email@example-one.com", "email"),
137      ("_______@example.com", "_______"),
138      ("email@example.name", "email"),
139      ("email@example.museum", "email"),
140      ("email@example.co.jp", "email"),
141      ("firstname-lastname@example.com", "firstname-lastname"),
142      // strange
143      // ("much.”more\\ unusual”@example.com", "much.”more\\ unusual”"),
144      // ("very.unusual.”@”.unusual.com@example.com", "very.unusual.”"),
145      // ("very.”(),:;<>[]”.VERY.”very@\\\\ \"very”.unusual@strange.example.com", "very.”(),:;<>[]”.VERY.”very"),
146    ];
147    for (email, login) in emails {
148      println!("Checking GOOD {}", email);
149      let extracted_login = extract_login(email).unwrap();
150      assert_eq!(extracted_login, login);
151    }
152  }
153
154  #[test]
155  fn test_extract_invalid_email() {
156    // from https://gist.github.com/cjaoude/fd9910626629b53c4d25
157    let bad_emails = vec![
158      "plainaddress",
159      "#@%^%#$@#$@#.com",
160      "@example.com",
161      "Joe Smith <email@example.com>",
162      "email.example.com",
163      "email@example@example.com",
164      ".email@example.com",
165      "email.@example.com",
166      "email..email@example.com",
167      "あいうえお@example.com",
168      "email@example.com (Joe Smith)",
169      // "email@example",
170      // "email@-example.com",
171      // "email@example.web",
172      // "email@111.222.333.44444",
173      // "email@example..com",
174      "Abc..123@example.com",
175
176      // strange
177      "”(),:;<>[\\]@example.com",
178      "just”not”right@example.com",
179      "this\\ is\"really\"not\\allowed@example.com",
180    ];
181    for bad_email in bad_emails {
182      println!("Checking BAD {}", bad_email);
183      assert_eq!(extract_login(bad_email), None);
184    }
185  }
186
187  #[test]
188  fn test_good_email() {
189    let email = EmailValue::try_new("a@b.com").unwrap();
190    assert_eq!(email.val(), "a@b.com");
191  }
192
193  #[test]
194  fn test_bad_email() {
195    let email_result = EmailValue::try_new("");
196    assert_eq!(email_result, Err(InvalidValue::Empty));
197
198    let email_result = EmailValue::try_new("ab.com");
199    assert_eq!(email_result, Err(InvalidValue::BadFormat));
200  }
201
202  #[test]
203  fn test_fromstr() {
204    assert!(matches!("".parse::<EmailValue>(), Err(_))); 
205    assert!(matches!("notemail".parse::<EmailValue>(), Err(_))); 
206    assert_eq!("valid@email.com".parse::<EmailValue>().unwrap(), EmailValue::try_new("valid@email.com").unwrap());
207  }
208}