use crate::config::{SmtpConfig, TlsMode};
use crate::email::Email;
use crate::error::SmtpError;
use crate::response::SmtpResponse;
use crate::transport::SmtpTransport;
use base64::prelude::*;
use log::{debug, warn};
const RESPONSE_BUF_SIZE: usize = 512;
#[derive(Debug, Default)]
pub struct EhloCapabilities {
pub auth_methods: Vec<String>,
pub starttls: bool,
pub max_size: usize,
}
impl EhloCapabilities {
#[must_use]
pub fn parse(response: &SmtpResponse) -> Self {
let mut caps = Self::default();
for line in response.message.split('\n') {
let line = line.trim();
let upper = line.to_uppercase();
if let Some(auth_str) = upper.strip_prefix("AUTH ") {
caps.auth_methods = auth_str
.split_whitespace()
.map(ToString::to_string)
.collect();
} else if upper == "STARTTLS" {
caps.starttls = true;
} else if let Some(size_str) = upper.strip_prefix("SIZE ") {
caps.max_size = size_str.trim().parse().unwrap_or(0);
}
}
caps
}
}
fn read_response<T: SmtpTransport>(transport: &mut T) -> Result<SmtpResponse, SmtpError> {
let mut buf = [0u8; RESPONSE_BUF_SIZE];
let mut data = Vec::new();
loop {
let n = transport
.read(&mut buf)
.map_err(|e| SmtpError::Io(std::io::Error::other(e)))?;
if n == 0 {
return Err(SmtpError::Io(std::io::Error::new(
std::io::ErrorKind::UnexpectedEof,
"connection closed",
)));
}
data.extend_from_slice(&buf[..n]);
if let Some(resp) = SmtpResponse::parse(&data) {
debug!("S: {resp}");
return Ok(resp);
}
}
}
fn send_command<T: SmtpTransport>(
transport: &mut T,
command: &str,
) -> Result<SmtpResponse, SmtpError> {
let line = format!("{command}\r\n");
debug!("C: {command}");
transport
.write_all(line.as_bytes())
.map_err(|e| SmtpError::Io(std::io::Error::other(e)))?;
read_response(transport)
}
fn expect_code(response: &SmtpResponse, expected: u16) -> Result<(), SmtpError> {
if response.code == expected {
Ok(())
} else {
Err(SmtpError::Protocol {
expected,
got: response.clone(),
})
}
}
pub fn send_email<T: SmtpTransport>(
transport: &mut T,
config: &SmtpConfig,
email: &Email,
) -> Result<(), SmtpError> {
let result = send_email_inner(transport, config, email);
let _ = send_command(transport, "QUIT");
result
}
#[allow(clippy::similar_names)] fn send_email_inner<T: SmtpTransport>(
transport: &mut T,
config: &SmtpConfig,
email: &Email,
) -> Result<(), SmtpError> {
let greeting = read_response(transport)?;
if greeting.code != 220 {
return Err(SmtpError::Protocol {
expected: 220,
got: greeting,
});
}
let ehlo_resp = send_command(transport, "EHLO localhost")?;
expect_code(&ehlo_resp, 250)?;
let mut caps = EhloCapabilities::parse(&ehlo_resp);
if config.tls_mode == TlsMode::StartTls {
if !caps.starttls {
return Err(SmtpError::Tls {
message: "server does not advertise STARTTLS".into(),
});
}
let starttls_resp = send_command(transport, "STARTTLS")?;
expect_code(&starttls_resp, 220)?;
transport
.upgrade_tls(&config.host, &config.tls_verify)
.map_err(|e| SmtpError::Tls {
message: e.to_string(),
})?;
let post_tls_ehlo = send_command(transport, "EHLO localhost")?;
expect_code(&post_tls_ehlo, 250)?;
caps = EhloCapabilities::parse(&post_tls_ehlo);
}
if let Some(ref creds) = config.credentials {
authenticate(transport, creds, &caps)?;
}
let mail_from = format!("MAIL FROM:<{}>", email.from.to_envelope());
let mail_resp = send_command(transport, &mail_from)?;
if !mail_resp.is_success() {
return Err(SmtpError::RejectedSender {
address: email.from.email.clone(),
code: mail_resp.code,
message: mail_resp.message,
});
}
for recipient in email.all_recipients() {
let rcpt_to = format!("RCPT TO:<{}>", recipient.to_envelope());
let rcpt_resp = send_command(transport, &rcpt_to)?;
if rcpt_resp.code != 250 && rcpt_resp.code != 251 {
return Err(SmtpError::RejectedRecipient {
address: recipient.email.clone(),
code: rcpt_resp.code,
message: rcpt_resp.message,
});
}
}
let data_resp = send_command(transport, "DATA")?;
if data_resp.code != 354 {
return Err(SmtpError::DataRejected {
code: data_resp.code,
message: data_resp.message,
});
}
let headers = email.headers();
transport
.write_all(headers.as_bytes())
.map_err(|e| SmtpError::Io(std::io::Error::other(e)))?;
transport
.write_all(b"\r\n")
.map_err(|e| SmtpError::Io(std::io::Error::other(e)))?;
let body = email.formatted_body();
transport
.write_all(body.as_bytes())
.map_err(|e| SmtpError::Io(std::io::Error::other(e)))?;
transport
.write_all(b".\r\n")
.map_err(|e| SmtpError::Io(std::io::Error::other(e)))?;
let msg_resp = read_response(transport)?;
if !msg_resp.is_success() {
return Err(SmtpError::DataRejected {
code: msg_resp.code,
message: msg_resp.message,
});
}
Ok(())
}
fn authenticate<T: SmtpTransport>(
transport: &mut T,
creds: &crate::config::Credentials,
caps: &EhloCapabilities,
) -> Result<(), SmtpError> {
if caps.auth_methods.contains(&"PLAIN".to_string()) {
auth_plain(transport, creds)
} else if caps.auth_methods.contains(&"LOGIN".to_string()) {
auth_login(transport, creds)
} else if caps.auth_methods.is_empty() {
warn!("no AUTH methods advertised, trying PLAIN");
auth_plain(transport, creds)
} else {
Err(SmtpError::NoAuthMethod {
available: caps.auth_methods.clone(),
})
}
}
fn auth_plain<T: SmtpTransport>(
transport: &mut T,
creds: &crate::config::Credentials,
) -> Result<(), SmtpError> {
let mut token = Vec::new();
token.push(0u8); token.extend_from_slice(creds.username.as_bytes());
token.push(0u8);
token.extend_from_slice(creds.password.as_bytes());
let encoded = BASE64_STANDARD.encode(&token);
let command = format!("AUTH PLAIN {encoded}");
let resp = send_command(transport, &command)?;
if resp.code != 235 {
return Err(SmtpError::Auth {
code: resp.code,
message: resp.message,
});
}
Ok(())
}
fn auth_login<T: SmtpTransport>(
transport: &mut T,
creds: &crate::config::Credentials,
) -> Result<(), SmtpError> {
let resp = send_command(transport, "AUTH LOGIN")?;
if resp.code != 334 {
return Err(SmtpError::Auth {
code: resp.code,
message: resp.message,
});
}
let user_b64 = BASE64_STANDARD.encode(creds.username.as_bytes());
let resp = send_command(transport, &user_b64)?;
if resp.code != 334 {
return Err(SmtpError::Auth {
code: resp.code,
message: resp.message,
});
}
let pass_b64 = BASE64_STANDARD.encode(creds.password.as_bytes());
let resp = send_command(transport, &pass_b64)?;
if resp.code != 235 {
return Err(SmtpError::Auth {
code: resp.code,
message: resp.message,
});
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_ehlo_capabilities_parse_auth() {
let resp = SmtpResponse {
code: 250,
message: "smtp.example.com\nSIZE 35882577\nAUTH LOGIN PLAIN\nSTARTTLS".into(),
};
let caps = EhloCapabilities::parse(&resp);
assert_eq!(caps.auth_methods, vec!["LOGIN", "PLAIN"]);
assert!(caps.starttls);
assert_eq!(caps.max_size, 35_882_577);
}
#[test]
fn test_ehlo_capabilities_parse_no_auth() {
let resp = SmtpResponse {
code: 250,
message: "smtp.example.com\nSIZE 1000".into(),
};
let caps = EhloCapabilities::parse(&resp);
assert!(caps.auth_methods.is_empty());
assert!(!caps.starttls);
}
}