#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[error("{0}")]
pub struct ValidationError(String);
impl ValidationError {
pub fn new(message: impl Into<String>) -> Self {
Self(message.into())
}
pub fn message(&self) -> &str {
&self.0
}
}
#[non_exhaustive]
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))]
pub struct HeaderName(String);
impl HeaderName {
pub fn new(s: impl Into<String>) -> crate::Result<Self> {
let s = s.into();
validate_ftext(&s)?;
Ok(Self(s))
}
#[cfg(test)]
pub(crate) fn new_unchecked(s: impl Into<String>) -> Self {
Self(s.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn into_inner(self) -> String {
self.0
}
}
impl AsRef<str> for HeaderName {
fn as_ref(&self) -> &str {
&self.0
}
}
impl PartialEq for HeaderName {
fn eq(&self, other: &Self) -> bool {
self.0.eq_ignore_ascii_case(&other.0)
}
}
impl Eq for HeaderName {}
impl std::hash::Hash for HeaderName {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
for byte in self.0.as_bytes() {
byte.to_ascii_lowercase().hash(state);
}
}
}
impl PartialOrd for HeaderName {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Ord for HeaderName {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
let a = self.0.as_bytes().iter().map(u8::to_ascii_lowercase);
let b = other.0.as_bytes().iter().map(u8::to_ascii_lowercase);
a.cmp(b)
}
}
impl std::fmt::Display for HeaderName {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl TryFrom<String> for HeaderName {
type Error = crate::error::Error;
fn try_from(s: String) -> Result<Self, Self::Error> {
Self::new(s)
}
}
impl TryFrom<&str> for HeaderName {
type Error = crate::error::Error;
fn try_from(s: &str) -> Result<Self, Self::Error> {
Self::new(s)
}
}
impl From<HeaderName> for String {
fn from(h: HeaderName) -> Self {
h.into_inner()
}
}
fn validate_ftext(s: &str) -> crate::Result<()> {
if s.is_empty() {
return Err(crate::error::Error::InvalidHeaderName(
"header name must not be empty".into(),
));
}
if let Some(bad) = s.chars().find(|&c| {
let b = c as u32;
!(33..=126).contains(&b) || c == ':'
}) {
return Err(crate::error::Error::InvalidHeaderName(format!(
"header name contains invalid character {bad:?} (RFC 5322 Section 2.2)"
)));
}
Ok(())
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))]
pub struct MessageId(String);
impl MessageId {
pub fn new(s: impl Into<String>) -> crate::Result<Self> {
let s = s.into();
validate_message_id(&s)?;
Ok(Self(s))
}
#[cfg(test)]
pub(crate) fn new_unchecked(s: impl Into<String>) -> Self {
Self(s.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn into_inner(self) -> String {
self.0
}
}
impl AsRef<str> for MessageId {
fn as_ref(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for MessageId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl TryFrom<String> for MessageId {
type Error = crate::error::Error;
fn try_from(s: String) -> Result<Self, Self::Error> {
Self::new(s)
}
}
impl TryFrom<&str> for MessageId {
type Error = crate::error::Error;
fn try_from(s: &str) -> Result<Self, Self::Error> {
Self::new(s)
}
}
impl From<MessageId> for String {
fn from(m: MessageId) -> Self {
m.into_inner()
}
}
fn validate_message_id(s: &str) -> crate::Result<()> {
if s.is_empty() {
return Err(crate::error::Error::InvalidMessageId(
"message-ID must not be empty".into(),
));
}
let Some((id_left, id_right)) = split_strict_message_id_parts(s) else {
return Err(crate::error::Error::InvalidMessageId(
"message-ID must contain exactly one '@' \
(RFC 5322 Section 3.6.4)"
.into(),
));
};
if id_left.is_empty() {
return Err(crate::error::Error::InvalidMessageId(
"message-ID id-left (before '@') must not be empty \
(RFC 5322 Section 3.6.4)"
.into(),
));
}
if id_right.is_empty() {
return Err(crate::error::Error::InvalidMessageId(
"message-ID id-right (after '@') must not be empty \
(RFC 5322 Section 3.6.4)"
.into(),
));
}
if !is_message_id_dot_atom_text(id_left) {
return Err(crate::error::Error::InvalidMessageId(
"message-ID id-left must be dot-atom-text \
(RFC 5322 Section 3.6.4)"
.into(),
));
}
if !(is_message_id_dot_atom_text(id_right) || is_message_id_no_fold_literal(id_right)) {
return Err(crate::error::Error::InvalidMessageId(
"message-ID id-right must be dot-atom-text or no-fold-literal \
(RFC 5322 Sections 3.6.4 and 3.2.3)"
.into(),
));
}
Ok(())
}
pub(crate) fn is_valid_bare_message_id_body(s: &str) -> bool {
split_liberal_message_id_parts(s).is_some_and(|(id_left, id_right)| {
!id_left.is_empty()
&& !id_right.is_empty()
&& (is_message_id_dot_atom_text(id_left) || is_message_id_no_fold_quote(id_left))
&& (is_message_id_dot_atom_text(id_right) || is_message_id_no_fold_literal(id_right))
})
}
pub(crate) fn is_strict_bare_message_id_body(s: &str) -> bool {
split_strict_message_id_parts(s).is_some_and(|(id_left, id_right)| {
!id_left.is_empty()
&& !id_right.is_empty()
&& is_message_id_dot_atom_text(id_left)
&& (is_message_id_dot_atom_text(id_right) || is_message_id_no_fold_literal(id_right))
})
}
fn split_strict_message_id_parts(s: &str) -> Option<(&str, &str)> {
let (id_left, id_right) = s.split_once('@')?;
if id_right.contains('@') {
return None;
}
Some((id_left, id_right))
}
fn split_liberal_message_id_parts(s: &str) -> Option<(&str, &str)> {
let bytes = s.as_bytes();
let mut in_quotes = false;
let mut escaped = false;
let mut at_pos = None;
for (index, &byte) in bytes.iter().enumerate() {
if in_quotes {
if escaped {
escaped = false;
continue;
}
match byte {
b'\\' => escaped = true,
b'"' => in_quotes = false,
_ => {}
}
continue;
}
match byte {
b'"' => in_quotes = true,
b'@' => {
if at_pos.is_some() {
return None;
}
at_pos = Some(index);
}
_ => {}
}
}
if in_quotes || escaped {
return None;
}
at_pos.map(|index| (&s[..index], &s[index + 1..]))
}
fn is_message_id_dot_atom_text(s: &str) -> bool {
if s.is_empty() || s.starts_with('.') || s.ends_with('.') || s.contains("..") {
return false;
}
s.bytes()
.all(|byte| is_message_id_atext(byte) || byte == b'.' || byte >= 0x80)
}
fn is_message_id_no_fold_quote(s: &str) -> bool {
let Some(inner) = s
.strip_prefix('"')
.and_then(|value| value.strip_suffix('"'))
else {
return false;
};
let bytes = inner.as_bytes();
let mut index = 0;
while index < bytes.len() {
let byte = bytes[index];
if byte == b'\\' {
index += 1;
if index >= bytes.len() {
return false;
}
let escaped = bytes[index];
if !(escaped == b'\t' || (0x20..=0x7E).contains(&escaped)) {
return false;
}
} else if !(matches!(byte, 33 | 35..=91 | 93..=126) || byte >= 0x80) {
return false;
}
index += 1;
}
true
}
fn is_message_id_no_fold_literal(s: &str) -> bool {
let Some(inner) = s
.strip_prefix('[')
.and_then(|value| value.strip_suffix(']'))
else {
return false;
};
inner
.bytes()
.all(|byte| matches!(byte, 33..=90 | 94..=126) || byte >= 0x80)
}
fn is_message_id_atext(byte: u8) -> bool {
byte.is_ascii_alphanumeric()
|| matches!(
byte,
b'!' | b'#'
| b'$'
| b'%'
| b'&'
| b'\''
| b'*'
| b'+'
| b'-'
| b'/'
| b'='
| b'?'
| b'^'
| b'_'
| b'`'
| b'{'
| b'|'
| b'}'
| b'~'
)
}
#[non_exhaustive]
#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct ParsedEmail {
pub message_id: Option<String>,
pub in_reply_to: Vec<String>,
pub references: Vec<String>,
pub subject: Option<String>,
pub from: Vec<Address>,
pub sender: Option<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 extra_headers: Vec<(String, String)>,
pub size: u64,
}
impl ParsedEmail {
pub fn validated_extra_headers(&self) -> Vec<(HeaderName, String)> {
self.extra_headers
.iter()
.filter_map(|(name, value)| {
HeaderName::new(name.clone())
.ok()
.map(|hn| (hn, value.clone()))
})
.collect()
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Address {
pub name: Option<String>,
pub email: String,
}
impl Address {
pub fn new(email: impl Into<String>) -> crate::Result<Self> {
let addr = Self {
name: None,
email: email.into(),
};
crate::builder::validate_address(&addr)?;
Ok(addr)
}
pub fn with_name(name: impl Into<String>, email: impl Into<String>) -> crate::Result<Self> {
let addr = Self {
name: Some(name.into()),
email: email.into(),
};
crate::builder::validate_address(&addr)?;
Ok(addr)
}
pub fn new_unchecked(name: Option<String>, email: String) -> Self {
Self { name, email }
}
}
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.trim().is_empty() => {
if !name.is_ascii() || name.bytes().any(|b| (b < 0x20 && b != b'\t') || b == 0x7F) {
let encoded = crate::builder::encode_rfc2047_if_needed(name);
write!(f, "{encoded} <{}>", self.email)
} else if name.contains("=?") {
let escaped = name.replace('\\', "\\\\").replace('"', "\\\"");
write!(f, "\"{escaped}\" <{}>", 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;
#[allow(clippy::too_many_lines)]
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 trailing = &s[angle_end + 1..];
validate_trailing_cfws(
trailing,
s,
"angle-addr",
"RFC 5322 Section 3.4 / Section 3.2.2",
)?;
let mut email = s[angle_start + 1..angle_end].trim().to_string();
if email.starts_with('@') {
if let Some(colon) = email.find(':') {
email = email[colon + 1..].trim().to_string();
}
}
let name_part = s[..angle_start].trim();
let name = crate::parser::normalize_display_name_phrase(name_part);
if email.is_empty() {
return Err(crate::error::Error::InvalidAddress(
"empty email in angle brackets".into(),
));
}
let addr = Self { name, email };
crate::builder::validate_address(&addr)?;
return Ok(addr);
}
}
}
if s.find('@').is_some() {
let (addr_part, comment_name) = if let Some(paren_start) =
crate::parser::find_paren_outside_quotes(s)
{
let before_paren = s[..paren_start].trim();
let comment_and_rest = &s[paren_start..];
let after_paren = &s[paren_start + 1..];
let name = strip_comment_delimiters(after_paren);
let comment_name = if name.is_empty() { None } else { Some(name) };
if !before_paren.is_empty() && before_paren.contains('@') {
validate_trailing_cfws(
comment_and_rest,
s,
"addr-spec comment",
"RFC 5322 Section 3.4.1 / Section 3.2.2",
)?;
(before_paren, comment_name)
} else {
let close_paren = find_matching_close_paren(&s[paren_start..]);
let after_comment = s.get(paren_start + close_paren + 1..).unwrap_or("").trim();
if !after_comment.is_empty() && after_comment.contains('@') {
(after_comment, comment_name)
} else {
(before_paren, comment_name)
}
}
} else {
(s, None)
};
let at_in_addr = addr_part.find('@').unwrap_or(addr_part.len());
let local = &addr_part[..at_in_addr];
let domain = addr_part.get(at_in_addr + 1..).unwrap_or("");
if local.is_empty() || domain.is_empty() {
return Err(crate::error::Error::InvalidAddress(format!(
"invalid bare email (empty local-part or domain): {s} \
(RFC 5322 Section 3.4.1)"
)));
}
let addr = Self {
name: comment_name,
email: addr_part.to_string(),
};
crate::builder::validate_address(&addr)?;
return Ok(addr);
}
Err(crate::error::Error::InvalidAddress(format!(
"cannot parse address: {s}"
)))
}
}
fn find_matching_close_paren(s: &str) -> usize {
let bytes = s.as_bytes();
if bytes.is_empty() || bytes[0] != b'(' {
return 0;
}
let mut depth: u32 = 0;
let mut i = 0;
while i < bytes.len() {
match bytes[i] {
b'\\' => {
i += 2;
continue;
}
b'(' => {
depth = depth.saturating_add(1);
}
b')' => {
depth = depth.saturating_sub(1);
if depth == 0 {
return i;
}
}
_ => {}
}
i += 1;
}
s.len().saturating_sub(1)
}
fn strip_comment_delimiters(input: &str) -> String {
let bytes = input.as_bytes();
let mut depth: u32 = 1;
let mut end = input.len();
let mut i = 0;
while i < bytes.len() {
match bytes[i] {
b'\\' => {
i += 2;
continue;
}
b'(' => {
depth = depth.saturating_add(1);
}
b')' => {
depth = depth.saturating_sub(1);
if depth == 0 {
end = i;
break;
}
}
_ => {}
}
i += 1;
}
unescape_quoted_string(input[..end].trim())
}
fn is_cfws_only(mut input: &str) -> bool {
loop {
input = input.trim_start();
if input.is_empty() {
return true;
}
if !input.starts_with('(') {
return false;
}
let close = find_matching_close_paren(input);
if input.as_bytes().get(close) != Some(&b')') {
return false;
}
input = &input[close + 1..];
}
}
fn validate_trailing_cfws(
trailing: &str,
original: &str,
context: &str,
rfc_sections: &str,
) -> Result<(), crate::error::Error> {
if trailing.is_empty() || is_cfws_only(trailing) {
return Ok(());
}
Err(crate::error::Error::InvalidAddress(format!(
"invalid trailing text after {context}: {original} ({rfc_sections})"
)))
}
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
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
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",
];
#[non_exhaustive]
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct DateTime {
pub(crate) year: u16,
pub(crate) month: u8,
pub(crate) day: u8,
pub(crate) hour: u8,
pub(crate) minute: u8,
pub(crate) second: u8,
pub(crate) tz_offset_minutes: i16,
}
impl DateTime {
pub fn new(
year: u16,
month: u8,
day: u8,
hour: u8,
minute: u8,
second: u8,
tz_offset_minutes: i16,
) -> Self {
Self {
year,
month,
day,
hour,
minute,
second,
tz_offset_minutes,
}
}
pub fn year(&self) -> u16 {
self.year
}
pub fn month(&self) -> u8 {
self.month.clamp(1, 12)
}
pub fn day(&self) -> u8 {
let month = self.month.clamp(1, 12);
let max_day = Self::days_in_month(month, self.year);
self.day.clamp(1, max_day)
}
pub fn hour(&self) -> u8 {
self.hour.clamp(0, 23)
}
pub fn minute(&self) -> u8 {
self.minute.clamp(0, 59)
}
pub fn second(&self) -> u8 {
self.second.clamp(0, 60)
}
pub fn tz_offset_minutes(&self) -> i16 {
self.tz_offset_minutes.clamp(-1439, 1439)
}
pub fn to_unix_timestamp(&self) -> i64 {
let (month, day, hour, minute, second) = self.clamped_fields();
let days = Self::civil_to_days(i32::from(self.year), u32::from(month), u32::from(day));
let second = if second >= 60 { 59 } else { second };
let secs =
days * 86400 + i64::from(hour) * 3600 + i64::from(minute) * 60 + i64::from(second);
let tz = i64::from(self.tz_offset_minutes.clamp(-1439, 1439));
secs - tz * 60
}
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
pub fn from_unix_timestamp(timestamp: i64, tz_offset_minutes: i16) -> Self {
let tz_offset_minutes = tz_offset_minutes.clamp(-1439, 1439);
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);
let year = year.clamp(0, 9999) as u16;
Self {
year,
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)
}
fn days_in_month(month: u8, year: u16) -> u8 {
match month {
2 => {
let y = u32::from(year);
if (y % 4 == 0 && y % 100 != 0) || (y % 400 == 0) {
29
} else {
28
}
}
4 | 6 | 9 | 11 => 30,
_ => 31,
}
}
fn clamped_fields(&self) -> (u8, u8, u8, u8, u8) {
let month = self.month.clamp(1, 12);
let max_day = Self::days_in_month(month, self.year);
let day = self.day.clamp(1, max_day);
(
month,
day,
self.hour.clamp(0, 23),
self.minute.clamp(0, 59),
self.second.clamp(0, 60),
)
}
#[allow(clippy::cast_sign_loss)]
pub fn weekday(&self) -> u8 {
let (month, day, _, _, _) = self.clamped_fields();
let days = Self::civil_to_days(i32::from(self.year), u32::from(month), u32::from(day));
(((days % 7) + 4 + 7) % 7) as u8
}
pub fn to_rfc5322_string(&self) -> String {
let (month, day, hour, minute, second) = self.clamped_fields();
let dow = self.weekday();
let dow_name = DOW_NAMES[dow as usize];
let month_name = MONTH_NAMES[(month - 1) as usize];
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}",
day, self.year, hour, minute, second,
)
}
pub fn to_iso8601_string(&self) -> String {
let (month, day, hour, minute, second) = self.clamped_fields();
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, month, day, hour, minute, second,
)
}
pub fn parse_rfc5322(input: &str) -> Option<Self> {
crate::parser::parse_rfc5322_date(input)
}
fn tz_parts(&self) -> (char, u16, u16) {
let clamped = self.tz_offset_minutes.clamp(-1439, 1439);
let sign = if clamped >= 0 { '+' } else { '-' };
let abs = clamped.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 (month, day, hour, minute, second) = self.clamped_fields();
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, month, day, hour, minute, 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}"))
})
}
}
#[non_exhaustive]
#[derive(Debug, Default, Clone, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct OutgoingEmail {
pub from: Vec<Address>,
pub sender: Option<Address>,
pub to: Vec<Address>,
pub cc: Vec<Address>,
pub bcc: Vec<Address>,
pub reply_to: Vec<Address>,
pub date: Option<DateTime>,
pub subject: String,
pub body_text: Option<String>,
pub body_html: Option<String>,
pub in_reply_to: Vec<String>,
pub references: Vec<String>,
pub attachments: Vec<OutgoingAttachment>,
pub extra_headers: Vec<(HeaderName, String)>,
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct OutgoingAttachment {
pub filename: String,
pub content_type: String,
pub data: Vec<u8>,
pub is_inline: bool,
pub content_id: Option<String>,
}
impl OutgoingAttachment {
pub fn new(
filename: impl Into<String>,
content_type: impl Into<String>,
data: impl Into<Vec<u8>>,
) -> Self {
Self {
filename: filename.into(),
content_type: content_type.into(),
data: data.into(),
is_inline: false,
content_id: None,
}
}
pub fn inline(
filename: impl Into<String>,
content_type: impl Into<String>,
data: impl Into<Vec<u8>>,
content_id: impl Into<String>,
) -> Self {
Self {
filename: filename.into(),
content_type: content_type.into(),
data: data.into(),
is_inline: true,
content_id: Some(content_id.into()),
}
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct BuiltMessage {
pub raw: Vec<u8>,
pub envelope_recipients: Vec<String>,
pub message_id: String,
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum TlsMode {
Implicit,
StartTls,
None,
}
#[cfg(test)]
#[path = "types_tests.rs"]
mod tests;