use crate::error::{AuthError, InvalidInputError, ProtocolError, SmtpError, SmtpOp};
use crate::protocol::{self, MAX_REPLY_LINE_LEN, format_command};
use crate::session::SessionState;
use crate::tracing_helpers::{smtp_debug, smtp_trace, smtp_warn};
use crate::transport::Transport;
mod auth;
mod io;
mod send;
mod starttls;
pub(super) const READ_CHUNK: usize = 1024;
pub(super) const RX_BUF_COMPACT_THRESHOLD: usize = 4096;
pub(super) const RX_BUF_HARD_LIMIT: usize = MAX_REPLY_LINE_LEN * 2;
pub struct SmtpClient<T: Transport> {
transport: T,
state: SessionState,
rx_buf: Vec<u8>,
rx_pos: usize,
capabilities: Vec<String>,
ehlo_domain: String,
enhanced_status_enabled: bool,
policy: Box<dyn crate::policy::SendPolicy>,
audit: Box<dyn crate::audit::AuditSink>,
}
impl<T: Transport> core::fmt::Debug for SmtpClient<T> {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.debug_struct("SmtpClient")
.field("state", &self.state)
.field("capabilities", &self.capabilities)
.field("ehlo_domain", &self.ehlo_domain)
.field("enhanced_status_enabled", &self.enhanced_status_enabled)
.field("rx_buf_len", &self.rx_buf.len())
.field("rx_pos", &self.rx_pos)
.finish_non_exhaustive()
}
}
impl<T: Transport> SmtpClient<T> {
pub async fn connect(transport: T, ehlo_domain: &str) -> Result<Self, SmtpError> {
Self::connect_with(transport, ehlo_domain, SmtpClientOptions::default()).await
}
pub async fn connect_with(
transport: T,
ehlo_domain: &str,
options: SmtpClientOptions,
) -> Result<Self, SmtpError> {
protocol::validate_ehlo_domain(ehlo_domain)?;
smtp_debug!(ehlo_domain = %ehlo_domain, "SMTP session: connect");
options.audit.on_event(&crate::audit::SmtpAuditEvent::Connected);
let mut client = Self {
transport,
state: SessionState::Greeting,
rx_buf: Vec::with_capacity(READ_CHUNK),
rx_pos: 0,
capabilities: Vec::new(),
ehlo_domain: ehlo_domain.to_owned(),
enhanced_status_enabled: false,
policy: options.policy,
audit: options.audit,
};
client.read_greeting().await?;
client.send_ehlo(ehlo_domain).await?;
smtp_debug!(
capability_count = client.capabilities.len(),
"SMTP session: ready"
);
Ok(client)
}
pub fn capabilities(&self) -> &[String] {
&self.capabilities
}
pub fn state(&self) -> SessionState {
self.state
}
pub async fn quit(mut self) -> Result<(), SmtpError> {
if self.state == SessionState::Closed {
smtp_trace!("quit: already closed; nothing to do");
return Ok(());
}
smtp_debug!("QUIT: closing session");
let send_result: Result<(), SmtpError> = async {
self.transition(SessionState::Quit)?;
self.write_all(&format_command("QUIT")).await?;
self.expect_code(221, SmtpOp::Quit).await?;
Ok(())
}
.await;
let close_result = self.transport.close().await;
self.state = SessionState::Closed;
if send_result.is_ok() && close_result.is_ok() {
self.audit.on_event(&crate::audit::SmtpAuditEvent::QuitCompleted);
} else {
self.audit.on_event(&crate::audit::SmtpAuditEvent::SessionAborted);
}
send_result?;
close_result.map_err(SmtpError::from)?;
Ok(())
}
fn assert_state_in(&self, allowed: &[SessionState]) -> Result<(), InvalidInputError> {
if allowed.contains(&self.state) {
Ok(())
} else if self.state == SessionState::Closed {
Err(InvalidInputError::new(
"operation not allowed: SMTP session is already closed",
))
} else {
Err(InvalidInputError::new(
"operation not allowed in the current SMTP session state",
))
}
}
fn transition(&mut self, next: SessionState) -> Result<(), InvalidInputError> {
if self.state.can_transition_to(next) {
self.state = next;
Ok(())
} else {
Err(InvalidInputError::new(
"internal session-state transition rejected",
))
}
}
fn mark_closed_on_logical_failure(&mut self) {
if self.state != SessionState::Closed {
smtp_warn!(
state = ?self.state,
"session closed on logical failure; further calls will fail fast"
);
}
self.state = SessionState::Closed;
}
}
pub struct SmtpClientOptions {
pub(crate) policy: Box<dyn crate::policy::SendPolicy>,
pub(crate) audit: Box<dyn crate::audit::AuditSink>,
}
impl SmtpClientOptions {
#[must_use]
pub fn new() -> Self {
Self {
policy: Box::new(crate::policy::DefaultPolicy),
audit: Box::new(crate::audit::NoopAuditSink),
}
}
#[must_use]
pub fn with_policy(mut self, policy: Box<dyn crate::policy::SendPolicy>) -> Self {
self.policy = policy;
self
}
#[must_use]
pub fn with_audit(mut self, audit: Box<dyn crate::audit::AuditSink>) -> Self {
self.audit = audit;
self
}
}
impl Default for SmtpClientOptions {
fn default() -> Self {
Self::new()
}
}
impl core::fmt::Debug for SmtpClientOptions {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.debug_struct("SmtpClientOptions")
.finish_non_exhaustive()
}
}
pub(super) fn find_crlf(buf: &[u8]) -> Option<usize> {
buf.windows(2).position(|w| w == b"\r\n")
}
pub(super) fn convert_auth(err: SmtpError) -> SmtpError {
match err {
SmtpError::Protocol(ProtocolError::UnexpectedCode {
actual,
enhanced,
message,
..
}) if (500..600).contains(&actual) => SmtpError::Auth(AuthError::Rejected {
code: actual,
enhanced,
message,
}),
other => other,
}
}