use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader};
use crate::error::Error;
use crate::protocol::ftp::UseSsl;
#[derive(Debug, Clone, Default)]
pub struct SmtpConfig<'a> {
pub mail_from: Option<&'a str>,
pub mail_rcpt: &'a [String],
pub mail_auth: Option<&'a str>,
pub sasl_authzid: Option<&'a str>,
pub sasl_ir: bool,
pub custom_request: Option<&'a str>,
pub oauth2_bearer: Option<&'a str>,
pub crlf: bool,
pub username: Option<&'a str>,
pub password: Option<&'a str>,
pub login_options: Option<&'a str>,
}
#[derive(Debug, Clone)]
pub struct SmtpResponse {
pub code: u16,
pub message: String,
pub raw: String,
}
impl SmtpResponse {
#[must_use]
pub const fn is_ok(&self) -> bool {
self.code >= 200 && self.code < 300
}
#[must_use]
pub const fn is_intermediate(&self) -> bool {
self.code >= 300 && self.code < 400
}
}
#[derive(Debug, Default)]
struct EhloCapabilities {
size: bool,
starttls: bool,
smtputf8: bool,
auth_mechanisms: Vec<String>,
}
fn parse_ehlo_capabilities(message: &str) -> EhloCapabilities {
let mut caps = EhloCapabilities::default();
for line in message.lines() {
let line_upper = line.to_uppercase();
if line_upper.starts_with("SIZE") || line_upper == "SIZE" {
caps.size = true;
} else if line_upper == "STARTTLS" || line_upper.starts_with("STARTTLS ") {
caps.starttls = true;
} else if line_upper == "SMTPUTF8" || line_upper.starts_with("SMTPUTF8 ") {
caps.smtputf8 = true;
} else if let Some(mechs) = line_upper.strip_prefix("AUTH ") {
for mech in mechs.split_whitespace() {
caps.auth_mechanisms.push(mech.to_string());
}
} else if line_upper == "AUTH" {
}
}
caps
}
pub async fn read_response<S: AsyncRead + Unpin>(
stream: &mut BufReader<S>,
) -> Result<SmtpResponse, Error> {
let mut full_message = String::new();
let mut raw_response = String::new();
let mut final_code: Option<u16> = None;
loop {
let mut line = String::new();
let bytes_read = stream
.read_line(&mut line)
.await
.map_err(|e| Error::Http(format!("SMTP read error: {e}")))?;
if bytes_read == 0 {
return Err(Error::Http("SMTP connection closed unexpectedly".to_string()));
}
let line = line.trim_end_matches('\n').trim_end_matches('\r');
raw_response.push_str(line);
raw_response.push_str("\r\n");
if line.len() < 4 {
full_message.push_str(line);
full_message.push('\n');
continue;
}
let code_str = &line[..3];
let separator = line.as_bytes().get(3).copied();
if let Ok(code) = code_str.parse::<u16>() {
match separator {
Some(b' ') => {
let msg = &line[4..];
full_message.push_str(msg);
final_code = Some(code);
break;
}
Some(b'-') => {
let msg = &line[4..];
full_message.push_str(msg);
full_message.push('\n');
if final_code.is_none() {
final_code = Some(code);
}
}
_ => {
full_message.push_str(line);
full_message.push('\n');
}
}
} else {
full_message.push_str(line);
full_message.push('\n');
}
}
let code =
final_code.ok_or_else(|| Error::Http("SMTP response has no status code".to_string()))?;
Ok(SmtpResponse { code, message: full_message, raw: raw_response })
}
pub async fn send_command<S: AsyncWrite + Unpin>(
stream: &mut S,
command: &str,
) -> Result<(), Error> {
let cmd = format!("{command}\r\n");
stream
.write_all(cmd.as_bytes())
.await
.map_err(|e| Error::Http(format!("SMTP write error: {e}")))?;
stream.flush().await.map_err(|e| Error::Http(format!("SMTP flush error: {e}")))?;
Ok(())
}
#[derive(Debug)]
enum SmtpMode {
Send,
Vrfy,
Custom(String),
Help,
}
async fn smtp_greeting_and_ehlo<R: AsyncRead + Unpin, W: AsyncWrite + Unpin>(
reader: &mut BufReader<R>,
writer: &mut W,
ehlo_name: &str,
) -> Result<(bool, EhloCapabilities), Error> {
let greeting = read_response(reader).await?;
if !greeting.is_ok() {
return Err(Error::Http(format!(
"SMTP server rejected connection: {} {}",
greeting.code, greeting.message
)));
}
send_command(writer, &format!("EHLO {ehlo_name}")).await?;
let ehlo_resp = read_response(reader).await?;
if ehlo_resp.is_ok() {
Ok((true, parse_ehlo_capabilities(&ehlo_resp.message)))
} else {
send_command(writer, &format!("HELO {ehlo_name}")).await?;
let helo_resp = read_response(reader).await?;
if !helo_resp.is_ok() {
return Err(Error::Http(format!(
"SMTP HELO failed: {} {}",
helo_resp.code, helo_resp.message
)));
}
Ok((false, EhloCapabilities::default()))
}
}
#[allow(clippy::too_many_lines)]
pub async fn send_mail(
url: &crate::url::Url,
mail_data: &[u8],
config: &SmtpConfig<'_>,
use_ssl: UseSsl,
tls_config: &crate::tls::TlsConfig,
pre_connected: Option<tokio::net::TcpStream>,
) -> Result<crate::protocol::http::response::Response, Error> {
let (host, port) = url.host_and_port()?;
let url_path = url.path();
let ehlo_name = url_path.strip_prefix('/').unwrap_or(url_path).split('/').next().unwrap_or("");
let ehlo_name = if ehlo_name.is_empty() { host.as_str() } else { ehlo_name };
let decoded_path = url_decode(url_path);
if decoded_path.contains('\r') || decoded_path.contains('\n') {
return Err(Error::UrlParse("URL contains CR/LF characters".to_string()));
}
let credentials: Option<(String, String)> = config.username.map_or_else(
|| {
url.credentials().map(|(u, p)| {
let decoded_user = url_decode(u);
let clean_user = strip_auth_from_username(&decoded_user);
(clean_user, p.to_string())
})
},
|user| Some((user.to_string(), config.password.unwrap_or("").to_string())),
);
let mode = determine_smtp_mode(config, !mail_data.is_empty());
let use_implicit_tls = url.scheme() == "smtps";
let use_starttls = !use_implicit_tls && use_ssl != UseSsl::None;
let tcp = if let Some(stream) = pre_connected {
stream
} else {
let addr = format!("{host}:{port}");
tokio::net::TcpStream::connect(&addr).await.map_err(Error::Connect)?
};
#[allow(clippy::type_complexity)]
let (mut reader, mut writer, caps, ehlo_ok): (
BufReader<Box<dyn tokio::io::AsyncRead + Unpin + Send>>,
Box<dyn tokio::io::AsyncWrite + Unpin + Send>,
EhloCapabilities,
bool,
) = if use_implicit_tls {
let connector = crate::tls::TlsConnector::new(tls_config)?;
let (tls_stream, _alpn) = connector.connect(tcp, &host).await?;
let (r, w) = tokio::io::split(tls_stream);
let mut rd = BufReader::new(Box::new(r) as Box<dyn tokio::io::AsyncRead + Unpin + Send>);
let mut wr: Box<dyn tokio::io::AsyncWrite + Unpin + Send> = Box::new(w);
let (ehlo_ok, caps) = smtp_greeting_and_ehlo(&mut rd, &mut wr, ehlo_name).await?;
(rd, wr, caps, ehlo_ok)
} else {
let (r, w) = tokio::io::split(tcp);
let mut plain_reader = BufReader::new(r);
let mut plain_writer = w;
let (ehlo_ok, caps) =
smtp_greeting_and_ehlo(&mut plain_reader, &mut plain_writer, ehlo_name).await?;
if use_starttls && ehlo_ok && caps.starttls {
send_command(&mut plain_writer, "STARTTLS").await?;
let starttls_resp = read_response(&mut plain_reader).await?;
if !starttls_resp.is_ok() {
return Err(Error::Protocol(8));
}
let tcp_restored = plain_reader.into_inner().unsplit(plain_writer);
let connector = crate::tls::TlsConnector::new_no_alpn(tls_config)?;
let (tls_stream, _) = connector.connect(tcp_restored, &host).await?;
let (r, w) = tokio::io::split(tls_stream);
let mut rd =
BufReader::new(Box::new(r) as Box<dyn tokio::io::AsyncRead + Unpin + Send>);
let mut wr: Box<dyn tokio::io::AsyncWrite + Unpin + Send> = Box::new(w);
send_command(&mut wr, &format!("EHLO {ehlo_name}")).await?;
let ehlo2 = read_response(&mut rd).await?;
let (ehlo_ok2, caps2) = if ehlo2.is_ok() {
(true, parse_ehlo_capabilities(&ehlo2.message))
} else {
(false, EhloCapabilities::default())
};
(rd, wr, caps2, ehlo_ok2)
} else if use_starttls && use_ssl == UseSsl::All && (!ehlo_ok || !caps.starttls) {
let _ = send_command(&mut plain_writer, "QUIT").await;
return Err(Error::Transfer {
code: 64,
message: "SMTP STARTTLS required but not available".to_string(),
});
} else {
let rd =
BufReader::new(Box::new(plain_reader.into_inner())
as Box<dyn tokio::io::AsyncRead + Unpin + Send>);
let wr: Box<dyn tokio::io::AsyncWrite + Unpin + Send> = Box::new(plain_writer);
(rd, wr, caps, ehlo_ok)
}
};
if ehlo_ok && !caps.auth_mechanisms.is_empty() {
if let Some((ref user, ref pass)) = credentials {
do_auth(
&mut reader,
&mut writer,
user,
pass,
config.sasl_authzid,
config.sasl_ir,
&caps.auth_mechanisms,
config.login_options,
&host,
port,
config.oauth2_bearer,
)
.await?;
}
}
let idn_rcpts: Vec<String> = config
.mail_rcpt
.iter()
.map(|r| crate::idn::idn_email_address(r).unwrap_or_else(|_| r.clone()))
.collect();
let need_smtputf8 = config.mail_rcpt.iter().any(|r| crate::idn::has_non_ascii(r))
|| config.mail_from.is_some_and(crate::idn::has_non_ascii);
let mut response_body = Vec::new();
match mode {
SmtpMode::Send => {
do_send_mail(
&mut reader,
&mut writer,
mail_data,
config,
&caps,
&idn_rcpts,
need_smtputf8,
)
.await?;
}
SmtpMode::Vrfy => {
let utf8_suffix = if caps.smtputf8 { " SMTPUTF8" } else { "" };
for rcpt in &idn_rcpts {
send_command(&mut writer, &format!("VRFY {rcpt}{utf8_suffix}")).await?;
let resp = read_response(&mut reader).await?;
if !resp.is_ok() && resp.code != 553 {
let _ = send_command(&mut writer, "QUIT").await;
return Err(Error::Protocol(8));
}
response_body.extend_from_slice(resp.raw.as_bytes());
}
}
SmtpMode::Custom(cmd) => {
let utf8_suffix = if caps.smtputf8 { " SMTPUTF8" } else { "" };
if idn_rcpts.is_empty() {
send_command(&mut writer, &format!("{cmd}{utf8_suffix}")).await?;
} else {
for rcpt in &idn_rcpts {
send_command(&mut writer, &format!("{cmd} {rcpt}{utf8_suffix}")).await?;
}
}
let resp = read_response(&mut reader).await?;
response_body.extend_from_slice(resp.raw.as_bytes());
if !resp.is_ok() {
let _ = send_command(&mut writer, "QUIT").await;
return Err(Error::Protocol(8));
}
}
SmtpMode::Help => {
send_command(&mut writer, "HELP").await?;
let resp = read_response(&mut reader).await?;
response_body.extend_from_slice(resp.raw.as_bytes());
if !resp.is_ok() {
let _ = send_command(&mut writer, "QUIT").await;
return Err(Error::Protocol(8));
}
}
}
send_command(&mut writer, "QUIT").await?;
let headers = std::collections::HashMap::new();
Ok(crate::protocol::http::response::Response::new(
250,
headers,
response_body,
url.as_str().to_string(),
))
}
fn determine_smtp_mode(config: &SmtpConfig<'_>, has_data: bool) -> SmtpMode {
if let Some(custom) = config.custom_request {
return SmtpMode::Custom(custom.to_string());
}
if config.mail_from.is_some() || has_data {
return SmtpMode::Send;
}
if !config.mail_rcpt.is_empty() {
return SmtpMode::Vrfy;
}
SmtpMode::Help
}
#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
async fn do_auth<S: AsyncRead + Unpin, W: AsyncWrite + Unpin>(
reader: &mut BufReader<S>,
writer: &mut W,
user: &str,
pass: &str,
sasl_authzid: Option<&str>,
sasl_ir: bool,
server_mechs: &[String],
login_options: Option<&str>,
host: &str,
port: u16,
oauth2_bearer: Option<&str>,
) -> Result<(), Error> {
use base64::Engine;
let has = |mech: &str| server_mechs.iter().any(|m| m.eq_ignore_ascii_case(mech));
let forced =
login_options.and_then(|lo| lo.strip_prefix("AUTH=").or_else(|| lo.strip_prefix("auth=")));
let should_try =
|mech: &str| forced.map_or_else(|| has(mech), |f| f.eq_ignore_ascii_case(mech));
if should_try("EXTERNAL") {
let encoded = if user.is_empty() {
"=".to_string()
} else {
base64::engine::general_purpose::STANDARD.encode(user.as_bytes())
};
if sasl_ir {
send_command(writer, &format!("AUTH EXTERNAL {encoded}")).await?;
} else {
send_command(writer, "AUTH EXTERNAL").await?;
let resp = read_response(reader).await?;
if resp.code != 334 {
return Err(Error::SmtpAuth("AUTH EXTERNAL failed".to_string()));
}
send_command(writer, &encoded).await?;
}
let auth_resp = read_response(reader).await?;
if auth_resp.is_ok() {
return Ok(());
}
return Err(Error::SmtpAuth("AUTH EXTERNAL failed".to_string()));
}
if let Some(bearer) = oauth2_bearer {
if should_try("OAUTHBEARER") {
let payload = format!(
"n,a={user},\x01host={host}\x01port={port}\x01auth=Bearer {bearer}\x01\x01"
);
let encoded = base64::engine::general_purpose::STANDARD.encode(payload.as_bytes());
if sasl_ir {
send_command(writer, &format!("AUTH OAUTHBEARER {encoded}")).await?;
} else {
send_command(writer, "AUTH OAUTHBEARER").await?;
let resp = read_response(reader).await?;
if resp.code != 334 {
return Err(Error::SmtpAuth(format!(
"AUTH OAUTHBEARER expected 334, got: {}",
resp.code
)));
}
send_command(writer, &encoded).await?;
}
let auth_resp = read_response(reader).await?;
if auth_resp.is_ok() {
return Ok(());
}
if auth_resp.code == 334 {
send_command(writer, "AQ==").await?;
let _ = read_response(reader).await;
}
return Err(Error::Transfer {
code: 67,
message: format!(
"AUTH OAUTHBEARER failed: {} {}",
auth_resp.code, auth_resp.message
),
});
}
if should_try("XOAUTH2") {
let payload = format!("user={user}\x01auth=Bearer {bearer}\x01\x01");
let encoded = base64::engine::general_purpose::STANDARD.encode(payload.as_bytes());
if sasl_ir {
send_command(writer, &format!("AUTH XOAUTH2 {encoded}")).await?;
} else {
send_command(writer, "AUTH XOAUTH2").await?;
let resp = read_response(reader).await?;
if resp.code != 334 {
return Err(Error::SmtpAuth(format!(
"AUTH XOAUTH2 expected 334, got: {}",
resp.code
)));
}
send_command(writer, &encoded).await?;
}
let auth_resp = read_response(reader).await?;
if auth_resp.is_ok() {
return Ok(());
}
return Err(Error::SmtpAuth(format!(
"AUTH XOAUTH2 failed: {} {}",
auth_resp.code, auth_resp.message
)));
}
}
let mut cram_failed = false;
let mut ntlm_failed = false;
if should_try("CRAM-MD5") {
send_command(writer, "AUTH CRAM-MD5").await?;
let resp = read_response(reader).await?;
if resp.code != 334 {
return Err(Error::SmtpAuth(format!("AUTH CRAM-MD5 expected 334, got: {}", resp.code)));
}
let challenge_b64 = resp.message.trim();
if let Ok(challenge_bytes) = base64::engine::general_purpose::STANDARD.decode(challenge_b64)
{
let challenge = String::from_utf8_lossy(&challenge_bytes);
let response_str = crate::auth::cram_md5::cram_md5_response(user, pass, &challenge);
let encoded = base64::engine::general_purpose::STANDARD.encode(response_str.as_bytes());
send_command(writer, &encoded).await?;
let auth_resp = read_response(reader).await?;
if auth_resp.is_ok() {
return Ok(());
}
return Err(Error::SmtpAuth(format!(
"AUTH CRAM-MD5 failed: {} {}",
auth_resp.code, auth_resp.message
)));
}
send_command(writer, "*").await?;
let _ = read_response(reader).await;
cram_failed = true;
}
if !cram_failed && should_try("NTLM") || cram_failed && has("NTLM") {
let type1 = crate::auth::ntlm::create_type1_message();
if sasl_ir {
send_command(writer, &format!("AUTH NTLM {type1}")).await?;
} else {
send_command(writer, "AUTH NTLM").await?;
let resp = read_response(reader).await?;
if resp.code != 334 {
return Err(Error::SmtpAuth(format!("AUTH NTLM expected 334, got: {}", resp.code)));
}
send_command(writer, &type1).await?;
}
let resp2 = read_response(reader).await?;
if resp2.code == 334 {
let challenge_b64 = resp2.message.trim();
if let Ok(challenge) = crate::auth::ntlm::parse_type2_message(challenge_b64) {
let type3 = crate::auth::ntlm::create_type3_message(&challenge, user, pass, "")?;
send_command(writer, &type3).await?;
let auth_resp = read_response(reader).await?;
if auth_resp.is_ok() {
return Ok(());
}
return Err(Error::SmtpAuth(format!(
"AUTH NTLM failed: {} {}",
auth_resp.code, auth_resp.message
)));
}
}
send_command(writer, "*").await?;
let _ = read_response(reader).await;
ntlm_failed = true;
}
if should_try("LOGIN") && !cram_failed && !ntlm_failed {
if sasl_ir {
let encoded_user = base64::engine::general_purpose::STANDARD.encode(user.as_bytes());
send_command(writer, &format!("AUTH LOGIN {encoded_user}")).await?;
} else {
send_command(writer, "AUTH LOGIN").await?;
let resp = read_response(reader).await?;
if resp.code != 334 {
return Err(Error::SmtpAuth(format!(
"AUTH LOGIN expected 334, got: {} {}",
resp.code, resp.message
)));
}
let encoded_user = base64::engine::general_purpose::STANDARD.encode(user.as_bytes());
send_command(writer, &encoded_user).await?;
}
let resp = read_response(reader).await?;
if resp.code != 334 {
return Err(Error::SmtpAuth(format!(
"AUTH LOGIN expected 334 for password, got: {} {}",
resp.code, resp.message
)));
}
let encoded_pass = base64::engine::general_purpose::STANDARD.encode(pass.as_bytes());
send_command(writer, &encoded_pass).await?;
let auth_resp = read_response(reader).await?;
if auth_resp.is_ok() {
return Ok(());
}
return Err(Error::SmtpAuth(format!(
"AUTH LOGIN failed: {} {}",
auth_resp.code, auth_resp.message
)));
}
let try_plain = should_try("PLAIN") || (cram_failed || ntlm_failed) && has("PLAIN");
if try_plain {
let auth_string = sasl_authzid.map_or_else(
|| format!("\0{user}\0{pass}"),
|authzid| format!("{authzid}\0{user}\0{pass}"),
);
let encoded = base64::engine::general_purpose::STANDARD.encode(auth_string.as_bytes());
if sasl_ir {
send_command(writer, &format!("AUTH PLAIN {encoded}")).await?;
} else {
send_command(writer, "AUTH PLAIN").await?;
let resp = read_response(reader).await?;
if resp.code != 334 {
return Err(Error::SmtpAuth(format!(
"AUTH PLAIN expected 334, got: {} {}",
resp.code, resp.message
)));
}
send_command(writer, &encoded).await?;
}
let auth_resp = read_response(reader).await?;
if auth_resp.is_ok() {
return Ok(());
}
return Err(Error::SmtpAuth(format!(
"AUTH PLAIN failed: {} {}",
auth_resp.code, auth_resp.message
)));
}
if cram_failed || ntlm_failed {
return Err(Error::Transfer {
code: 67,
message: "SMTP authentication cancelled, no fallback available".to_string(),
});
}
Ok(())
}
async fn do_send_mail<S: AsyncRead + Unpin, W: AsyncWrite + Unpin>(
reader: &mut BufReader<S>,
writer: &mut W,
mail_data: &[u8],
config: &SmtpConfig<'_>,
caps: &EhloCapabilities,
idn_rcpts: &[String],
need_smtputf8: bool,
) -> Result<(), Error> {
let from_addr = config.mail_from.unwrap_or("");
let from_addr =
crate::idn::idn_email_address(from_addr).unwrap_or_else(|_| from_addr.to_string());
let mut mail_from_cmd = format!("MAIL FROM:<{from_addr}>");
if let Some(auth) = config.mail_auth {
use std::fmt::Write;
let _ = write!(mail_from_cmd, " AUTH=<{auth}>");
}
if caps.size {
use std::fmt::Write;
let _ = write!(mail_from_cmd, " SIZE={}", mail_data.len());
}
if caps.smtputf8 && need_smtputf8 {
mail_from_cmd.push_str(" SMTPUTF8");
}
send_command(writer, &mail_from_cmd).await?;
let mail_resp = read_response(reader).await?;
if !mail_resp.is_ok() {
let _ = send_command(writer, "QUIT").await;
return Err(Error::SmtpSend(format!(
"SMTP MAIL FROM failed: {} {}",
mail_resp.code, mail_resp.message
)));
}
for rcpt in idn_rcpts {
send_command(writer, &format!("RCPT TO:<{rcpt}>")).await?;
let rcpt_resp = read_response(reader).await?;
if !rcpt_resp.is_ok() {
let _ = send_command(writer, "QUIT").await;
return Err(Error::SmtpSend(format!(
"SMTP RCPT TO failed: {} {}",
rcpt_resp.code, rcpt_resp.message
)));
}
}
send_command(writer, "DATA").await?;
let data_resp = read_response(reader).await?;
if !data_resp.is_intermediate() {
return Err(Error::SmtpSend(format!(
"SMTP DATA failed: {} {}",
data_resp.code, data_resp.message
)));
}
if check_7bit_violation(mail_data) {
return Err(Error::Transfer {
code: 26,
message: "7-bit encoding applied to 8-bit data".to_string(),
});
}
write_smtp_data(writer, mail_data, config.crlf).await?;
send_command(writer, ".").await?;
let end_resp = read_response(reader).await?;
if !end_resp.is_ok() {
return Err(Error::SmtpSend(format!(
"SMTP message rejected: {} {}",
end_resp.code, end_resp.message
)));
}
Ok(())
}
fn check_7bit_violation(data: &[u8]) -> bool {
let text = String::from_utf8_lossy(data);
let mut in_7bit_part = false;
let mut past_headers = false;
for line in text.split('\n') {
let trimmed = line.trim_end_matches('\r');
if trimmed.starts_with("--") {
in_7bit_part = false;
past_headers = false;
continue;
}
if trimmed.is_empty() && !past_headers {
past_headers = true;
continue;
}
if !past_headers && trimmed.eq_ignore_ascii_case("Content-Transfer-Encoding: 7bit") {
in_7bit_part = true;
continue;
}
if in_7bit_part && past_headers && line.as_bytes().iter().any(|&b| b > 127) {
return true;
}
}
false
}
async fn write_smtp_data<W: AsyncWrite + Unpin>(
writer: &mut W,
data: &[u8],
crlf: bool,
) -> Result<(), Error> {
let mut buf = Vec::with_capacity(data.len() + data.len() / 50);
let mut at_line_start = true;
let mut last_was_crlf = true;
let mut i = 0;
while i < data.len() {
let b = data[i];
if at_line_start && b == b'.' {
buf.push(b'.');
buf.push(b'.');
at_line_start = false;
last_was_crlf = false;
i += 1;
continue;
}
if b == b'\r' && data.get(i + 1) == Some(&b'\n') {
buf.push(b'\r');
buf.push(b'\n');
at_line_start = true;
last_was_crlf = true;
i += 2;
continue;
}
if b == b'\n' {
if crlf {
buf.push(b'\r');
buf.push(b'\n');
last_was_crlf = true;
} else {
buf.push(b'\n');
last_was_crlf = false;
}
at_line_start = true;
i += 1;
continue;
}
buf.push(b);
at_line_start = false;
last_was_crlf = false;
i += 1;
}
if !last_was_crlf {
buf.push(b'\r');
buf.push(b'\n');
}
writer.write_all(&buf).await.map_err(|e| Error::Http(format!("SMTP data write error: {e}")))?;
writer.flush().await.map_err(|e| Error::Http(format!("SMTP flush error: {e}")))?;
Ok(())
}
fn strip_auth_from_username(username: &str) -> String {
username
.find(";AUTH=")
.or_else(|| username.find(";auth="))
.or_else(|| username.to_uppercase().find(";AUTH="))
.map_or_else(|| username.to_string(), |pos| username[..pos].to_string())
}
fn url_decode(s: &str) -> String {
let mut result = Vec::new();
let bytes = s.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
if let (Some(hi), Some(lo)) = (hex_digit(bytes[i + 1]), hex_digit(bytes[i + 2])) {
result.push(hi * 16 + lo);
i += 3;
continue;
}
}
result.push(bytes[i]);
i += 1;
}
String::from_utf8_lossy(&result).to_string()
}
const fn hex_digit(b: u8) -> Option<u8> {
match b {
b'0'..=b'9' => Some(b - b'0'),
b'a'..=b'f' => Some(b - b'a' + 10),
b'A'..=b'F' => Some(b - b'A' + 10),
_ => None,
}
}
#[cfg(test)]
fn extract_mail_addresses(mail: &str) -> (Option<String>, Option<String>) {
let mut from = None;
let mut to = None;
for line in mail.lines() {
if line.is_empty() {
break; }
if let Some(addr) = line.strip_prefix("From:").or_else(|| line.strip_prefix("from:")) {
from = Some(extract_address(addr.trim()));
} else if let Some(addr) = line.strip_prefix("To:").or_else(|| line.strip_prefix("to:")) {
to = Some(extract_address(addr.trim()));
}
}
(from, to)
}
#[cfg(test)]
fn extract_address(value: &str) -> String {
if let Some(start) = value.find('<') {
if let Some(end) = value.find('>') {
return value[start + 1..end].to_string();
}
}
value.to_string()
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[tokio::test]
async fn read_simple_response() {
let data = b"220 mail.example.com ESMTP\r\n";
let mut reader = BufReader::new(std::io::Cursor::new(data.to_vec()));
let resp = read_response(&mut reader).await.unwrap();
assert_eq!(resp.code, 220);
assert_eq!(resp.message, "mail.example.com ESMTP");
}
#[tokio::test]
async fn read_multiline_response() {
let data = b"250-mail.example.com\r\n250-SIZE 10240000\r\n250 AUTH PLAIN LOGIN\r\n";
let mut reader = BufReader::new(std::io::Cursor::new(data.to_vec()));
let resp = read_response(&mut reader).await.unwrap();
assert_eq!(resp.code, 250);
assert!(resp.message.contains("mail.example.com"));
assert!(resp.message.contains("AUTH PLAIN LOGIN"));
}
#[tokio::test]
async fn read_response_connection_closed() {
let data = b"";
let mut reader = BufReader::new(std::io::Cursor::new(data.to_vec()));
let result = read_response(&mut reader).await;
assert!(result.is_err());
}
#[test]
fn smtp_response_status_ok() {
let resp = SmtpResponse { code: 250, message: String::new(), raw: String::new() };
assert!(resp.is_ok());
assert!(!resp.is_intermediate());
}
#[test]
fn smtp_response_status_intermediate() {
let resp = SmtpResponse { code: 354, message: String::new(), raw: String::new() };
assert!(resp.is_intermediate());
assert!(!resp.is_ok());
}
#[test]
fn extract_address_bare() {
assert_eq!(extract_address("user@example.com"), "user@example.com");
}
#[test]
fn extract_address_angle_brackets() {
assert_eq!(extract_address("<user@example.com>"), "user@example.com");
}
#[test]
fn extract_address_display_name() {
assert_eq!(extract_address("\"John Doe\" <john@example.com>"), "john@example.com");
}
#[test]
fn extract_from_to_headers() {
let mail = "From: sender@example.com\r\nTo: receiver@example.com\r\n\r\nBody";
let (from, to) = extract_mail_addresses(mail);
assert_eq!(from.unwrap(), "sender@example.com");
assert_eq!(to.unwrap(), "receiver@example.com");
}
#[test]
fn extract_from_to_with_angle_brackets() {
let mail = "From: <sender@example.com>\r\nTo: \"Bob\" <bob@example.com>\r\n\r\n";
let (from, to) = extract_mail_addresses(mail);
assert_eq!(from.unwrap(), "sender@example.com");
assert_eq!(to.unwrap(), "bob@example.com");
}
#[test]
fn extract_no_from() {
let mail = "To: receiver@example.com\r\n\r\nBody";
let (from, _to) = extract_mail_addresses(mail);
assert!(from.is_none());
}
#[test]
fn parse_ehlo_caps_with_size_and_auth() {
let msg = "smtp.example.com\nSIZE 10240000\nAUTH PLAIN LOGIN";
let caps = parse_ehlo_capabilities(msg);
assert!(caps.size);
assert_eq!(caps.auth_mechanisms, vec!["PLAIN", "LOGIN"]);
}
#[test]
fn parse_ehlo_caps_no_size() {
let msg = "smtp.example.com\nAUTH PLAIN";
let caps = parse_ehlo_capabilities(msg);
assert!(!caps.size);
assert_eq!(caps.auth_mechanisms, vec!["PLAIN"]);
}
#[test]
fn parse_ehlo_caps_size_only() {
let msg = "smtp.example.com\nSIZE";
let caps = parse_ehlo_capabilities(msg);
assert!(caps.size);
assert!(caps.auth_mechanisms.is_empty());
}
#[test]
fn url_decode_basic() {
assert_eq!(url_decode("/hello"), "/hello");
assert_eq!(url_decode("/%0d%0a"), "/\r\n");
assert_eq!(url_decode("/foo%20bar"), "/foo bar");
}
#[test]
fn determine_mode_send() {
let config = SmtpConfig {
mail_from: Some("sender@example.com"),
mail_rcpt: &[],
..SmtpConfig::default()
};
assert!(matches!(determine_smtp_mode(&config, false), SmtpMode::Send));
}
#[test]
fn determine_mode_send_with_data() {
let config = SmtpConfig::default();
assert!(matches!(determine_smtp_mode(&config, true), SmtpMode::Send));
}
#[test]
fn determine_mode_vrfy() {
let rcpts = vec!["recipient".to_string()];
let config = SmtpConfig { mail_rcpt: &rcpts, ..SmtpConfig::default() };
assert!(matches!(determine_smtp_mode(&config, false), SmtpMode::Vrfy));
}
#[test]
fn determine_mode_help() {
let config = SmtpConfig::default();
assert!(matches!(determine_smtp_mode(&config, false), SmtpMode::Help));
}
#[test]
fn determine_mode_custom() {
let config = SmtpConfig { custom_request: Some("EXPN"), ..SmtpConfig::default() };
assert!(matches!(determine_smtp_mode(&config, false), SmtpMode::Custom(_)));
}
}