use crate::error::{InvalidInputError, ProtocolError};
pub const MAX_REPLY_LINE_LEN: usize = 998;
pub const MAX_REPLY_LINES: usize = 128;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReplyLine<'a> {
pub code: u16,
pub is_last: bool,
pub text: &'a [u8],
}
pub fn parse_reply_line(line: &[u8]) -> Result<ReplyLine<'_>, ProtocolError> {
if line.len() < 3 {
return Err(malformed(line));
}
let d0 = ascii_digit_value(line[0]).ok_or_else(|| malformed(line))?;
let d1 = ascii_digit_value(line[1]).ok_or_else(|| malformed(line))?;
let d2 = ascii_digit_value(line[2]).ok_or_else(|| malformed(line))?;
let code = u16::from(d0) * 100 + u16::from(d1) * 10 + u16::from(d2);
if line.len() == 3 {
return Ok(ReplyLine {
code,
is_last: true,
text: &[],
});
}
let (is_last, text) = match line[3] {
b' ' => (true, &line[4..]),
b'-' => (false, &line[4..]),
_ => return Err(malformed(line)),
};
Ok(ReplyLine {
code,
is_last,
text,
})
}
fn ascii_digit_value(b: u8) -> Option<u8> {
if b.is_ascii_digit() {
Some(b - b'0')
} else {
None
}
}
fn malformed(line: &[u8]) -> ProtocolError {
ProtocolError::Malformed(String::from_utf8_lossy(line).into_owned())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Reply {
pub code: u16,
pub lines: Vec<String>,
}
impl Reply {
pub fn class(&self) -> u8 {
u8::try_from(self.code / 100).unwrap_or(0)
}
pub fn joined_text(&self) -> String {
self.lines.join("\n")
}
pub fn iter_lines(&self) -> impl Iterator<Item = &str> {
self.lines.iter().map(String::as_str)
}
}
pub fn format_command(verb: &str) -> Vec<u8> {
let mut buf = Vec::with_capacity(verb.len() + 2);
buf.extend_from_slice(verb.as_bytes());
buf.extend_from_slice(b"\r\n");
buf
}
pub fn format_command_arg(verb: &str, arg: &str) -> Vec<u8> {
let mut buf = Vec::with_capacity(verb.len() + 1 + arg.len() + 2);
buf.extend_from_slice(verb.as_bytes());
buf.push(b' ');
buf.extend_from_slice(arg.as_bytes());
buf.extend_from_slice(b"\r\n");
buf
}
pub fn format_mail_from(addr: &str) -> Vec<u8> {
let mut buf = Vec::with_capacity(13 + addr.len() + 2);
buf.extend_from_slice(b"MAIL FROM:<");
buf.extend_from_slice(addr.as_bytes());
buf.extend_from_slice(b">\r\n");
buf
}
pub fn format_rcpt_to(addr: &str) -> Vec<u8> {
let mut buf = Vec::with_capacity(11 + addr.len() + 2);
buf.extend_from_slice(b"RCPT TO:<");
buf.extend_from_slice(addr.as_bytes());
buf.extend_from_slice(b">\r\n");
buf
}
pub fn dot_stuff_and_terminate(body: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(body.len() + 8);
let mut at_line_start = true;
let mut prev: u8 = 0;
for &b in body {
if at_line_start && b == b'.' {
out.push(b'.');
}
out.push(b);
at_line_start = prev == b'\r' && b == b'\n';
prev = b;
}
if !out.ends_with(b"\r\n") {
out.extend_from_slice(b"\r\n");
}
out.extend_from_slice(b".\r\n");
out
}
const BASE64_ALPHABET: &[u8; 64] =
b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
pub fn base64_encode(input: &[u8]) -> String {
let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
let chunks = input.chunks_exact(3);
let rem = chunks.remainder();
for chunk in chunks {
let n = (u32::from(chunk[0]) << 16) | (u32::from(chunk[1]) << 8) | u32::from(chunk[2]);
push_b64(&mut out, n, 4);
}
match rem.len() {
0 => {}
1 => {
let n = u32::from(rem[0]) << 16;
push_b64(&mut out, n, 2);
out.push_str("==");
}
2 => {
let n = (u32::from(rem[0]) << 16) | (u32::from(rem[1]) << 8);
push_b64(&mut out, n, 3);
out.push('=');
}
_ => unreachable!(),
}
out
}
fn push_b64(out: &mut String, n: u32, count: u8) {
for i in 0..count {
let shift = 18 - 6 * i;
let idx = ((n >> shift) & 0x3F) as usize;
out.push(char::from(BASE64_ALPHABET[idx]));
}
}
pub fn validate_address(addr: &str) -> Result<(), InvalidInputError> {
if addr.is_empty() {
return Err(InvalidInputError::new("mail address must not be empty"));
}
if !addr.is_ascii() {
return Err(InvalidInputError::new(
"mail address must be ASCII (SMTPUTF8 is not supported)",
));
}
for b in addr.bytes() {
match b {
b'\r' | b'\n' => {
return Err(InvalidInputError::new(
"mail address must not contain CR or LF",
));
}
0 => {
return Err(InvalidInputError::new(
"mail address must not contain a NUL byte",
));
}
b'<' | b'>' => {
return Err(InvalidInputError::new(
"mail address must not contain '<' or '>'",
));
}
b' ' | b'\t' => {
return Err(InvalidInputError::new(
"mail address must not contain whitespace",
));
}
_ => {}
}
}
Ok(())
}
pub fn validate_ehlo_domain(domain: &str) -> Result<(), InvalidInputError> {
if domain.is_empty() {
return Err(InvalidInputError::new("EHLO domain must not be empty"));
}
if !domain.is_ascii() {
return Err(InvalidInputError::new("EHLO domain must be ASCII"));
}
if domain.bytes().any(|b| !(0x21..=0x7E).contains(&b)) {
return Err(InvalidInputError::new(
"EHLO domain must contain only printable ASCII characters",
));
}
Ok(())
}
pub fn validate_login_username(user: &str) -> Result<(), InvalidInputError> {
if user.is_empty() {
return Err(InvalidInputError::new("AUTH username must not be empty"));
}
Ok(())
}
pub fn validate_login_password(pass: &str) -> Result<(), InvalidInputError> {
if pass.is_empty() {
return Err(InvalidInputError::new("AUTH password must not be empty"));
}
Ok(())
}
pub fn ehlo_advertises_auth<S: AsRef<str>>(capability_lines: &[S], mechanism: &str) -> bool {
for line in capability_lines {
let mut parts = line.as_ref().split_ascii_whitespace();
let Some(head) = parts.next() else { continue };
if !head.eq_ignore_ascii_case("AUTH") {
continue;
}
for mech in parts {
if mech.eq_ignore_ascii_case(mechanism) {
return true;
}
}
}
false
}
pub fn ehlo_advertises_starttls<S: AsRef<str>>(capability_lines: &[S]) -> bool {
for line in capability_lines {
if let Some(head) = line.as_ref().split_ascii_whitespace().next()
&& head.eq_ignore_ascii_case("STARTTLS")
{
return true;
}
}
false
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum AuthMechanism {
Plain,
Login,
}
impl AuthMechanism {
#[must_use]
pub const fn name(self) -> &'static str {
match self {
Self::Plain => "PLAIN",
Self::Login => "LOGIN",
}
}
}
impl core::fmt::Display for AuthMechanism {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.write_str(self.name())
}
}
pub fn select_auth_mechanism<S: AsRef<str>>(capability_lines: &[S]) -> Option<AuthMechanism> {
if ehlo_advertises_auth(capability_lines, "PLAIN") {
Some(AuthMechanism::Plain)
} else if ehlo_advertises_auth(capability_lines, "LOGIN") {
Some(AuthMechanism::Login)
} else {
None
}
}
#[must_use]
pub fn build_auth_plain_initial_response(user: &str, pass: &str) -> String {
let mut payload = Vec::with_capacity(2 + user.len() + pass.len());
payload.push(0u8); payload.extend_from_slice(user.as_bytes());
payload.push(0u8);
payload.extend_from_slice(pass.as_bytes());
base64_encode(&payload)
}
pub fn validate_plain_username(user: &str) -> Result<(), InvalidInputError> {
if user.is_empty() {
return Err(InvalidInputError::new("AUTH username must not be empty"));
}
if user.bytes().any(|b| b == 0) {
return Err(InvalidInputError::new(
"AUTH username must not contain a NUL byte",
));
}
Ok(())
}
pub fn validate_plain_password(pass: &str) -> Result<(), InvalidInputError> {
if pass.is_empty() {
return Err(InvalidInputError::new("AUTH password must not be empty"));
}
if pass.bytes().any(|b| b == 0) {
return Err(InvalidInputError::new(
"AUTH password must not contain a NUL byte",
));
}
Ok(())
}