Files
rust-email_address/src/lib.rs
T
Simon Johnston 0d6786f83e Documentation.
2020-03-23 20:54:13 -07:00

673 lines
20 KiB
Rust

/*!
A Rust crate providing an implementation of an RFC-compliant `EmailAddress` newtype.
# Example
The following shoes the basic `is_valid` and `from_str` functions.
```rust
use email_address::*;
use std::str::FromStr;
assert!(EmailAddress::is_valid("user.name+tag+sorting@example.com"));
assert_eq!(
EmailAddress::from_str("Abc.example.com"),
Error::MissingSeparator.into()
);
```
The following shows the three format functions used to output an email address.
```rust
use email_address::*;
use std::str::FromStr;
let email = EmailAddress::from_str("johnstonsk@gmail.com").unwrap();
assert_eq!(
email.to_string(),
"johnstonsk@gmail.com".to_string()
);
assert_eq!(
email.to_uri(),
"mailto:johnstonsk@gmail.com".to_string()
);
assert_eq!(
email.to_display("Simon Johnston"),
"Simon Johnston <johnstonsk@gmail.com>".to_string()
);
```
# Specifications
1. RFC 3629: [_UTF-8, a transformation format of ISO 10646_](https://tools.ietf.org/html/rfc3629),
IETF, Nov 2003.
1. RFC 3696: [_Application Techniques for Checking and Transformation of
Names_](https://tools.ietf.org/html/rfc3696), IETF, Feb 2004.
1. RFC 5321: [_Simple Mail Transfer Protocol_](https://tools.ietf.org/html/rfc5321),
IETF, Oct 2008.
1. RFC 5322: [_Internet Message Format_](https://tools.ietf.org/html/rfc5322), I
ETF, Oct 2008.
1. RFC 5890: [_Internationalized Domain Names for Applications (IDNA): Definitions and Document
Framework_](https://tools.ietf.org/html/rfc5890), IETF, Aug 2010.
1. RFC 6531: [_SMTP Extension for Internationalized Email_](https://tools.ietf.org/html/rfc6531),
IETF, Feb 2012
1. RFC 5234: [_Augmented BNF for Syntax Specifications: ABNF_](https://tools.ietf.org/html/rfc5234),
IETF, Jan 2008.
From RFC 5322: §3.2.1. [Quoted characters](https://tools.ietf.org/html/rfc5322#section-3.2.1):
```ebnf
quoted-pair = ("\" (VCHAR / WSP)) / obs-qp
```
From RFC 5322: §3.2.2. [Folding White Space and Comments](https://tools.ietf.org/html/rfc5322#section-3.2.2):
```ebnf
FWS = ([*WSP CRLF] 1*WSP) / obs-FWS
; Folding white space
ctext = %d33-39 / ; Printable US-ASCII
%d42-91 / ; characters not including
%d93-126 / ; "(", ")", or "\"
obs-ctext
ccontent = ctext / quoted-pair / comment
comment = "(" *([FWS] ccontent) [FWS] ")"
CFWS = (1*([FWS] comment) [FWS]) / FWS
```
From RFC 5322: §3.2.3. [Atom](https://tools.ietf.org/html/rfc5322#section-3.2.3):
```ebnf
atext = ALPHA / DIGIT / ; Printable US-ASCII
"!" / "#" / ; characters not including
"$" / "%" / ; specials. Used for atoms.
"&" / "'" /
"*" / "+" /
"-" / "/" /
"=" / "?" /
"^" / "_" /
"`" / "{" /
"|" / "}" /
"~"
atom = [CFWS] 1*atext [CFWS]
dot-atom-text = 1*atext *("." 1*atext)
dot-atom = [CFWS] dot-atom-text [CFWS]
specials = "(" / ")" / ; Special characters that do
"<" / ">" / ; not appear in atext
"[" / "]" /
":" / ";" /
"@" / "\" /
"," / "." /
DQUOTE
```
From RFC 5322: §3.2.4. [Quoted Strings](https://tools.ietf.org/html/rfc5322#section-3.2.4):
```ebnf
qtext = %d33 / ; Printable US-ASCII
%d35-91 / ; characters not including
%d93-126 / ; "\" or the quote character
obs-qtext
qcontent = qtext / quoted-pair
quoted-string = [CFWS]
DQUOTE *([FWS] qcontent) [FWS] DQUOTE
[CFWS]
```
From RFC 5322, §3.4.1. [Addr-Spec Specification](https://tools.ietf.org/html/rfc5322#section-3.4.1):
```ebnf
addr-spec = local-part "@" domain
local-part = dot-atom / quoted-string / obs-local-part
domain = dot-atom / domain-literal / obs-domain
domain-literal = [CFWS] "[" *([FWS] dtext) [FWS] "]" [CFWS]
dtext = %d33-90 / ; Printable US-ASCII
%d94-126 / ; characters not including
obs-dtext ; "[", "]", or "\"
```
RFC 3696, §3. [Restrictions on email addresses](https://tools.ietf.org/html/rfc3696#section-3)
describes in detail the quoting of characters in an address.
RFC 6531, §3.3. [Extended Mailbox Address Syntax](https://tools.ietf.org/html/rfc6531#section-3.3)
extends the rules above for non-ASCII character sets.
```ebnf
UTF8-non-ascii = UTF8-2 / UTF8-3 / UTF8-4
```
This refers to RFC 6529 §4. [Syntax of UTF-8 Byte Sequences](https://tools.ietf.org/html/rfc3629#section-4):
> A UTF-8 string is a sequence of octets representing a sequence of UCS
> characters. An octet sequence is valid UTF-8 only if it matches the
> following syntax, which is derived from the rules for encoding UTF-8
> and is expressed in the ABNF of [RFC2234].
```ebnf
UTF8-octets = *( UTF8-char )
UTF8-char = UTF8-1 / UTF8-2 / UTF8-3 / UTF8-4
UTF8-1 = %x00-7F
UTF8-2 = %xC2-DF UTF8-tail
UTF8-3 = %xE0 %xA0-BF UTF8-tail / %xE1-EC 2( UTF8-tail ) /
%xED %x80-9F UTF8-tail / %xEE-EF 2( UTF8-tail )
UTF8-4 = %xF0 %x90-BF 2( UTF8-tail ) / %xF1-F3 3( UTF8-tail ) /
%xF4 %x80-8F 2( UTF8-tail )
UTF8-tail = %x80-BF
```
Comments in addresses are discussed in RFC 5322 Appendix A.5. [White Space, Comments, and Other
Oddities](https://tools.ietf.org/html/rfc5322#appendix-A.5).
An informal description can be found on [Wikipedia](https://en.wikipedia.org/wiki/Email_address).
*/
#![warn(
missing_debug_implementations,
missing_docs,
unused_extern_crates,
rust_2018_idioms
)]
#[cfg(feature = "serde_support")]
use serde::{Deserialize, Serialize};
use std::fmt::{Debug, Display, Formatter};
use std::str::FromStr;
// ------------------------------------------------------------------------------------------------
// Public Types
// ------------------------------------------------------------------------------------------------
///
/// Error type used when parsing an address.
///
#[derive(Debug, Clone, PartialEq)]
pub enum Error {
/// An invalid character was found in some component of the address.
InvalidCharacter,
/// The separator character between `local-part` and `domain` (character: '@') was missing.
MissingSeparator,
/// More than one separator character (character: '@') was found.
TooManySeparators,
/// The `local-part` is an empty string.
LocalPartEmpty,
/// The `local-part` is is too long.
LocalPartTooLong,
/// The `domain` is an empty string.
DomainEmpty,
/// The `domain` is is too long.
DomainTooLong,
/// Too few `sub-domain`s in `domain`.
DomainTooFew,
/// Invalid placement of the domain separator (character: '.').
DomainInvalidSeparator,
/// The quotes (character: '"') around `local-part` are unbalanced.
UnbalancedQuotes,
/// A Comment within the either the `local-part`, or `domain`, was malformed.
InvalidComment,
/// An IP address in a `domain-literal` was malformed.
InvalidIPAddress,
}
///
/// Type representing a single email address. This is basically a wrapper around a String, the
/// email address is parsed for correctness with `FromStr::from_str`, which is the only want to
/// create an instance. The various components of the email _are not_ parsed out to be accessible
/// independently.
///
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde_support", derive(Deserialize, Serialize))]
pub struct EmailAddress(String);
// ------------------------------------------------------------------------------------------------
// Implementations
// ------------------------------------------------------------------------------------------------
const LOCAL_PART_MAX_LENGTH: usize = 64;
const DOMAIN_MAX_LENGTH: usize = 255;
#[allow(dead_code)]
const CR: char = '\r';
#[allow(dead_code)]
const LF: char = '\n';
const SP: char = ' ';
const HTAB: char = '\t';
const ESC: char = '\\';
const AT: char = '@';
const DOT: char = '.';
const DQUOTE: char = '"';
const LBRACKET: char = '[';
const RBRACKET: char = ']';
#[allow(dead_code)]
const LPAREN: char = '(';
#[allow(dead_code)]
const RPAREN: char = ')';
const LT: char = '<';
const GT: char = '>';
const MAILTO_URI_PREFIX: &str = "mailto:";
impl Display for Error {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Error::InvalidCharacter => write!(f, "Invalid character."),
Error::LocalPartEmpty => write!(f, "Local part is empty."),
Error::LocalPartTooLong => write!(
f,
"Local part is too long. Length limit: {}",
LOCAL_PART_MAX_LENGTH
),
Error::DomainEmpty => write!(f, "Domain is empty."),
Error::DomainTooLong => {
write!(f, "Domain is too long. Length limit: {}", DOMAIN_MAX_LENGTH)
}
Error::MissingSeparator => write!(f, "Missing separator character '{}'.", AT),
Error::TooManySeparators => {
write!(f, "Found more than one separator character '{}'.", AT)
}
Error::DomainTooFew => write!(f, "Too few parts in the domain"),
Error::DomainInvalidSeparator => {
write!(f, "Invalid placement of the domain separator '{:?}", DOT)
}
Error::InvalidIPAddress => write!(f, "Invalid IP Address specified for doamin."),
Error::UnbalancedQuotes => write!(f, "Quotes around the local-part are unbalanced."),
Error::InvalidComment => write!(f, "A comment was badly formed."),
}
}
}
unsafe impl Send for Error {}
unsafe impl Sync for Error {}
impl std::error::Error for Error {}
impl<T> Into<std::result::Result<T, Error>> for Error {
fn into(self) -> Result<T, Error> {
Err(self)
}
}
// ------------------------------------------------------------------------------------------------
impl Display for EmailAddress {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
impl FromStr for EmailAddress {
type Err = Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
parse_address(s)
}
}
impl EmailAddress {
///
/// Determine whether the passed string is a valid email address. Note this is equivalent to
/// the following:
///
/// ```rust
/// use email_address::*;
/// use std::str::FromStr;
///
/// let is_valid = EmailAddress::from_str("johnstonskj@gmail.com").is_ok();
/// ```
///
pub fn is_valid(address: &str) -> bool {
Self::from_str(&address).is_ok()
}
///
/// Return this email address formatted as a URI.
///
pub fn to_uri(&self) -> String {
format!("{}{}", MAILTO_URI_PREFIX, self)
}
///
/// Return a string formatted as a display email with the user name.
///
pub fn to_display(&self, display_name: &str) -> String {
format!("{} <{}>", display_name, self)
}
}
// ------------------------------------------------------------------------------------------------
// Private Functions
// ------------------------------------------------------------------------------------------------
fn parse_address(address: &str) -> Result<EmailAddress, Error> {
let address = if address.starts_with(LT) && address.ends_with(GT) {
&address[1..address.len() - 1]
} else {
address
};
let parts = address.split(AT).collect::<Vec<&str>>();
match parts.len() {
l if l < 2 => Error::MissingSeparator.into(),
l if l > 2 => Error::TooManySeparators.into(),
_ => {
parse_local_part(parts.first().unwrap())?;
parse_domain(parts.last().unwrap())?;
Ok(EmailAddress(address.to_string()))
}
}
}
fn parse_local_part(part: &str) -> Result<(), Error> {
if part.is_empty() {
Error::LocalPartEmpty.into()
} else if part.len() > LOCAL_PART_MAX_LENGTH {
Error::LocalPartTooLong.into()
} else if part.starts_with(DQUOTE) && part.ends_with(DQUOTE) {
parse_quoted_local_part(&part[1..part.len() - 1])
} else {
parse_unquoted_local_part(part)
}
}
fn parse_quoted_local_part(part: &str) -> Result<(), Error> {
if part.is_ascii() && is_qcontent(part) {
return Ok(());
}
Error::InvalidCharacter.into()
}
fn parse_unquoted_local_part(part: &str) -> Result<(), Error> {
if part.is_ascii() && is_dot_atom_text(part) {
return Ok(());
}
Error::InvalidCharacter.into()
}
fn parse_domain(part: &str) -> Result<(), Error> {
if part.is_empty() {
Error::DomainEmpty.into()
} else if part.len() > LOCAL_PART_MAX_LENGTH {
Error::DomainTooLong.into()
} else if part.starts_with(LBRACKET) && part.ends_with(RBRACKET) {
parse_literal_domain(&part[1..part.len() - 1])
} else {
parse_text_domain(part)
}
}
fn parse_text_domain(part: &str) -> Result<(), Error> {
if part.is_ascii() && is_dot_atom_text(part) {
return Ok(());
}
Error::InvalidCharacter.into()
}
fn parse_literal_domain(part: &str) -> Result<(), Error> {
if part.is_ascii() && part.chars().all(is_dtext_char) {
return Ok(());
}
Error::InvalidCharacter.into()
}
// ------------------------------------------------------------------------------------------------
fn is_atext(c: char) -> bool {
c.is_alphanumeric()
|| c == '!'
|| c == '#'
|| c == '$'
|| c == '%'
|| c == '&'
|| c == '\''
|| c == '*'
|| c == '+'
|| c == '-'
|| c == '/'
|| c == '='
|| c == '?'
|| c == '^'
|| c == '_'
|| c == '`'
|| c == '{'
|| c == '|'
|| c == '}'
|| c == '~'
}
fn is_atom(s: &str) -> bool {
!s.is_empty() && s.chars().all(is_atext)
}
fn is_dot_atom_text(s: &str) -> bool {
s.split(DOT).all(is_atom)
}
fn is_vchar(c: char) -> bool {
c >= '\x21' && c <= '\x7E'
}
fn is_wsp(c: char) -> bool {
c == SP || c == HTAB
}
#[allow(dead_code)]
fn is_ctext_char(c: char) -> bool {
(c >= '\x21' && c == '\x27') || (c >= '\x2A' && c <= '\x5B') || (c >= '\x5D' && c <= '\x7E')
}
fn is_qtext_char(c: char) -> bool {
c == '\x21' || (c >= '\x23' && c <= '\x5B') || (c >= '\x5D' && c <= '\x7E')
}
fn is_qcontent(s: &str) -> bool {
let mut char_iter = s.chars();
while let Some(c) = &char_iter.next() {
if c == &ESC {
// quoted-pair
match char_iter.next() {
Some(c2) if is_vchar(c2) => (),
_ => return false,
}
} else if !(is_wsp(*c) || is_qtext_char(*c)) {
// qtext
return false;
}
}
true
}
fn is_dtext_char(c: char) -> bool {
(c >= '\x21' && c <= '\x5A') || (c >= '\x5E' && c <= '\x7E')
}
#[allow(dead_code)]
fn is_ctext(s: &str) -> bool {
s.chars().all(is_ctext_char)
}
// ------------------------------------------------------------------------------------------------
// Unit Tests
// ------------------------------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_good_examples_from_wikipedia_01() {
assert!(EmailAddress::is_valid("simple@example.com"));
}
#[test]
fn test_good_examples_from_wikipedia_02() {
assert!(EmailAddress::is_valid("very.common@example.com"));
}
#[test]
fn test_good_examples_from_wikipedia_03() {
assert!(EmailAddress::is_valid(
"disposable.style.email.with+symbol@example.com"
));
}
#[test]
fn test_good_examples_from_wikipedia_04() {
assert!(EmailAddress::is_valid(
"other.email-with-hyphen@example.com"
));
}
#[test]
fn test_good_examples_from_wikipedia_05() {
assert!(EmailAddress::is_valid("fully-qualified-domain@example.com"));
}
#[test]
fn test_good_examples_from_wikipedia_06() {
// may go to user.name@example.com inbox depending on mail server
assert!(EmailAddress::is_valid("user.name+tag+sorting@example.com"));
}
#[test]
fn test_good_examples_from_wikipedia_07() {
// one-letter local-part
assert!(EmailAddress::is_valid("x@example.com"));
}
#[test]
fn test_good_examples_from_wikipedia_08() {
assert!(EmailAddress::is_valid("example-indeed@strange-example.com"));
}
#[test]
fn test_good_examples_from_wikipedia_09() {
// local domain name with no TLD, although ICANN highly discourages dotless email addresses
assert!(EmailAddress::is_valid("admin@mailserver1"));
}
#[test]
fn test_good_examples_from_wikipedia_10() {
// see the List of Internet top-level domains
assert!(EmailAddress::is_valid("example@s.example"));
}
#[test]
fn test_good_examples_from_wikipedia_11() {
// space between the quotes
assert!(EmailAddress::is_valid("\" \"@example.org"));
}
#[test]
fn test_good_examples_from_wikipedia_12() {
// quoted double dot
assert!(EmailAddress::is_valid("\"john..doe\"@example.org"));
}
#[test]
fn test_good_examples_from_wikipedia_13() {
// bangified host route used for uucp mailers
assert!(EmailAddress::is_valid("mailhost!username@example.org"));
}
#[test]
fn test_good_examples_from_wikipedia_14() {
// % escaped mail route to user@example.com via example.org
assert!(EmailAddress::is_valid("user%example.com@example.org"));
}
#[test]
fn test_good_examples_from_wikipedia_15() {
assert!(EmailAddress::is_valid("jsmith@[192.168.2.1]"));
}
#[test]
fn test_good_examples_from_wikipedia_16() {
assert!(EmailAddress::is_valid("jsmith@[IPv6:2001:db8::1]"));
}
#[test]
fn test_bad_examples_from_wikipedia_00() {
// (no @ character)
assert_eq!(
EmailAddress::from_str("Abc.example.com"),
Error::MissingSeparator.into()
);
}
#[test]
fn test_bad_examples_from_wikipedia_01() {
// (only one @ is allowed outside quotation marks)
assert_eq!(
EmailAddress::from_str("A@b@c@example.com"),
Error::TooManySeparators.into()
);
}
#[test]
fn test_bad_examples_from_wikipedia_02() {
// (none of the special characters in this local-part are allowed outside quotation marks)
assert_eq!(
EmailAddress::from_str("a\"b(c)d,e:f;g<h>i[j\\k]l@example.com"),
Error::InvalidCharacter.into()
);
}
#[test]
fn test_bad_examples_from_wikipedia_03() {
// (quoted strings must be dot separated or the only element making up the local-part)
assert_eq!(
EmailAddress::from_str("just\"not\"right@example.com"),
Error::InvalidCharacter.into()
);
}
#[test]
fn test_bad_examples_from_wikipedia_04() {
// (spaces, quotes, and backslashes may only exist when within quoted strings and preceded by a backslash)
assert_eq!(
EmailAddress::from_str("this is\"not\\allowed@example.com"),
Error::InvalidCharacter.into()
);
}
#[test]
fn test_bad_examples_from_wikipedia_05() {
// (even if escaped (preceded by a backslash), spaces, quotes, and backslashes must still be contained by quotes)
assert_eq!(
EmailAddress::from_str("this\\ still\"not\\allowed@example.com"),
Error::InvalidCharacter.into()
);
}
#[test]
fn test_bad_examples_from_wikipedia_06() {
// (local part is longer than 64 characters)
assert_eq!(
EmailAddress::from_str(
"1234567890123456789012345678901234567890123456789012345678901234+x@example.com"
),
Error::LocalPartTooLong.into()
);
}
}