/// RFC 5322 address grammar parsing.
///
/// Extends the basic address parsing with the full mailbox syntax:
/// - Display names (phrases) before the angle-addr
/// - Comments in parentheses
/// - Group syntax: GroupName: a@b, c@d;
/// - Domain and local-part validation
///
/// The parsed structures reuse the core AST types and add the
/// Mailbox type for display-name aware addresses.
///|
/// A mailbox: optional display name plus the addr-spec.
pub struct Mailbox {
display_name : String
address : EmailAddress?
}
///|
/// Create a Mailbox value.
pub fn mailbox(
display_name? : String = "",
address~ : EmailAddress?,
) -> Mailbox {
Mailbox::{ display_name, address }
}
///|
/// Parse a full mailbox into a Mailbox value.
///
/// Handles formats:
/// user@example.com
///
/// Display Name
/// "Display Name"
/// Display Name (comment)
pub fn parse_mailbox(input : String) -> Mailbox? {
let s = input.trim(chars=" \t\r\n").to_owned()
if s.length() == 0 {
return None
}
// Find the angle-addr portion if present
let langle = s.find("<")
let rangle = s.find(">")
if langle is Some(lp) && rangle is Some(rp) && rp > lp {
// Extract the display name (text before <)
let name_raw = s
.unsafe_substring(start=0, end=lp)
.trim(chars=" \t\r\n")
.to_owned()
let addr_str = s.unsafe_substring(start=lp + 1, end=rp)
let addr = parse_address(addr_str)
let display = clean_display_name(name_raw)
let display_decoded = decode_header_value(display)
Some(Mailbox::{ display_name: display_decoded, address: addr })
} else {
// No angle brackets: the whole thing is an addr-spec,
// possibly with a leading phrase and comments.
let stripped = strip_comments(s)
let addr = parse_address(stripped)
match addr {
Some(a) => Some(Mailbox::{ display_name: "", address: Some(a) })
None =>
// Maybe "Display Name user@example.com" without brackets:
// take the last token as the address
parse_addr_from_phrase(stripped)
}
}
}
///|
/// Try to parse a phrase-form address without angle brackets.
/// Splits on the last '@' to find a plausible addr-spec tail.
fn parse_addr_from_phrase(s : String) -> Mailbox? {
// Find the last whitespace before '@'
let at_pos = s.find("@")
match at_pos {
None => None
Some(ap) => {
// Take the substring from the last space before @ to the end
let mut last_space : Int = -1
let mut i = 0
while i < ap {
let c = s.get_char(i).unwrap()
if c == ' ' || c == '\t' {
last_space = i
}
i = i + 1
}
if last_space >= 0 {
let name = s
.unsafe_substring(start=0, end=last_space)
.trim(chars=" \t")
.to_owned()
let addr_str = s.unsafe_substring(start=last_space + 1, end=s.length())
let addr = parse_address(addr_str)
match addr {
Some(a) =>
Some(Mailbox::{
display_name: decode_header_value(clean_display_name(name)),
address: Some(a),
})
None => None
}
} else {
let addr = parse_address(s)
match addr {
Some(a) => Some(Mailbox::{ display_name: "", address: Some(a) })
None => None
}
}
}
}
}
///|
/// Clean a display name: strip quotes, unescape, and trim.
fn clean_display_name(s : String) -> String {
let trimmed = s.trim(chars=" \t").to_owned()
if trimmed.has_prefix("\"") &&
trimmed.has_suffix("\"") &&
trimmed.length() >= 2 {
let inner = trimmed.unsafe_substring(start=1, end=trimmed.length() - 1)
unescape_quoted(inner)
} else {
trimmed
}
}
///|
/// Remove backslash escapes from a quoted string.
fn unescape_quoted(s : String) -> String {
let sb = StringBuilder::new()
let len = s.length()
let mut i = 0
while i < len {
let c = s.get_char(i).unwrap()
if c == '\\' && i + 1 < len {
sb.write_char(s.get_char(i + 1).unwrap())
i = i + 2
} else {
sb.write_char(c)
i = i + 1
}
}
sb.to_string()
}
///|
/// Parse an address list that may contain groups.
///
/// Returns all mailbox addresses found. Group members are flattened
/// into the result list.
pub fn parse_mailbox_list(input : String) -> Array[Mailbox] {
let results : Array[Mailbox] = []
let s = input.trim(chars=" \t\r\n").to_owned()
if s.length() == 0 {
return results
}
// Check for group syntax: ends with ';' and contains ':'
if s.has_suffix(";") && s.contains(":") {
// Split into groups
let groups = split_groups(s)
for g in groups {
let members = parse_group_members(g)
for m in members {
results.push(m)
}
}
} else {
// Plain mailbox list
let parts = split_addr_list(s)
for part in parts {
let mb = parse_mailbox(part)
match mb {
Some(m) => results.push(m)
None => ()
}
}
}
results
}
///|
/// Split an address list into group segments.
fn split_groups(input : String) -> Array[String] {
let results : Array[String] = []
let len = input.length()
let mut start = 0
let mut in_quote = false
let mut i = 0
while i < len {
let c = input.get_char(i).unwrap()
if c == '"' {
in_quote = !in_quote
} else if c == ';' && !in_quote {
let segment = input.unsafe_substring(start~, end=i + 1)
results.push(segment)
start = i + 1
}
i = i + 1
}
if start < len {
results.push(input.unsafe_substring(start~, end=len))
}
results
}
///|
/// Parse the members of a single group: "Name: a@b, c@d;"
fn parse_group_members(group : String) -> Array[Mailbox] {
let results : Array[Mailbox] = []
let colon = group.find(":")
match colon {
None => {
// Not a group, treat as mailbox
let mb = parse_mailbox(group)
match mb {
Some(m) => results.push(m)
None => ()
}
}
Some(cp) => {
let member_part = group
.unsafe_substring(start=cp + 1, end=group.length())
.trim(chars=" \t;")
.to_owned()
let parts = split_addr_list(member_part)
for part in parts {
let mb = parse_mailbox(part)
match mb {
Some(m) => results.push(m)
None => ()
}
}
}
}
results
}
///|
/// Validate a domain per RFC 5322: letters, digits, hyphen, and dots.
pub fn validate_domain(domain : String) -> Bool {
if domain.length() == 0 {
return false
}
let mut last_was_dot = true // leading dot not allowed
let mut i = 0
while i < domain.length() {
let c = domain.get_char(i).unwrap()
if c == '.' {
if last_was_dot {
return false
}
last_was_dot = true
} else if is_domain_char(c) {
last_was_dot = false
} else {
return false
}
i = i + 1
}
!last_was_dot
}
///|
/// Check whether a character is valid in a domain label.
fn is_domain_char(c : Char) -> Bool {
(c >= 'a' && c <= 'z') ||
(c >= 'A' && c <= 'Z') ||
(c >= '0' && c <= '9') ||
c == '-'
}
///|
/// Validate a local part per RFC 5322.
/// Accepts dot-atom and quoted-string forms.
pub fn validate_local_part(local_part : String) -> Bool {
if local_part.length() == 0 {
return false
}
if local_part.has_prefix("\"") {
// Quoted form: must end with a quote
local_part.has_suffix("\"") && local_part.length() >= 2
} else {
// Dot-atom: atext separated by dots, no leading/trailing/consecutive dots
let mut last_was_dot = true
let mut i = 0
while i < local_part.length() {
let c = local_part.get_char(i).unwrap()
if c == '.' {
if last_was_dot {
return false
}
last_was_dot = true
} else if is_atext_char(c) {
last_was_dot = false
} else {
return false
}
i = i + 1
}
!last_was_dot
}
}
///|
/// Check whether a character is valid in an atom (RFC 5322 atext).
fn is_atext_char(c : Char) -> Bool {
(c >= 'a' && c <= 'z') ||
(c >= 'A' && c <= 'Z') ||
(c >= '0' && c <= '9') ||
c == '!' ||
c == '#' ||
c == '$' ||
c == '%' ||
c == '&' ||
c == '\'' ||
c == '*' ||
c == '+' ||
c == '-' ||
c == '/' ||
c == '=' ||
c == '?' ||
c == '^' ||
c == '_' ||
c == '`' ||
c == '{' ||
c == '|' ||
c == '}' ||
c == '~'
}
///|
/// Validate a full email address (local part and domain).
pub fn validate_email_address(addr : EmailAddress) -> Bool {
validate_local_part(addr.local_part) && validate_domain(addr.domain)
}
///|
/// Get the display name from a Mailbox, or the local part if empty.
pub fn mailbox_display_name(mb : Mailbox) -> String {
if mb.display_name.length() > 0 {
mb.display_name
} else {
match mb.address {
None => ""
Some(a) => a.local_part
}
}
}
///|
/// Format a Mailbox back to a string.
pub fn mailbox_to_string(mb : Mailbox) -> String {
let addr_str = match mb.address {
None => ""
Some(a) => address_to_string(a)
}
if mb.display_name.length() > 0 {
// Quote the display name if it contains special characters
let name = if mb.display_name.contains(",") ||
mb.display_name.contains(";") ||
mb.display_name.contains(":") ||
mb.display_name.contains("<") ||
mb.display_name.contains(">") ||
mb.display_name.contains("@") {
"\"" + mb.display_name + "\""
} else {
mb.display_name
}
if addr_str.length() > 0 {
name + " <" + addr_str + ">"
} else {
name
}
} else {
addr_str
}
}
///|
/// Convert a list of Mailboxes to a comma-separated string.
pub fn mailbox_list_to_string(mbs : Array[Mailbox]) -> String {
let sb = StringBuilder::new()
for i = 0; i < mbs.length(); i = i + 1 {
if i > 0 {
sb.write_string(", ")
}
sb.write_string(mailbox_to_string(mbs[i]))
}
sb.to_string()
}
///|
/// Get all EmailAddress values from an array of Mailboxes.
pub fn mailbox_addresses(mbs : Array[Mailbox]) -> Array[EmailAddress] {
let results : Array[EmailAddress] = []
for mb in mbs {
match mb.address {
Some(a) => results.push(a)
None => ()
}
}
results
}