use bytes::BytesMut;
use crate::deliver_by::validate_deliver_by_value;
use crate::error::Error;
use crate::future_release::{validate_hold_for_seconds, validate_hold_until_datetime};
use crate::types::{
BodyType, DeliverByMode, DomainOrLiteral, DsnNotify, DsnRet, ForwardPath, MailFromParams,
RcptToParams, ReversePath, SmtpAuthParam,
};
const SMTP_MAX_COMMAND_LINE: usize = 512;
const SMTP_MAX_RCPT_TO_DSN_LINE: usize = 1012;
const SMTP_MAX_MAIL_FROM_LINE: usize = 512 + 26 + 17 + 10 + 11 + 109 + 34 + 17 + 16 + 500;
fn validate_no_crlf(value: &str, context: &str) -> Result<(), Error> {
if value.bytes().any(|b| b == b'\r' || b == b'\n') {
return Err(Error::Protocol(format!(
"{context} must not contain CR or LF (RFC 5321 Section 4.1.2)"
)));
}
Ok(())
}
fn validate_printable_ascii(value: &str, context: &str) -> Result<(), Error> {
for &b in value.as_bytes() {
if b < 0x20 || b == 0x7F {
return Err(Error::Protocol(format!(
"{context} contains control character (byte 0x{b:02X}); \
only printable US-ASCII is permitted \
(RFC 5321 Section 4.1.2)"
)));
}
if b > 0x7F {
return Err(Error::Protocol(format!(
"{context} contains non-ASCII characters; \
only printable US-ASCII is permitted \
(RFC 5321 Section 4.1.2)"
)));
}
}
Ok(())
}
fn validate_utf8_query_string(value: &str, context: &str) -> Result<(), Error> {
for &b in value.as_bytes() {
if b < 0x20 || b == 0x7F {
return Err(Error::Protocol(format!(
"{context} contains control character (byte 0x{b:02X}); \
SMTPUTF8 does not permit ASCII control characters in VRFY/EXPN arguments \
(RFC 5321 Section 4.1.2 / RFC 6531 Section 3.7.4.2)"
)));
}
}
Ok(())
}
fn validate_command_line_length(line_len: usize, cmd_name: &str) -> Result<(), Error> {
if line_len > SMTP_MAX_COMMAND_LINE {
return Err(Error::Protocol(format!(
"{cmd_name} command line exceeds 512-octet limit \
(RFC 5321 Section 4.5.3.1.4): {line_len} octets"
)));
}
Ok(())
}
fn validate_mail_from_line_length(line_len: usize) -> Result<(), Error> {
if line_len > SMTP_MAX_MAIL_FROM_LINE {
return Err(Error::Protocol(format!(
"MAIL FROM command line exceeds {SMTP_MAX_MAIL_FROM_LINE}-octet limit \
(RFC 5321 Section 4.5.3.1.4 plus registered extension allowances): \
{line_len} octets"
)));
}
Ok(())
}
fn validate_rcpt_to_line_length(line_len: usize, has_dsn_params: bool) -> Result<(), Error> {
let limit = if has_dsn_params {
SMTP_MAX_RCPT_TO_DSN_LINE
} else {
SMTP_MAX_COMMAND_LINE
};
if line_len > limit {
let rfc = if has_dsn_params {
"RFC 3461 Section 5"
} else {
"RFC 5321 Section 4.5.3.1.4"
};
return Err(Error::Protocol(format!(
"RCPT TO command line exceeds {limit}-octet limit ({rfc}): {line_len} octets"
)));
}
Ok(())
}
fn encode_greeting(buf: &mut BytesMut, cmd: &[u8], domain: &DomainOrLiteral) -> Result<(), Error> {
let domain_str = domain.as_str();
let total = cmd.len() + domain_str.len() + 2; validate_command_line_length(total, "EHLO/HELO")?;
buf.extend_from_slice(cmd);
buf.extend_from_slice(domain_str.as_bytes());
buf.extend_from_slice(b"\r\n");
Ok(())
}
pub(crate) fn encode_ehlo(buf: &mut BytesMut, domain: &DomainOrLiteral) -> Result<(), Error> {
encode_greeting(buf, b"EHLO ", domain)
}
#[cfg(test)]
pub(crate) fn encode_auth_plain(buf: &mut BytesMut, user: &str, pass: &str) {
use base64::Engine;
let mut credentials = Vec::with_capacity(1 + user.len() + 1 + pass.len());
credentials.push(0);
credentials.extend_from_slice(user.as_bytes());
credentials.push(0);
credentials.extend_from_slice(pass.as_bytes());
let encoded = base64::engine::general_purpose::STANDARD.encode(&credentials);
buf.extend_from_slice(b"AUTH PLAIN ");
buf.extend_from_slice(encoded.as_bytes());
buf.extend_from_slice(b"\r\n");
}
#[cfg(test)]
pub(crate) fn encode_auth_xoauth2(buf: &mut BytesMut, user: &str, token: &str) {
use base64::Engine;
let sasl_string = format!("user={user}\x01auth=Bearer {token}\x01\x01");
let encoded = base64::engine::general_purpose::STANDARD.encode(sasl_string.as_bytes());
buf.extend_from_slice(b"AUTH XOAUTH2 ");
buf.extend_from_slice(encoded.as_bytes());
buf.extend_from_slice(b"\r\n");
}
#[cfg(test)]
pub(crate) fn encode_auth_oauthbearer(buf: &mut BytesMut, token: &str) {
use base64::Engine;
let sasl_string = format!("n,,\x01auth=Bearer {token}\x01\x01");
let encoded = base64::engine::general_purpose::STANDARD.encode(sasl_string.as_bytes());
buf.extend_from_slice(b"AUTH OAUTHBEARER ");
buf.extend_from_slice(encoded.as_bytes());
buf.extend_from_slice(b"\r\n");
}
#[cfg(test)]
pub(crate) fn encode_auth_login_initial(buf: &mut BytesMut) {
buf.extend_from_slice(b"AUTH LOGIN\r\n");
}
pub(crate) fn encode_mail_from(
buf: &mut BytesMut,
from: &ReversePath,
size: Option<u64>,
) -> Result<(), Error> {
let params = MailFromParams {
size,
..MailFromParams::default()
};
encode_mail_from_full(buf, from, ¶ms)
}
#[allow(clippy::unnecessary_wraps)]
pub(crate) fn encode_rcpt_to(buf: &mut BytesMut, to: &ForwardPath) -> Result<(), Error> {
buf.extend_from_slice(b"RCPT TO:<");
buf.extend_from_slice(to.as_str().as_bytes());
buf.extend_from_slice(b">\r\n");
Ok(())
}
pub(crate) fn encode_rcpt_to_full(
buf: &mut BytesMut,
to: &ForwardPath,
params: &RcptToParams,
) -> Result<(), Error> {
let to_str = to.as_str();
let mut line = BytesMut::new();
let mut has_dsn_params = false;
line.extend_from_slice(b"RCPT TO:<");
line.extend_from_slice(to_str.as_bytes());
line.extend_from_slice(b">");
if let Some(notify) = ¶ms.notify {
if !notify.is_empty() {
has_dsn_params = true;
line.extend_from_slice(b" NOTIFY=");
if notify.iter().any(|n| matches!(n, DsnNotify::Never)) {
if notify.len() > 1 {
return Err(Error::Protocol(
"NOTIFY=NEVER must not be combined with other values \
(RFC 3461 Section 4.1)"
.into(),
));
}
line.extend_from_slice(b"NEVER");
} else {
let mut first = true;
#[allow(clippy::needless_continue)]
for n in notify {
if !first {
line.extend_from_slice(b",");
}
first = false;
match n {
DsnNotify::Success => line.extend_from_slice(b"SUCCESS"),
DsnNotify::Failure => line.extend_from_slice(b"FAILURE"),
DsnNotify::Delay => line.extend_from_slice(b"DELAY"),
DsnNotify::Never => continue,
}
}
}
}
}
if let Some(orcpt) = ¶ms.orcpt {
if orcpt.is_empty() {
return Err(crate::Error::Protocol(
"ORCPT value must be non-empty (RFC 3461 Section 4.2: xtext = 1*xchar)".into(),
));
}
has_dsn_params = true;
if orcpt.is_ascii() {
line.extend_from_slice(b" ORCPT=rfc822;");
encode_xtext(&mut line, orcpt);
} else {
line.extend_from_slice(b" ORCPT=utf-8;");
encode_utf8_addr_xtext(&mut line, orcpt);
}
}
line.extend_from_slice(b"\r\n");
validate_rcpt_to_line_length(line.len(), has_dsn_params)?;
buf.extend_from_slice(&line);
Ok(())
}
pub(crate) fn encode_data(buf: &mut BytesMut) {
buf.extend_from_slice(b"DATA\r\n");
}
pub(crate) fn encode_data_end(buf: &mut BytesMut, preceding_data: &[u8]) {
if !preceding_data.ends_with(b"\r\n") {
buf.extend_from_slice(b"\r\n");
}
buf.extend_from_slice(b".\r\n");
}
pub(crate) fn encode_starttls(buf: &mut BytesMut) {
buf.extend_from_slice(b"STARTTLS\r\n");
}
pub(crate) fn encode_quit(buf: &mut BytesMut) {
buf.extend_from_slice(b"QUIT\r\n");
}
pub(crate) fn encode_rset(buf: &mut BytesMut) {
buf.extend_from_slice(b"RSET\r\n");
}
pub(crate) fn encode_noop(buf: &mut BytesMut) {
buf.extend_from_slice(b"NOOP\r\n");
}
pub(crate) fn encode_bdat(buf: &mut BytesMut, size: usize, last: bool) {
buf.extend_from_slice(b"BDAT ");
buf.extend_from_slice(size.to_string().as_bytes());
if last {
buf.extend_from_slice(b" LAST");
}
buf.extend_from_slice(b"\r\n");
}
pub(crate) fn encode_lhlo(buf: &mut BytesMut, domain: &DomainOrLiteral) -> Result<(), Error> {
encode_greeting(buf, b"LHLO ", domain)
}
pub(crate) fn encode_helo(buf: &mut BytesMut, domain: &DomainOrLiteral) -> Result<(), Error> {
encode_greeting(buf, b"HELO ", domain)
}
#[allow(clippy::too_many_lines)]
pub(crate) fn encode_mail_from_full(
buf: &mut BytesMut,
from: &ReversePath,
params: &MailFromParams,
) -> Result<(), Error> {
let from_str = from.as_str();
if from.requires_smtputf8() && !params.smtputf8 {
return Err(Error::Protocol(
"MAIL FROM address contains non-ASCII characters; RFC 6531 Sections 3.3 and 3.4 require the SMTPUTF8 parameter for internationalized reverse-paths".into(),
));
}
let mut line = BytesMut::new();
line.extend_from_slice(b"MAIL FROM:<");
line.extend_from_slice(from_str.as_bytes());
line.extend_from_slice(b">");
if let Some(size) = params.size {
line.extend_from_slice(b" SIZE=");
line.extend_from_slice(size.to_string().as_bytes());
}
let effective_body = match (params.smtputf8, params.body) {
(true, Some(BodyType::SevenBit)) => {
return Err(Error::Protocol(
"BODY=7BIT cannot be used with SMTPUTF8; \
RFC 6531 Section 3.6 requires BODY=8BITMIME or \
BODY=BINARYMIME when SMTPUTF8 is used"
.into(),
));
}
(true, Some(body)) => Some(body),
(true, None) => Some(BodyType::EightBitMime),
(false, body) => body,
};
if let Some(body) = effective_body {
match body {
BodyType::SevenBit => line.extend_from_slice(b" BODY=7BIT"),
BodyType::EightBitMime => line.extend_from_slice(b" BODY=8BITMIME"),
BodyType::BinaryMime => line.extend_from_slice(b" BODY=BINARYMIME"),
}
}
if params.smtputf8 {
line.extend_from_slice(b" SMTPUTF8");
}
if params.requiretls {
line.extend_from_slice(b" REQUIRETLS");
}
if let Some(ret) = ¶ms.ret {
match ret {
DsnRet::Full => line.extend_from_slice(b" RET=FULL"),
DsnRet::Hdrs => line.extend_from_slice(b" RET=HDRS"),
}
}
if let Some(envid) = ¶ms.envid {
line.extend_from_slice(b" ENVID=");
encode_xtext(&mut line, envid.as_str());
}
if params.hold_for.is_some() && params.hold_until.is_some() {
return Err(Error::Protocol(
"HOLDFOR and HOLDUNTIL are mutually exclusive; \
a client MUST NOT send both on the same MAIL FROM command \
(RFC 4865 Section 5)"
.into(),
));
}
if let Some(hold_for) = params.hold_for {
validate_hold_for_seconds(hold_for)?;
line.extend_from_slice(b" HOLDFOR=");
line.extend_from_slice(hold_for.to_string().as_bytes());
}
if let Some(hold_until) = ¶ms.hold_until {
validate_no_crlf(hold_until, "HOLDUNTIL datetime")?;
validate_hold_until_datetime(hold_until)?;
line.extend_from_slice(b" HOLDUNTIL=");
line.extend_from_slice(hold_until.as_bytes());
}
if let Some(deliver_by) = ¶ms.deliver_by {
validate_deliver_by_value(deliver_by)?;
line.extend_from_slice(b" BY=");
line.extend_from_slice(deliver_by.seconds.to_string().as_bytes());
match deliver_by.mode {
DeliverByMode::Notify => line.extend_from_slice(b";N"),
DeliverByMode::Return => line.extend_from_slice(b";R"),
}
if deliver_by.trace {
line.extend_from_slice(b"T");
}
}
if let Some(mt_priority) = params.mt_priority {
if !(-9..=9).contains(&mt_priority) {
return Err(Error::Protocol(format!(
"MT-PRIORITY value {mt_priority} out of range -9..9 (RFC 6758 Section 4)"
)));
}
line.extend_from_slice(b" MT-PRIORITY=");
line.extend_from_slice(mt_priority.to_string().as_bytes());
}
if let Some(ref auth) = params.auth {
match auth {
SmtpAuthParam::Mailbox(mailbox) => {
if !mailbox.as_str().is_ascii() {
return Err(Error::Protocol(
"MAIL FROM AUTH mailbox contains non-ASCII characters; \
only printable US-ASCII is permitted \
(RFC 4954 Section 5 / RFC 5321 Section 4.1.2)"
.into(),
));
}
line.extend_from_slice(b" AUTH=");
encode_xtext(&mut line, mailbox.as_str());
}
SmtpAuthParam::Empty => {
line.extend_from_slice(b" AUTH=<>");
}
}
}
line.extend_from_slice(b"\r\n");
validate_mail_from_line_length(line.len())?;
buf.extend_from_slice(&line);
Ok(())
}
fn encode_xtext(buf: &mut BytesMut, s: &str) {
for &b in s.as_bytes() {
if b == b'+' || b == b'=' || b <= 0x20 || b > 0x7E {
buf.extend_from_slice(b"+");
buf.extend_from_slice(format!("{b:02X}").as_bytes());
} else {
buf.extend_from_slice(&[b]);
}
}
}
fn encode_utf8_addr_xtext(buf: &mut BytesMut, s: &str) {
for &b in s.as_bytes() {
if b >= 0x80 {
buf.extend_from_slice(&[b]);
} else if b == b'+' || b == b'=' || b == b'\\' || b <= 0x20 || b > 0x7E {
buf.extend_from_slice(b"+");
buf.extend_from_slice(format!("{b:02X}").as_bytes());
} else {
buf.extend_from_slice(&[b]);
}
}
}
fn encode_cmd_with_arg(
buf: &mut BytesMut,
cmd: &[u8],
arg: &str,
smtputf8: bool,
) -> Result<(), Error> {
if arg.is_empty() {
return Err(Error::Protocol(
"SMTP query argument must not be empty \
(RFC 5321 Sections 4.1.1.6-4.1.1.7)"
.into(),
));
}
validate_no_crlf(arg, "SMTP command argument")?;
if smtputf8 {
validate_utf8_query_string(arg, "SMTP command argument")?;
} else {
validate_printable_ascii(arg, "SMTP command argument")?;
}
let rendered_arg = render_smtp_string_argument(arg, smtputf8)?;
let total_len = cmd.len() + rendered_arg.len() + if smtputf8 { 9 } else { 0 } + 2; validate_command_line_length(total_len, "SMTP")?;
buf.extend_from_slice(cmd);
buf.extend_from_slice(&rendered_arg);
if smtputf8 {
buf.extend_from_slice(b" SMTPUTF8");
}
buf.extend_from_slice(b"\r\n");
Ok(())
}
fn render_smtp_string_argument(arg: &str, smtputf8: bool) -> Result<Vec<u8>, Error> {
let bytes = arg.as_bytes();
let already_quoted = bytes.len() >= 2 && bytes[0] == b'"' && bytes[bytes.len() - 1] == b'"';
if already_quoted {
validate_smtp_quoted_string(arg, smtputf8)?;
return Ok(bytes.to_vec());
}
if arg.chars().all(|ch| is_smtp_atom_char(ch, smtputf8)) {
return Ok(bytes.to_vec());
}
let mut rendered = Vec::with_capacity(bytes.len() + 2);
rendered.push(b'"');
for &byte in bytes {
if byte == b'\\' || byte == b'"' {
rendered.push(b'\\');
}
rendered.push(byte);
}
rendered.push(b'"');
Ok(rendered)
}
pub(crate) fn validate_smtp_quoted_string(value: &str, smtputf8: bool) -> Result<(), Error> {
let Some(inner) = value
.strip_prefix('"')
.and_then(|rest| rest.strip_suffix('"'))
else {
return Err(Error::Protocol(
"SMTP quoted-string must begin and end with DQUOTE \
(RFC 5321 Section 4.1.2)"
.into(),
));
};
let bytes = inner.as_bytes();
let mut i = 0;
while i < bytes.len() {
match bytes[i] {
b'\\' => {
if i + 1 >= bytes.len() {
return Err(Error::Protocol(
"SMTP quoted-string must not end with a bare backslash \
(RFC 5321 Section 4.1.2)"
.into(),
));
}
let next = bytes[i + 1];
if !(0x20..=0x7E).contains(&next) {
return Err(Error::Protocol(
"SMTP quoted-string contains an invalid escaped byte; \
quoted-pairSMTP permits only %d32-126 after '\\' \
(RFC 5321 Section 4.1.2 / RFC 6531 Section 3.3)"
.into(),
));
}
i += 2;
}
b'"' => {
return Err(Error::Protocol(
"SMTP quoted-string contains an unescaped DQUOTE \
(RFC 5321 Section 4.1.2)"
.into(),
));
}
b if b.is_ascii() => i += 1,
_ if smtputf8 => {
let ch_len = inner[i..].chars().next().map_or(1, char::len_utf8);
i += ch_len;
}
_ => {
return Err(Error::Protocol(
"SMTP quoted-string contains non-ASCII data without SMTPUTF8 \
(RFC 5321 Section 4.1.2 / RFC 6531 Section 3.7.4.2)"
.into(),
));
}
}
}
Ok(())
}
fn is_smtp_atom_char(ch: char, smtputf8: bool) -> bool {
match ch {
'A'..='Z'
| 'a'..='z'
| '0'..='9'
| '!'
| '#'
| '$'
| '%'
| '&'
| '\''
| '*'
| '+'
| '-'
| '/'
| '='
| '?'
| '^'
| '_'
| '`'
| '{'
| '|'
| '}'
| '~' => true,
_ if smtputf8 && !ch.is_ascii() => true,
_ => false,
}
}
pub(crate) fn encode_vrfy(buf: &mut BytesMut, address: &str) -> Result<(), Error> {
encode_cmd_with_arg(buf, b"VRFY ", address, false)
}
pub(crate) fn encode_vrfy_smtputf8(buf: &mut BytesMut, address: &str) -> Result<(), Error> {
encode_cmd_with_arg(buf, b"VRFY ", address, true)
}
pub(crate) fn encode_expn(buf: &mut BytesMut, list_name: &str) -> Result<(), Error> {
encode_cmd_with_arg(buf, b"EXPN ", list_name, false)
}
pub(crate) fn encode_expn_smtputf8(buf: &mut BytesMut, list_name: &str) -> Result<(), Error> {
encode_cmd_with_arg(buf, b"EXPN ", list_name, true)
}
pub(crate) fn encode_help(buf: &mut BytesMut) {
buf.extend_from_slice(b"HELP\r\n");
}
pub(crate) fn encode_help_with_arg(buf: &mut BytesMut, topic: &str) -> Result<(), Error> {
encode_cmd_with_arg(buf, b"HELP ", topic, false)
}
pub(crate) fn dot_stuff(data: &[u8]) -> Vec<u8> {
let mut result = Vec::with_capacity(data.len().saturating_add(data.len() / 50));
let mut at_line_start = true;
let mut prev_cr = false;
for &byte in data {
if at_line_start && byte == b'.' {
result.push(b'.');
}
result.push(byte);
at_line_start = byte == b'\n' && prev_cr;
prev_cr = byte == b'\r';
}
result
}
pub(crate) fn dot_stuff_and_terminate(data: &[u8]) -> Vec<u8> {
let mut result = dot_stuff(data);
if !result.ends_with(b"\r\n") {
result.extend_from_slice(b"\r\n");
}
result.extend_from_slice(b".\r\n");
result
}
#[cfg(test)]
pub(crate) fn dot_stuff_size(data: &[u8]) -> usize {
let mut size = data.len();
let mut at_line_start = true;
let mut prev_cr = false;
for &byte in data {
if at_line_start && byte == b'.' {
size += 1;
}
at_line_start = byte == b'\n' && prev_cr;
prev_cr = byte == b'\r';
}
size
}
#[cfg(test)]
#[path = "encode_tests.rs"]
mod tests;