///|
/// A parsed email address: an optional display name plus the `local@domain`
/// addr-spec (RFC 5322 section 3.4).
pub struct MailAddress {
display_name : String
local_part : String
domain : String
} derive(Eq, Debug)
///|
pub fn MailAddress::new(
local_part : String,
domain : String,
display_name? : String = "",
) -> MailAddress {
{ display_name, local_part, domain }
}
///|
pub fn MailAddress::addr_spec(self : MailAddress) -> String {
"\{self.local_part}@\{self.domain}"
}
///|
pub fn MailAddress::display_name(self : MailAddress) -> String {
self.display_name
}
///|
pub fn MailAddress::local_part(self : MailAddress) -> String {
self.local_part
}
///|
pub fn MailAddress::domain(self : MailAddress) -> String {
self.domain
}
///|
/// Render the address back to a single RFC 5322 `mailbox`:
/// `Display Name ` when a display name is present,
/// otherwise the bare `addr-spec`.
pub fn MailAddress::to_string(self : MailAddress) -> String {
if self.display_name.is_empty() {
self.addr_spec()
} else {
"\{display_name_to_string(self.display_name)} <\{self.addr_spec()}>"
}
}
///|
pub impl Show for MailAddress with fn output(self, logger) {
logger.write_string(self.to_string())
}
///|
/// Quote the display name when it contains characters outside the safe atom set.
pub fn display_name_to_string(display_name : String) -> String {
if is_atom(display_name) {
display_name
} else {
let escaped = display_name
.replace(old="\\", new="\\\\")
.replace(old="\"", new="\\\"")
"\"\{escaped}\""
}
}
///|
/// `atext` check over a code point (RFC 5322 section 3.2.3).
fn is_atext_int(c : Int) -> Bool {
if c >= 0x41 && c <= 0x5A {
return true
}
if c >= 0x61 && c <= 0x7A {
return true
}
if c >= 0x30 && c <= 0x39 {
return true
}
c == 0x21 ||
c == 0x23 ||
c == 0x24 ||
c == 0x25 ||
c == 0x26 ||
c == 0x27 ||
c == 0x2A ||
c == 0x2B ||
c == 0x2D ||
c == 0x2F ||
c == 0x3D ||
c == 0x3F ||
c == 0x5E ||
c == 0x5F ||
c == 0x60 ||
c == 0x7B ||
c == 0x7C ||
c == 0x7D ||
c == 0x7E
}
///|
fn is_atext_byte(b : Byte) -> Bool {
is_atext_int(b.to_int())
}
///|
/// An atom is a nonempty run of `atext`.
fn is_atom(s : String) -> Bool {
if s.is_empty() {
return false
}
for ch in s {
if !is_atext_int(ch.to_int()) {
return false
}
}
true
}
///|
/// Validate a local-part / domain pair as an addr-spec (RFC 5322 3.4.1).
pub fn validate_addr_spec(
local_part : String,
domain : String,
) -> Unit raise MailFailure {
if local_part.is_empty() {
raise MailFailure::of(InvalidAddress, MM_ADDR_003, "empty local-part")
}
if domain.is_empty() {
raise MailFailure::of(InvalidAddress, MM_ADDR_004, "empty domain")
}
let lp = string_to_bytes(local_part)
if lp[0] == b'"' {
return validate_quoted_local(local_part)
}
guard validate_dot_atom(local_part) else {
raise MailFailure::of(
InvalidAddress,
MM_ADDR_005,
"invalid local-part: \{local_part}",
)
}
guard validate_domain(domain) else {
raise MailFailure::of(
InvalidAddress,
MM_ADDR_006,
"invalid domain: \{domain}",
)
}
}
///|
fn validate_quoted_local(local_part : String) -> Unit raise MailFailure {
let bytes = string_to_bytes(local_part)
if bytes.length() < 2 || bytes[bytes.length() - 1] != b'"' {
raise MailFailure::of(
InvalidAddress,
MM_ADDR_007,
"unclosed quoted-string local-part",
)
}
let mut i = 1
let mut escaped = false
while i < bytes.length() - 1 {
let b = bytes[i]
if escaped {
escaped = false
} else if b == b'\\' {
escaped = true
} else if b == b'\r' || b == b'\n' || b == b'\x00' {
raise MailFailure::of(
InvalidAddress,
MM_ADDR_005,
"control byte in quoted local-part",
)
}
i += 1
}
if escaped {
raise MailFailure::of(
InvalidAddress,
MM_ADDR_007,
"dangling escape in quoted local-part",
)
}
}
///|
/// Validate a dot-atom: `atext` runs separated by single dots, with no
/// leading or trailing dot.
fn validate_dot_atom(s : String) -> Bool {
let bytes = string_to_bytes(s)
if bytes.is_empty() {
return false
}
if bytes[0] == b'.' || bytes[bytes.length() - 1] == b'.' {
return false
}
let mut prev_dot = false
for i = 0; i < bytes.length(); i = i + 1 {
let b = bytes[i]
if b == b'.' {
if prev_dot {
return false
}
prev_dot = true
continue
}
prev_dot = false
if !is_atext_byte(b) {
return false
}
}
true
}
///|
fn validate_domain(domain : String) -> Bool {
let bytes = string_to_bytes(domain)
if bytes.is_empty() {
return false
}
if bytes[0] == b'[' {
return bytes[bytes.length() - 1] == b']'
}
let mut label_start = true
for i = 0; i < bytes.length(); i = i + 1 {
let b = bytes[i]
if b == b'.' {
if label_start {
return false
}
label_start = true
continue
}
let c = b.to_int()
let is_alnum = (c >= 0x61 && c <= 0x7A) ||
(c >= 0x41 && c <= 0x5A) ||
(c >= 0x30 && c <= 0x39)
if !is_alnum && b != b'-' {
return false
}
if b == b'-' && label_start {
return false
}
label_start = false
}
// a trailing dot leaves the last label empty
!label_start && bytes[bytes.length() - 1] != b'-'
}
///|
/// Find the index of `ch` not inside a quoted string, or `-1`.
fn find_unquoted(s : String, ch : Char) -> Int {
find_unquoted_from(s, ch, 0)
}
///|
fn find_unquoted_from(s : String, ch : Char, from : Int) -> Int {
let bytes = string_to_bytes(s)
let mut in_quote = false
let mut escaped = false
let mut i = from
while i < bytes.length() {
let b = bytes[i]
if escaped {
escaped = false
} else if b == b'\\' && in_quote {
escaped = true
} else if b == b'"' {
in_quote = !in_quote
} else if !in_quote && b == ch.to_int().to_byte() {
return i
}
i += 1
}
-1
}
///|
/// Remove a single `( comment )` (outside quotes) if present.
fn strip_trailing_comment(s : String) -> String {
let bytes = string_to_bytes(s)
let mut depth = 0
let mut in_quote = false
let mut escaped = false
let mut start = -1
for i = 0; i < bytes.length(); i = i + 1 {
let b = bytes[i]
if escaped {
escaped = false
} else if b == b'\\' && in_quote {
escaped = true
} else if b == b'"' {
in_quote = !in_quote
} else if !in_quote {
if b == b'(' {
if depth == 0 {
start = i
}
depth += 1
} else if b == b')' {
depth -= 1
if depth == 0 {
return s[0:start].to_owned() + s[i + 1:s.length()].to_owned()
}
}
}
}
s
}
///|
fn unquote_display(display : String) -> String {
let d = display.trim().to_owned()
let bytes = string_to_bytes(d)
if bytes.length() >= 2 &&
bytes[0] == b'"' &&
bytes[bytes.length() - 1] == b'"' {
d[1:d.length() - 1]
.to_owned()
.replace(old="\\\"", new="\"")
.replace(old="\\\\", new="\\")
} else {
d
}
}
///|
/// Parse a single `mailbox`:
/// - `"Display Name" `
/// - `local@domain (comment)`
/// - `local@domain`
/// - `Display Name `
pub fn MailAddress::parse(input : String) -> MailAddress raise MailFailure {
let s = strip_trailing_comment(input.trim().to_owned())
if s.is_empty() {
raise MailFailure::invalid_address("empty address")
}
let lt = find_unquoted(s, '<')
if lt >= 0 {
let display = s[0:lt].trim().to_owned()
let gt = find_unquoted(s, '>')
if gt < 0 {
raise MailFailure::invalid_address("missing '>' in angle-addr")
}
let addr_spec = s[lt + 1:gt].trim().to_owned()
guard addr_spec != "" else {
raise MailFailure::invalid_address("empty angle-addr")
}
let (local_part, domain) = split_addr_spec(addr_spec)
validate_addr_spec(local_part, domain)
let name = unquote_display(display)
return { display_name: name, local_part, domain }
}
let (local_part, domain) = split_addr_spec(s)
validate_addr_spec(local_part, domain)
{ display_name: "", local_part, domain }
}
///|
/// Split `local@domain`, raising when `@` is absent or appears more than once
/// outside quotes.
fn split_addr_spec(s : String) -> (String, String) raise MailFailure {
let at = find_unquoted(s, '@')
if at < 0 {
raise MailFailure::of(
InvalidAddress,
MM_ADDR_002,
"missing '@' in address: \{s}",
)
}
let second = find_unquoted_from(s, '@', at + 1)
if second >= 0 {
raise MailFailure::invalid_address("too many '@' in address: \{s}")
}
let local_part = s[0:at].trim().to_owned()
let domain = s[at + 1:s.length()].trim().to_owned()
(local_part, domain)
}
///|
/// Split an address list on commas, respecting quoted strings and comments.
pub fn split_address_list(input : String) -> Array[String] {
let items = []
let bytes = string_to_bytes(input)
let mut start = 0
let mut depth = 0
let mut in_quote = false
let mut escaped = false
for i = 0; i < bytes.length(); i = i + 1 {
let b = bytes[i]
if escaped {
escaped = false
} else if b == b'\\' && in_quote {
escaped = true
} else if b == b'"' {
in_quote = !in_quote
} else if !in_quote {
if b == b'(' {
depth += 1
} else if b == b')' {
if depth > 0 {
depth -= 1
}
} else if b == b',' && depth == 0 {
items.push(input[start:i].trim().to_owned())
start = i + 1
}
}
}
items.push(input[start:bytes.length()].trim().to_owned())
items.filter(fn(s) { !s.is_empty() })
}
///|
/// Parse a comma separated list of addresses (e.g. a `To` header value).
pub fn parse_address_list(
input : String,
) -> Array[MailAddress] raise MailFailure {
let out = []
for item in split_address_list(input) {
let addr = MailAddress::parse(item)
out.push(addr)
}
out
}