use super::*;
pub(super) fn normalize_line_endings(input: &str) -> String {
let mut result = String::with_capacity(input.len());
let mut chars = input.chars().peekable();
while let Some(c) = chars.next() {
match c {
'\r' => {
result.push_str("\r\n");
if chars.peek() == Some(&'\n') {
chars.next();
}
}
'\n' => {
result.push_str("\r\n");
}
_ => {
result.push(c);
}
}
}
result
}
pub(super) fn sanitize_header_value(value: &str) -> String {
let mut result = String::with_capacity(value.len());
let mut last_output_wsp = false;
for c in value.chars() {
if c.is_ascii_control() && c != '\t' {
if !last_output_wsp {
result.push(' ');
last_output_wsp = true;
}
} else {
result.push(c);
last_output_wsp = c == ' ' || c == '\t';
}
}
result
}
pub(crate) fn encode_rfc2047_if_needed(text: &str) -> String {
let has_overlong_word = text
.split_whitespace()
.any(|w| w.len() > HARD_LINE_LIMIT - 2);
let has_control_chars = text.bytes().any(|b| (b < 0x20 && b != b'\t') || b == 0x7F);
if text.bytes().all(|b| b.is_ascii())
&& !has_control_chars
&& !text.contains("=?")
&& !has_overlong_word
{
return text.to_string();
}
let max_raw_bytes: usize = 42;
let bytes = text.as_bytes();
let mut words: Vec<String> = Vec::new();
let mut pos = 0;
while pos < bytes.len() {
let chunk_end = snap_utf8_chunk_end(bytes, pos, max_raw_bytes);
let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes[pos..chunk_end]);
words.push(format!("=?UTF-8?B?{b64}?="));
pos = chunk_end;
}
words.join(" ")
}
pub(crate) fn validate_address(addr: &Address) -> Result<(), Error> {
let email = &addr.email;
if email.is_empty() {
return Err(Error::InvalidAddress("empty email address".into()));
}
let at_pos = if email.starts_with('"') {
let bytes = email.as_bytes();
let mut i = 1;
while i < bytes.len() {
if bytes[i] == b'\\' {
i += 2;
} else if bytes[i] == b'"' {
break;
} else {
i += 1;
}
}
if i >= bytes.len() || bytes[i] != b'"' {
return Err(Error::InvalidAddress(format!(
"unterminated quoted local-part: {email}"
)));
}
let sep = i + 1;
if sep >= bytes.len() || bytes[sep] != b'@' {
return Err(Error::InvalidAddress(format!(
"missing '@' after quoted local-part: {email}"
)));
}
sep
} else {
email
.find('@')
.ok_or_else(|| Error::InvalidAddress(format!("missing '@' in email: {email}")))?
};
if email[at_pos + 1..].contains('@') {
return Err(Error::InvalidAddress(format!(
"multiple '@' characters in email: {email}"
)));
}
let local = &email[..at_pos];
let domain = &email[at_pos + 1..];
if local.is_empty() {
return Err(Error::InvalidAddress(format!("empty local part: {email}")));
}
if domain.is_empty() {
return Err(Error::InvalidAddress(format!("empty domain part: {email}")));
}
if domain.starts_with('.') || domain.ends_with('.') {
return Err(Error::InvalidAddress(format!(
"domain must not start or end with '.': {email}"
)));
}
if domain.contains("..") {
return Err(Error::InvalidAddress(format!(
"domain contains consecutive dots: {email}"
)));
}
let is_quoted_local = local.len() >= 2 && local.starts_with('"') && local.ends_with('"');
if is_quoted_local {
let inner = &local.as_bytes()[1..local.len() - 1];
validate_quoted_string_content(inner, email)?;
validate_domain(domain, email)?;
} else {
for &b in local.as_bytes() {
if b >= 0x80 {
continue;
}
if !is_atext(b) && b != b'.' {
return Err(Error::InvalidAddress(format!(
"unquoted local-part contains non-atext character '{}' \
(RFC 5322 Section 3.2.3): {email}",
char::from(b)
)));
}
}
validate_domain(domain, email)?;
if local.contains("..") || local.starts_with('.') || local.ends_with('.') {
return Err(Error::InvalidAddress(format!(
"invalid unquoted local-part per RFC 5321 Section 4.1.2 \
(Dot-string = Atom *(\".\", Atom)): {email}"
)));
}
}
Ok(())
}
fn validate_quoted_string_content(inner: &[u8], email: &str) -> Result<(), Error> {
let mut i = 0;
while i < inner.len() {
let b = inner[i];
if b == b'\\' {
if i + 1 >= inner.len() {
return Err(Error::InvalidAddress(format!(
"quoted local-part has trailing backslash \
(RFC 5322 Section 3.2.4): {email}"
)));
}
let next = inner[i + 1];
if !((0x21..=0x7E).contains(&next) || next == b' ' || next == b'\t') {
return Err(Error::InvalidAddress(format!(
"quoted local-part has invalid quoted-pair \
(RFC 5322 Section 3.2.4): {email}"
)));
}
i += 2;
} else if b == b'"' {
return Err(Error::InvalidAddress(format!(
"quoted local-part contains unescaped double-quote \
(RFC 5322 Section 3.2.4): {email}"
)));
} else if b == b' ' || b == b'\t' {
i += 1;
} else if (0x21..=0x7E).contains(&b) {
i += 1;
} else if b >= 0x80 {
i += 1;
} else {
return Err(Error::InvalidAddress(format!(
"quoted local-part contains invalid byte 0x{b:02X} \
(RFC 5322 Section 3.2.4 qtext): {email}"
)));
}
}
Ok(())
}
pub(super) fn is_valid_msg_id(bare: &str) -> bool {
is_strict_bare_message_id_body(bare)
}
fn is_ldh(b: u8) -> bool {
b.is_ascii_alphanumeric() || b == b'-'
}
fn validate_domain(domain: &str, email: &str) -> Result<(), Error> {
if domain.starts_with('[') && domain.ends_with(']') {
return validate_domain_literal(domain, email);
}
for label in domain.split('.') {
if label.is_empty() {
return Err(Error::InvalidAddress(format!(
"domain contains empty label (RFC 5321 Section 4.1.2): {email}"
)));
}
if label.is_ascii() && label.len() > 63 {
return Err(Error::InvalidAddress(format!(
"domain label exceeds 63-octet ASCII limit \
(RFC 1035 Section 2.3.4 / RFC 5321 Section 4.1.2): {email}"
)));
}
for &b in label.as_bytes() {
if !b.is_ascii() {
continue;
}
if !is_ldh(b) {
return Err(Error::InvalidAddress(format!(
"domain contains invalid character '{}' \
(RFC 5321 Section 4.1.2: sub-domain = Let-dig [Ldh-str]): {email}",
char::from(b)
)));
}
}
if label.starts_with('-') || label.ends_with('-') {
return Err(Error::InvalidAddress(format!(
"domain label must not start or end with a hyphen \
(RFC 5321 Section 4.1.2): {email}"
)));
}
}
Ok(())
}
fn validate_domain_literal(domain: &str, email: &str) -> Result<(), Error> {
let Some(body) = domain.strip_prefix('[').and_then(|s| s.strip_suffix(']')) else {
return Err(Error::InvalidAddress(format!(
"domain-literal must be enclosed in '[' and ']' \
(RFC 5322 Section 3.4.1): {email}"
)));
};
for ch in body.chars() {
if ch == '[' || ch == ']' || ch == '\\' || ch == '\r' || ch == '\n' {
return Err(Error::InvalidAddress(format!(
"domain-literal contains invalid character {ch:?} \
(RFC 5322 Section 3.4.1 dtext): {email}"
)));
}
if ch.is_ascii() {
let byte = ch as u8;
if byte.is_ascii_control() || byte == b' ' || byte == b'\t' {
return Err(Error::InvalidAddress(format!(
"domain-literal contains invalid ASCII byte 0x{byte:02X} \
(RFC 5322 Section 3.4.1 dtext): {email}"
)));
}
}
}
Ok(())
}
fn is_atext(b: u8) -> bool {
b.is_ascii_alphanumeric()
|| matches!(
b,
b'!' | b'#'
| b'$'
| b'%'
| b'&'
| b'\''
| b'*'
| b'+'
| b'-'
| b'/'
| b'='
| b'?'
| b'^'
| b'_'
| b'`'
| b'{'
| b'|'
| b'}'
| b'~'
)
}
pub(super) fn validate_reserved_header_name(name: &HeaderName) -> Result<(), Error> {
let lower = name.as_str().to_ascii_lowercase();
if RESERVED_HEADER_NAMES.contains(&lower.as_str()) {
return Err(Error::ReservedHeaderName(format!(
"header '{}' is a standard header managed by the builder \
(RFC 5322 Section 3.6); use the dedicated OutgoingEmail field instead",
name.as_str()
)));
}
Ok(())
}
pub(super) const RESERVED_HEADER_NAMES: &[&str] = &[
"from",
"sender",
"to",
"cc",
"bcc",
"reply-to",
"subject",
"date",
"message-id",
"mime-version",
"content-type",
"content-transfer-encoding",
"in-reply-to",
"references",
];
pub(super) const STRUCTURED_EXTRA_HEADERS: &[&str] = &[
"content-disposition",
"content-id",
"received",
"return-path",
"resent-date",
"resent-from",
"resent-sender",
"resent-to",
"resent-cc",
"resent-bcc",
"resent-reply-to",
"resent-message-id",
"dkim-signature",
"domainkey-signature",
"arc-seal",
"arc-message-signature",
"arc-authentication-results",
"authentication-results",
];
pub(super) fn is_structured_extra_header(name: &str) -> bool {
let lower = name.to_ascii_lowercase();
STRUCTURED_EXTRA_HEADERS.contains(&lower.as_str())
}
pub(super) fn is_trace_extra_header(name: &str) -> bool {
name.eq_ignore_ascii_case("return-path") || name.eq_ignore_ascii_case("received")
}
pub(super) fn validate_trace_headers(
return_path_headers: &[(String, String)],
received_headers: &[(String, String)],
) -> Result<(), Error> {
if return_path_headers.len() > 1 {
return Err(Error::InvalidTraceHeader(
"trace header block must contain at most one Return-Path \
(RFC 5322 Section 3.6.7)"
.into(),
));
}
if !return_path_headers.is_empty() && received_headers.is_empty() {
return Err(Error::InvalidTraceHeader(
"Return-Path must not appear without at least one Received header \
(RFC 5322 Section 3.6.7)"
.into(),
));
}
Ok(())
}
pub(super) fn validate_trace_header_value(name: &str, value: &str) -> Result<(), Error> {
if name.eq_ignore_ascii_case("return-path") {
let trimmed = value.trim();
let Some(inner) = trimmed.strip_prefix('<').and_then(|s| s.strip_suffix('>')) else {
return Err(Error::InvalidTraceHeader(
"Return-Path must be an angle-addr or null path \
(RFC 5322 Section 3.6.7 / RFC 5321 Section 4.4)"
.into(),
));
};
if inner.trim().is_empty() {
return Ok(());
}
if inner != inner.trim() {
return Err(Error::InvalidTraceHeader(
"Return-Path angle-addr must not contain internal surrounding whitespace \
(RFC 5322 Section 3.6.7)"
.into(),
));
}
validate_address(&Address {
name: None,
email: inner.to_owned(),
})
.map_err(|_| {
Error::InvalidTraceHeader(
"Return-Path must contain a syntactically valid addr-spec \
(RFC 5322 Section 3.6.7 / RFC 5321 Section 4.4)"
.into(),
)
})?;
} else if name.eq_ignore_ascii_case("received") {
let Some((_, date_time)) = value.rsplit_once(';') else {
return Err(Error::InvalidTraceHeader(
"Received must end with ';' and a date-time \
(RFC 5322 Section 3.6.7)"
.into(),
));
};
if parse_rfc5322_date(date_time.trim()).is_none() {
return Err(Error::InvalidTraceHeader(
"Received must end with a syntactically valid date-time \
(RFC 5322 Section 3.6.7)"
.into(),
));
}
}
Ok(())
}
pub(super) fn is_resent_extra_header(name: &str) -> bool {
matches!(
name.to_ascii_lowercase().as_str(),
"resent-date"
| "resent-from"
| "resent-sender"
| "resent-to"
| "resent-cc"
| "resent-bcc"
| "resent-reply-to"
| "resent-message-id"
)
}
pub(super) fn resent_field_kind(
name: &str,
resent_from_mailbox_count: usize,
) -> Result<ResentFieldKind, Error> {
match name.to_ascii_lowercase().as_str() {
"resent-date" => Ok(ResentFieldKind::Date),
"resent-from" => Ok(ResentFieldKind::From {
mailbox_count: resent_from_mailbox_count,
}),
"resent-sender" => Ok(ResentFieldKind::Sender),
"resent-to" => Ok(ResentFieldKind::To),
"resent-cc" => Ok(ResentFieldKind::Cc),
"resent-bcc" => Ok(ResentFieldKind::Bcc),
"resent-reply-to" => Ok(ResentFieldKind::ReplyTo),
"resent-message-id" => Ok(ResentFieldKind::MessageId),
_ => Err(Error::InvalidResentHeader(format!(
"unknown resent field: {name}"
))),
}
}
pub(super) fn validate_resent_header_value(
name: &str,
value: &str,
) -> Result<Option<usize>, Error> {
if name.eq_ignore_ascii_case("resent-date") {
if parse_rfc5322_date(value).is_none() {
return Err(Error::InvalidResentHeader(
"Resent-Date must use date-time syntax \
(RFC 5322 Sections 3.3 and 3.6.6)"
.into(),
));
}
return Ok(None);
}
if name.eq_ignore_ascii_case("resent-from") {
let mailbox_count = parse_address_list(value).len();
if mailbox_count == 0 {
return Err(Error::InvalidResentHeader(
"Resent-From must use mailbox-list syntax \
(RFC 5322 Sections 3.6.2 and 3.6.6)"
.into(),
));
}
return Ok(Some(mailbox_count));
}
if name.eq_ignore_ascii_case("resent-sender") {
if parse_address_list(value).len() != 1 {
return Err(Error::InvalidResentHeader(
"Resent-Sender must use mailbox syntax \
(RFC 5322 Sections 3.6.2 and 3.6.6)"
.into(),
));
}
return Ok(None);
}
if name.eq_ignore_ascii_case("resent-to") || name.eq_ignore_ascii_case("resent-cc") {
if parse_address_list(value).is_empty() {
return Err(Error::InvalidResentHeader(format!(
"{name} must use address-list syntax \
(RFC 5322 Sections 3.6.3 and 3.6.6)"
)));
}
return Ok(None);
}
if name.eq_ignore_ascii_case("resent-reply-to") {
if parse_address_list(value).is_empty() {
return Err(Error::InvalidResentHeader(
"Resent-Reply-To must use address-list syntax \
(RFC 5322 Sections 3.6.2 and 4.5.6)"
.into(),
));
}
return Ok(None);
}
if name.eq_ignore_ascii_case("resent-bcc") {
if strip_comments(value).trim().is_empty() {
return Ok(None);
}
if parse_address_list(value).is_empty() {
return Err(Error::InvalidResentHeader(
"Resent-Bcc must use address-list syntax or be empty/CFWS \
(RFC 5322 Sections 3.6.3 and 3.6.6)"
.into(),
));
}
return Ok(None);
}
if name.eq_ignore_ascii_case("resent-message-id") {
let trimmed = value.trim();
let bare = strip_angle_brackets(trimmed).trim();
if bare.is_empty()
|| trimmed.matches('<').count() > 1
|| trimmed.matches('>').count() > 1
|| trimmed.contains('<') != trimmed.contains('>')
|| !is_valid_msg_id(bare)
{
return Err(Error::InvalidResentHeader(
"Resent-Message-ID must use msg-id syntax \
(RFC 5322 Sections 3.6.4 and 3.6.6)"
.into(),
));
}
}
Ok(None)
}
pub(super) fn partition_resent_blocks(
headers: &[PendingResentHeader],
) -> Result<Vec<Vec<(String, String)>>, Error> {
let boundaries = partition_resent_blocks_from(headers, 0).map_err(|err| match err {
ResentBlockError::MissingRequiredFields => Error::InvalidResentHeader(
"resent header blocks must include both Resent-Date and Resent-From \
(RFC 5322 Section 3.6.6)"
.into(),
),
ResentBlockError::MissingSender => Error::InvalidResentHeader(
"multi-mailbox Resent-From requires Resent-Sender \
(RFC 5322 Section 3.6.6)"
.into(),
),
})?;
let mut blocks = Vec::with_capacity(boundaries.len());
let mut start = 0usize;
for end in boundaries {
blocks.push(
headers[start..end]
.iter()
.map(|header| (header.name.clone(), header.value.clone()))
.collect(),
);
start = end;
}
Ok(blocks)
}
pub(super) fn partition_resent_blocks_from(
headers: &[PendingResentHeader],
start: usize,
) -> Result<Vec<usize>, ResentBlockError> {
if start == headers.len() {
return Ok(Vec::new());
}
let mut seen_fields = [false; 8];
let mut has_date = false;
let mut has_from = false;
let mut has_sender = false;
let mut resent_from_mailbox_count = 0usize;
let mut failure = ResentBlockError::MissingRequiredFields;
let mut end = start;
while end < headers.len() {
let kind = headers[end].kind;
let slot = kind.slot_index();
if seen_fields[slot] {
break;
}
seen_fields[slot] = true;
match kind {
ResentFieldKind::Date => has_date = true,
ResentFieldKind::From { mailbox_count } => {
has_from = true;
resent_from_mailbox_count = mailbox_count;
}
ResentFieldKind::Sender => has_sender = true,
ResentFieldKind::To
| ResentFieldKind::Cc
| ResentFieldKind::Bcc
| ResentFieldKind::ReplyTo
| ResentFieldKind::MessageId => {}
}
if resent_from_mailbox_count > 1 && !has_sender {
failure = ResentBlockError::MissingSender;
}
end += 1;
}
if !(has_date && has_from && (resent_from_mailbox_count <= 1 || has_sender)) {
return Err(failure);
}
let mut rest = partition_resent_blocks_from(headers, end)?;
rest.insert(0, end);
Ok(rest)
}
pub(super) fn escape_quoted_string(s: &str) -> String {
s.replace('\\', "\\\\").replace('"', "\\\"")
}
pub(super) fn format_address(addr: &Address) -> String {
match &addr.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 = encode_rfc2047_if_needed(name);
format!("{encoded} <{}>", addr.email)
} else if name.contains("=?") {
let escaped = escape_quoted_string(name);
format!("\"{escaped}\" <{}>", addr.email)
} else if needs_quoting(name) {
let escaped = escape_quoted_string(name);
format!("\"{escaped}\" <{}>", addr.email)
} else {
format!("{name} <{}>", addr.email)
}
}
_ => addr.email.clone(),
}
}
fn needs_quoting(name: &str) -> bool {
name.chars().any(|c| {
matches!(
c,
'(' | ')' | '<' | '>' | '[' | ']' | ':' | ';' | '@' | '\\' | ',' | '.' | '"'
)
})
}
pub(super) fn format_address_list(addrs: &[Address]) -> String {
addrs
.iter()
.map(format_address)
.collect::<Vec<_>>()
.join(", ")
}
pub(super) fn strip_angle_brackets(s: &str) -> &str {
s.strip_prefix('<')
.and_then(|s| s.strip_suffix('>'))
.unwrap_or(s)
}
pub(super) fn extract_domain(email: &str) -> Option<&str> {
let bytes = email.as_bytes();
let mut i = 0;
if bytes.first() == Some(&b'"') {
i = 1;
while i < bytes.len() {
match bytes[i] {
b'\\' => i += 2, b'"' => {
i += 1;
break;
}
_ => i += 1,
}
}
}
let at = email[i..].find('@').map(|pos| pos + i)?;
let domain = &email[at + 1..];
if domain.is_empty() {
None
} else {
Some(domain)
}
}