#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParsedEmail {
pub message_id: Option<String>,
pub in_reply_to: Option<String>,
pub references: Option<String>,
pub subject: Option<String>,
pub from: Address,
pub to: Vec<Address>,
pub cc: Vec<Address>,
pub bcc: Vec<Address>,
pub reply_to: Vec<Address>,
pub date: Option<DateTime>,
pub body_text: Option<String>,
pub body_html: Option<String>,
pub attachments: Vec<ParsedAttachment>,
pub raw_headers: String,
pub size: u64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Address {
pub name: Option<String>,
pub email: String,
}
const SPECIALS: &[char] = &[
'(', ')', '<', '>', '[', ']', ':', ';', '@', '\\', ',', '.', '"',
];
impl std::fmt::Display for Address {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match &self.name {
Some(name) if !name.is_empty() => {
if !name.is_ascii() {
let encoded = crate::builder::encode_rfc2047_if_needed(name);
write!(f, "{encoded} <{}>", self.email)
} else if name.chars().any(|c| SPECIALS.contains(&c)) {
let escaped = name.replace('\\', "\\\\").replace('"', "\\\"");
write!(f, "\"{escaped}\" <{}>", self.email)
} else {
write!(f, "{name} <{}>", self.email)
}
}
_ => write!(f, "{}", self.email),
}
}
}
impl std::str::FromStr for Address {
type Err = crate::error::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let s = s.trim();
if s.is_empty() {
return Err(crate::error::Error::InvalidAddress(
"empty address string".into(),
));
}
if let Some(angle_start) = s.rfind('<') {
if let Some(angle_end) = s.rfind('>') {
if angle_end > angle_start {
let email = s[angle_start + 1..angle_end].trim().to_string();
let name_part = s[..angle_start].trim();
let name = if name_part.is_empty() {
None
} else {
let stripped = strip_outer_quotes(name_part);
let name = stripped.trim().to_string();
if name.is_empty() {
None
} else {
let unescaped = unescape_quoted_string(&name);
let decoded = crate::parser::decode_encoded_words(&unescaped);
Some(decoded)
}
};
if email.is_empty() {
return Err(crate::error::Error::InvalidAddress(
"empty email in angle brackets".into(),
));
}
return Ok(Self { name, email });
}
}
}
if s.contains('@') {
return Ok(Self {
name: None,
email: s.to_string(),
});
}
Err(crate::error::Error::InvalidAddress(format!(
"cannot parse address: {s}"
)))
}
}
fn strip_outer_quotes(input: &str) -> &str {
if input.len() >= 2 && input.starts_with('"') && input.ends_with('"') {
&input[1..input.len() - 1]
} else {
input
}
}
fn unescape_quoted_string(input: &str) -> String {
let mut result = String::with_capacity(input.len());
let mut chars = input.chars();
while let Some(c) = chars.next() {
if c == '\\' {
if let Some(next) = chars.next() {
result.push(next);
} else {
result.push(c);
}
} else {
result.push(c);
}
}
result
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParsedAttachment {
pub filename: Option<String>,
pub content_type: String,
pub content_id: Option<String>,
pub is_inline: bool,
pub size: Option<u64>,
pub section: Option<String>,
}
const DOW_NAMES: [&str; 7] = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const MONTH_NAMES: [&str; 12] = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
];
#[derive(Debug, Clone)]
pub struct DateTime {
pub year: u16,
pub month: u8,
pub day: u8,
pub hour: u8,
pub minute: u8,
pub second: u8,
pub tz_offset_minutes: i16,
}
impl DateTime {
pub fn to_unix_timestamp(&self) -> i64 {
let days = Self::civil_to_days(
i32::from(self.year),
u32::from(self.month),
u32::from(self.day),
);
let secs = days * 86400
+ i64::from(self.hour) * 3600
+ i64::from(self.minute) * 60
+ i64::from(self.second);
secs - i64::from(self.tz_offset_minutes) * 60
}
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
pub fn from_unix_timestamp(timestamp: i64, tz_offset_minutes: i16) -> Self {
let local_secs = timestamp + i64::from(tz_offset_minutes) * 60;
let days = local_secs.div_euclid(86400);
let time_secs = local_secs.rem_euclid(86400) as u64;
let (year, month, day) = Self::civil_from_days(days);
Self {
year: year as u16,
month: month as u8,
day: day as u8,
hour: (time_secs / 3600) as u8,
minute: ((time_secs % 3600) / 60) as u8,
second: (time_secs % 60) as u8,
tz_offset_minutes,
}
}
#[allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_possible_wrap
)]
pub fn now() -> Self {
use std::time::{SystemTime, UNIX_EPOCH};
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
Self::from_unix_timestamp(secs as i64, 0)
}
#[allow(clippy::cast_sign_loss)]
pub fn weekday(&self) -> u8 {
let days = Self::civil_to_days(
i32::from(self.year),
u32::from(self.month),
u32::from(self.day),
);
(((days % 7) + 4 + 7) % 7) as u8
}
pub fn to_rfc5322_string(&self) -> String {
let dow = self.weekday();
let dow_name = DOW_NAMES[dow as usize];
let month_idx = self.month.clamp(1, 12).saturating_sub(1) as usize;
let month_name = MONTH_NAMES[month_idx];
let (sign, tz_h, tz_m) = self.tz_parts();
format!(
"{dow_name}, {:02} {month_name} {:04} {:02}:{:02}:{:02} {sign}{tz_h:02}{tz_m:02}",
self.day, self.year, self.hour, self.minute, self.second,
)
}
pub fn to_iso8601_string(&self) -> String {
let (sign, tz_h, tz_m) = self.tz_parts();
format!(
"{:04}-{:02}-{:02}T{:02}:{:02}:{:02}{sign}{tz_h:02}:{tz_m:02}",
self.year, self.month, self.day, self.hour, self.minute, self.second,
)
}
pub fn parse_rfc5322(input: &str) -> Option<Self> {
crate::parser::parse_rfc5322_date(input)
}
fn tz_parts(&self) -> (char, u16, u16) {
let sign = if self.tz_offset_minutes >= 0 {
'+'
} else {
'-'
};
let abs = self.tz_offset_minutes.unsigned_abs();
(sign, abs / 60, abs % 60)
}
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
fn civil_from_days(z: i64) -> (i32, u32, u32) {
let z = z + 719_468;
let era = (if z >= 0 { z } else { z - 146_096 }) / 146_097;
let doe = (z - era * 146_097) as u32;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
let y = i64::from(yoe) + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
(y as i32, m, d)
}
#[allow(clippy::cast_sign_loss, clippy::cast_possible_wrap)]
fn civil_to_days(year: i32, month: u32, day: u32) -> i64 {
let y = if month <= 2 {
i64::from(year) - 1
} else {
i64::from(year)
};
let m = if month <= 2 { month + 9 } else { month - 3 };
let era = (if y >= 0 { y } else { y - 399 }) / 400;
let yoe = (y - era * 400) as u64;
let doy = (153 * u64::from(m) + 2) / 5 + u64::from(day) - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
era * 146_097 + doe as i64 - 719_468
}
}
impl PartialEq for DateTime {
fn eq(&self, other: &Self) -> bool {
self.to_unix_timestamp() == other.to_unix_timestamp()
}
}
impl Eq for DateTime {}
impl std::hash::Hash for DateTime {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
self.to_unix_timestamp().hash(state);
}
}
impl PartialOrd for DateTime {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Ord for DateTime {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.to_unix_timestamp().cmp(&other.to_unix_timestamp())
}
}
impl std::fmt::Display for DateTime {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let (sign, tz_h, tz_m) = self.tz_parts();
write!(
f,
"{:04}-{:02}-{:02} {:02}:{:02}:{:02} {sign}{tz_h:02}{tz_m:02}",
self.year, self.month, self.day, self.hour, self.minute, self.second,
)
}
}
impl std::str::FromStr for DateTime {
type Err = crate::error::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::parse_rfc5322(s).ok_or_else(|| {
crate::error::Error::InvalidDate(format!("cannot parse RFC 5322 date: {s}"))
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OutgoingEmail {
pub from: Address,
pub to: Vec<Address>,
pub cc: Vec<Address>,
pub bcc: Vec<Address>,
pub reply_to: Option<Address>,
pub subject: String,
pub body_text: Option<String>,
pub body_html: Option<String>,
pub in_reply_to: Option<String>,
pub references: Option<String>,
pub attachments: Vec<OutgoingAttachment>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OutgoingAttachment {
pub filename: String,
pub content_type: String,
pub data: Vec<u8>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BuiltMessage {
pub raw: Vec<u8>,
pub envelope_recipients: Vec<String>,
pub message_id: String,
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn address_display_bare_email() {
let addr = Address {
name: None,
email: "user@example.com".into(),
};
assert_eq!(addr.to_string(), "user@example.com");
}
#[test]
fn address_display_with_name() {
let addr = Address {
name: Some("John Doe".into()),
email: "john@example.com".into(),
};
assert_eq!(addr.to_string(), "John Doe <john@example.com>");
}
#[test]
fn address_display_name_with_specials_quoted() {
let addr = Address {
name: Some("Doe, John".into()),
email: "john@example.com".into(),
};
assert_eq!(addr.to_string(), "\"Doe, John\" <john@example.com>");
}
#[test]
fn address_display_name_with_quotes_escaped() {
let addr = Address {
name: Some("John \"Doc\" Doe".into()),
email: "john@example.com".into(),
};
assert_eq!(
addr.to_string(),
"\"John \\\"Doc\\\" Doe\" <john@example.com>"
);
}
#[test]
fn address_from_str_bare_email() {
let addr: Address = "user@example.com".parse().unwrap();
assert_eq!(addr.email, "user@example.com");
assert!(addr.name.is_none());
}
#[test]
fn address_from_str_with_name() {
let addr: Address = "John Doe <john@example.com>".parse().unwrap();
assert_eq!(addr.email, "john@example.com");
assert_eq!(addr.name.as_deref(), Some("John Doe"));
}
#[test]
fn address_from_str_quoted_name() {
let addr: Address = "\"Doe, John\" <john@example.com>".parse().unwrap();
assert_eq!(addr.email, "john@example.com");
assert_eq!(addr.name.as_deref(), Some("Doe, John"));
}
#[test]
fn address_from_str_escaped_quotes_in_name() {
let addr: Address = "\"John \\\"Doc\\\" Doe\" <john@example.com>"
.parse()
.unwrap();
assert_eq!(addr.email, "john@example.com");
assert_eq!(addr.name.as_deref(), Some("John \"Doc\" Doe"));
}
#[test]
fn address_from_str_empty_rejected() {
let result: Result<Address, _> = "".parse();
assert!(result.is_err());
}
#[test]
fn address_from_str_no_at_rejected() {
let result: Result<Address, _> = "not-an-email".parse();
assert!(result.is_err());
}
#[test]
fn address_round_trip_display_from_str() {
let original = Address {
name: Some("Doe, John".into()),
email: "john@example.com".into(),
};
let displayed = original.to_string();
let parsed: Address = displayed.parse().unwrap();
assert_eq!(original, parsed);
}
#[test]
fn datetime_now_returns_plausible_date() {
let now = DateTime::now();
assert!(now.year >= 2025, "DateTime::now() year is {}", now.year);
assert!((1..=12).contains(&now.month));
assert!((1..=31).contains(&now.day));
assert!(now.hour <= 23);
assert!(now.minute <= 59);
assert!(now.second <= 60);
assert_eq!(now.tz_offset_minutes, 0, "now() should return UTC");
}
#[test]
fn datetime_weekday_known_dates() {
let dt = DateTime {
year: 2025,
month: 2,
day: 13,
hour: 0,
minute: 0,
second: 0,
tz_offset_minutes: 0,
};
assert_eq!(dt.weekday(), 4, "2025-02-13 should be Thursday (4)");
let epoch = DateTime::from_unix_timestamp(0, 0);
assert_eq!(epoch.weekday(), 4, "1970-01-01 should be Thursday (4)");
let sunday = DateTime {
year: 2025,
month: 3,
day: 16,
hour: 0,
minute: 0,
second: 0,
tz_offset_minutes: 0,
};
assert_eq!(sunday.weekday(), 0, "2025-03-16 should be Sunday (0)");
}
#[test]
fn datetime_eq_consistent_with_ord() {
let utc = DateTime {
year: 2025,
month: 1,
day: 15,
hour: 12,
minute: 0,
second: 0,
tz_offset_minutes: 0,
};
let plus_five = DateTime {
year: 2025,
month: 1,
day: 15,
hour: 17,
minute: 0,
second: 0,
tz_offset_minutes: 300, };
assert_eq!(utc.to_unix_timestamp(), plus_five.to_unix_timestamp());
assert_eq!(
utc.cmp(&plus_five),
std::cmp::Ordering::Equal,
"cmp should consider same-UTC-instant values equal"
);
assert_eq!(
utc, plus_five,
"PartialEq must agree with Ord: same UTC instant should be =="
);
}
#[test]
fn datetime_hash_consistent_with_eq() {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
fn hash_of(dt: &DateTime) -> u64 {
let mut hasher = DefaultHasher::new();
dt.hash(&mut hasher);
hasher.finish()
}
let utc = DateTime {
year: 2025,
month: 1,
day: 15,
hour: 12,
minute: 0,
second: 0,
tz_offset_minutes: 0,
};
let plus_five = DateTime {
year: 2025,
month: 1,
day: 15,
hour: 17,
minute: 0,
second: 0,
tz_offset_minutes: 300,
};
assert_eq!(utc, plus_five);
assert_eq!(
hash_of(&utc),
hash_of(&plus_five),
"Hash must be consistent with Eq: same UTC instant must hash the same"
);
}
#[test]
fn datetime_to_rfc5322_string_utc() {
let dt = DateTime {
year: 2025,
month: 2,
day: 13,
hour: 15,
minute: 47,
second: 33,
tz_offset_minutes: 0,
};
assert_eq!(dt.to_rfc5322_string(), "Thu, 13 Feb 2025 15:47:33 +0000");
}
#[test]
fn datetime_to_rfc5322_string_positive_offset() {
let dt = DateTime {
year: 2025,
month: 2,
day: 13,
hour: 21,
minute: 17,
second: 33,
tz_offset_minutes: 330, };
assert_eq!(dt.to_rfc5322_string(), "Thu, 13 Feb 2025 21:17:33 +0530");
}
#[test]
fn datetime_to_rfc5322_string_negative_offset() {
let dt = DateTime {
year: 2025,
month: 3,
day: 16,
hour: 9,
minute: 0,
second: 0,
tz_offset_minutes: -480, };
assert_eq!(dt.to_rfc5322_string(), "Sun, 16 Mar 2025 09:00:00 -0800");
}
#[test]
fn datetime_parse_rfc5322_basic() {
let dt = DateTime::parse_rfc5322("Thu, 13 Feb 2025 15:47:33 +0000").unwrap();
assert_eq!(dt.year, 2025);
assert_eq!(dt.month, 2);
assert_eq!(dt.day, 13);
assert_eq!(dt.hour, 15);
assert_eq!(dt.minute, 47);
assert_eq!(dt.second, 33);
assert_eq!(dt.tz_offset_minutes, 0);
}
#[test]
fn datetime_parse_rfc5322_with_offset() {
let dt = DateTime::parse_rfc5322("Fri, 14 Feb 2025 09:15:00 -0800").unwrap();
assert_eq!(dt.year, 2025);
assert_eq!(dt.tz_offset_minutes, -480);
}
#[test]
fn datetime_parse_rfc5322_round_trip() {
let original = DateTime {
year: 2025,
month: 12,
day: 25,
hour: 0,
minute: 0,
second: 0,
tz_offset_minutes: 330,
};
let s = original.to_rfc5322_string();
let parsed = DateTime::parse_rfc5322(&s).unwrap();
assert_eq!(original, parsed);
}
#[test]
fn datetime_parse_rfc5322_invalid() {
assert!(DateTime::parse_rfc5322("not a date").is_none());
assert!(DateTime::parse_rfc5322("").is_none());
}
#[test]
fn datetime_to_iso8601_string_utc() {
let dt = DateTime {
year: 2025,
month: 2,
day: 13,
hour: 15,
minute: 47,
second: 33,
tz_offset_minutes: 0,
};
assert_eq!(dt.to_iso8601_string(), "2025-02-13T15:47:33+00:00");
}
#[test]
fn datetime_to_iso8601_string_positive_offset() {
let dt = DateTime {
year: 2025,
month: 2,
day: 13,
hour: 21,
minute: 17,
second: 33,
tz_offset_minutes: 330, };
assert_eq!(dt.to_iso8601_string(), "2025-02-13T21:17:33+05:30");
}
#[test]
fn datetime_to_iso8601_string_negative_offset() {
let dt = DateTime {
year: 2025,
month: 3,
day: 16,
hour: 9,
minute: 0,
second: 0,
tz_offset_minutes: -480, };
assert_eq!(dt.to_iso8601_string(), "2025-03-16T09:00:00-08:00");
}
#[test]
fn address_display_non_ascii_is_rfc2047_encoded() {
let addr = Address {
name: Some("José García".into()),
email: "jose@example.com".into(),
};
let displayed = addr.to_string();
assert!(
displayed.is_ascii(),
"Display output must be pure ASCII, got: {displayed}"
);
assert!(
displayed.contains("=?UTF-8?B?"),
"Non-ASCII name must be RFC 2047 encoded, got: {displayed}"
);
assert!(
displayed.contains("<jose@example.com>"),
"Email must appear in angle brackets, got: {displayed}"
);
}
#[test]
fn address_display_ascii_name_unchanged() {
let addr = Address {
name: Some("John Doe".into()),
email: "john@example.com".into(),
};
let displayed = addr.to_string();
assert_eq!(displayed, "John Doe <john@example.com>");
assert!(
!displayed.contains("=?"),
"ASCII name should not be RFC 2047 encoded, got: {displayed}"
);
}
#[test]
fn address_display_non_ascii_round_trip() {
let original = Address {
name: Some("José García".into()),
email: "jose@example.com".into(),
};
let displayed = original.to_string();
let parsed: Address = displayed.parse().unwrap();
assert_eq!(
original, parsed,
"Round-trip failed: displayed as '{displayed}', parsed name = {:?}",
parsed.name
);
}
#[test]
fn datetime_invalid_month_no_panic() {
let dt_zero = DateTime {
year: 2025,
month: 0,
day: 15,
hour: 12,
minute: 0,
second: 0,
tz_offset_minutes: 0,
};
let _ = dt_zero.to_rfc5322_string();
let _ = dt_zero.weekday();
let dt_thirteen = DateTime {
year: 2025,
month: 13,
day: 15,
hour: 12,
minute: 0,
second: 0,
tz_offset_minutes: 0,
};
let _ = dt_thirteen.to_rfc5322_string();
let _ = dt_thirteen.weekday();
let dt_max = DateTime {
year: 2025,
month: 255,
day: 15,
hour: 12,
minute: 0,
second: 0,
tz_offset_minutes: 0,
};
let _ = dt_max.to_rfc5322_string();
let _ = dt_max.weekday();
}
#[test]
fn datetime_from_str_basic() {
let dt: DateTime = "Thu, 13 Feb 2025 15:47:33 +0000".parse().unwrap();
assert_eq!(dt.year, 2025);
assert_eq!(dt.month, 2);
assert_eq!(dt.day, 13);
assert_eq!(dt.hour, 15);
assert_eq!(dt.minute, 47);
assert_eq!(dt.second, 33);
assert_eq!(dt.tz_offset_minutes, 0);
}
#[test]
fn datetime_from_str_with_offset() {
let dt: DateTime = "Fri, 14 Feb 2025 09:15:00 -0800".parse().unwrap();
assert_eq!(dt.year, 2025);
assert_eq!(dt.tz_offset_minutes, -480);
}
#[test]
fn datetime_from_str_invalid() {
let result: Result<DateTime, _> = "not a date".parse();
assert!(result.is_err());
}
#[test]
fn datetime_from_str_empty() {
let result: Result<DateTime, _> = "".parse();
assert!(result.is_err());
}
#[test]
fn datetime_from_str_round_trip() {
let original = DateTime {
year: 2025,
month: 7,
day: 4,
hour: 12,
minute: 30,
second: 0,
tz_offset_minutes: -300,
};
let s = original.to_rfc5322_string();
let parsed: DateTime = s.parse().unwrap();
assert_eq!(original, parsed);
}
#[test]
fn datetime_display_utc() {
let dt = DateTime {
year: 2025,
month: 2,
day: 13,
hour: 15,
minute: 47,
second: 33,
tz_offset_minutes: 0,
};
assert_eq!(dt.to_string(), "2025-02-13 15:47:33 +0000");
}
#[test]
fn datetime_display_positive_offset() {
let dt = DateTime {
year: 2025,
month: 2,
day: 13,
hour: 21,
minute: 17,
second: 33,
tz_offset_minutes: 330, };
assert_eq!(dt.to_string(), "2025-02-13 21:17:33 +0530");
}
#[test]
fn datetime_display_negative_offset() {
let dt = DateTime {
year: 2025,
month: 3,
day: 16,
hour: 9,
minute: 0,
second: 0,
tz_offset_minutes: -480, };
assert_eq!(dt.to_string(), "2025-03-16 09:00:00 -0800");
}
#[test]
fn datetime_display_non_half_hour_offset() {
let dt = DateTime {
year: 2025,
month: 6,
day: 1,
hour: 12,
minute: 0,
second: 0,
tz_offset_minutes: 345, };
assert_eq!(dt.to_string(), "2025-06-01 12:00:00 +0545");
}
#[test]
fn datetime_display_extreme_offset() {
let dt = DateTime {
year: 2025,
month: 1,
day: 1,
hour: 0,
minute: 0,
second: 0,
tz_offset_minutes: 720, };
assert_eq!(dt.to_string(), "2025-01-01 00:00:00 +1200");
let dt_neg = DateTime {
year: 2025,
month: 1,
day: 1,
hour: 0,
minute: 0,
second: 0,
tz_offset_minutes: -720, };
assert_eq!(dt_neg.to_string(), "2025-01-01 00:00:00 -1200");
}
#[test]
fn address_from_str_empty_angle_brackets_rejected() {
let result: Result<Address, _> = "<>".parse();
let err = result.unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("empty email in angle brackets"),
"expected 'empty email in angle brackets' error, got: {msg}"
);
}
#[test]
fn address_from_str_angle_brackets_no_name() {
let addr: Address = "<user@example.com>".parse().unwrap();
assert_eq!(addr.email, "user@example.com");
assert!(
addr.name.is_none(),
"expected name to be None for bare angle-bracket address, got: {:?}",
addr.name
);
}
#[test]
fn address_from_str_quoted_empty_name_is_none() {
let addr: Address = "\"\" <user@example.com>".parse().unwrap();
assert_eq!(addr.email, "user@example.com");
assert!(
addr.name.is_none(),
"expected name to be None for quoted empty name, got: {:?}",
addr.name
);
}
#[test]
fn address_from_str_whitespace_only_name_is_none() {
let addr: Address = " <user@example.com>".parse().unwrap();
assert_eq!(addr.email, "user@example.com");
assert!(
addr.name.is_none(),
"expected name to be None for whitespace-only prefix, got: {:?}",
addr.name
);
}
#[test]
fn address_from_str_unclosed_angle_bracket() {
let result: Result<Address, _> = "<user@example.com".parse();
if let Ok(addr) = result {
assert!(addr.name.is_none());
assert!(addr.email.contains("user@example.com"));
}
}
#[test]
fn address_display_name_with_special_chars() {
let addr = Address {
name: Some("O'Brien \\test".into()),
email: "obrien@example.com".into(),
};
let displayed = addr.to_string();
assert!(
displayed.contains("\\\\"),
"backslash should be escaped in quoted name, got: {displayed}"
);
assert!(
displayed.starts_with('"'),
"name with specials should be quoted, got: {displayed}"
);
assert!(
displayed.contains("<obrien@example.com>"),
"email must appear in angle brackets, got: {displayed}"
);
}
#[test]
fn unescape_trailing_backslash() {
let result = unescape_quoted_string("hello\\");
assert_eq!(
result, "hello\\",
"trailing backslash with no following char should be preserved"
);
}
}