///|
/// The session state machine tracks the SMTP conversation progress.
pub enum SmtpState {
Disconnected
Connected
EhloDone
AuthDone
MailFromDone
RcptDone
DataDone
QuitDone
} derive(Eq, Debug)
///|
/// A stateful SMTP client session over a `SmtpTransport`. The session never
/// touches a socket itself; all I/O goes through the injected transport
/// (FakeTransport for deterministic tests, the async socket adapter on native).
pub struct SmtpSession[T] {
transport : T
config : SmtpConfig
mut state : SmtpState
mut caps : SmtpCaps
trace : SmtpTrace
}
///|
pub fn[T] SmtpSession::new(
transport : T,
config : SmtpConfig,
) -> SmtpSession[T] {
{
transport,
config,
state: Disconnected,
caps: SmtpCaps::empty(),
trace: SmtpTrace::empty(),
}
}
///|
pub fn[T] SmtpSession::state(self : SmtpSession[T]) -> SmtpState {
self.state
}
///|
pub fn[T] SmtpSession::caps(self : SmtpSession[T]) -> SmtpCaps {
self.caps
}
///|
pub fn[T] SmtpSession::trace(self : SmtpSession[T]) -> SmtpTrace {
self.trace
}
///|
/// Read a complete (possibly multi-line) server reply.
async fn[T : SmtpTransport] SmtpSession::read_reply(
self : SmtpSession[T],
) -> ServerReply raise MailFailure {
let lines = []
let mut more = true
while more {
let line = self.transport.read_line()
lines.push(line)
match parse_reply_line(line) {
Some(r) => more = r.more
None => {
if lines.length() == 1 {
raise MailFailure::smtp(MM_SMTP_001, "malformed SMTP reply: \{line}")
}
more = false
}
}
}
let reply = ServerReply::from_lines(lines)
self.trace.final_code = reply.code
reply
}
///|
/// Send one line and read the reply, recording the interaction in the trace.
async fn[T : SmtpTransport] SmtpSession::command(
self : SmtpSession[T],
line : String,
) -> ServerReply raise MailFailure {
self.transport.write_line(line)
let reply = self.read_reply()
self.trace.add_step(line, reply, 0)
reply
}
///|
/// Send a sensitive line (AUTH payload) that must not appear in the trace.
async fn[T : SmtpTransport] SmtpSession::sensitive_command(
self : SmtpSession[T],
line : String,
) -> ServerReply raise MailFailure {
self.transport.write_line(line)
let reply = self.read_reply()
self.trace.add_step("[REDACTED]", reply, 0)
reply
}
///|
/// Connect and read the server greeting (220).
pub async fn[T : SmtpTransport] SmtpSession::connect(
self : SmtpSession[T],
) -> Unit raise MailFailure {
self.transport.connect(self.config)
let greeting = self.read_reply()
self.trace.add_step("CONNECT", greeting, 0)
guard greeting.is_positive() else {
raise MailFailure::smtp(
MM_SMTP_001,
"server did not greet with 2xx: \{greeting.lines.join(" / ")}",
)
}
self.state = Connected
}
///|
/// Negotiate with EHLO (falling back to HELO when the server rejects EHLO).
pub async fn[T : SmtpTransport] SmtpSession::ehlo(
self : SmtpSession[T],
) -> Unit raise MailFailure {
let reply = self.command(encode_ehlo(self.config.host))
if reply.code == 250 {
// every EHLO line may carry a capability, including the first line
let caps_lines = reply.lines.map(fn(l) { strip_reply_code(l) })
self.caps = parse_caps(caps_lines)
self.trace.capabilities = self.caps
self.state = EhloDone
return
}
if reply.is_permanent() {
let helo = self.command(encode_helo(self.config.host))
guard helo.is_positive() else {
raise MailFailure::smtp(
MM_SMTP_001,
"EHLO and HELO both rejected: \{helo.lines.join(" / ")}",
)
}
self.trace.capabilities = self.caps
self.state = EhloDone
return
}
raise MailFailure::smtp(
MM_SMTP_001,
"EHLO failed: \{reply.lines.join(" / ")}",
)
}
///|
/// Authenticate using the configured method (MM_AUTH_001 / MM_AUTH_002).
pub async fn[T : SmtpTransport] SmtpSession::auth(
self : SmtpSession[T],
auth : AuthMethod,
) -> Unit raise MailFailure {
match auth {
Plain(user, pass) => self.auth_plain(user, pass)
Login(user, pass) => self.auth_login(user, pass)
CramMd5(user, pass) => self.auth_cram_md5(user, pass)
}
self.state = AuthDone
}
///|
async fn[T : SmtpTransport] SmtpSession::auth_plain(
self : SmtpSession[T],
user : String,
pass : String,
) -> Unit raise MailFailure {
self.trace.auth_used = "PLAIN"
let creds = auth_plain_credentials(user, pass)
let reply = self.command(encode_auth_plain(creds))
if reply.is_positive() {
return
}
if reply.code == 334 {
// server expects a separate empty response line
let reply2 = self.sensitive_command("")
guard reply2.is_positive() else {
raise MailFailure::auth(
"AUTH PLAIN rejected: \{reply2.lines.join(" / ")}",
)
}
return
}
raise MailFailure::auth("AUTH PLAIN rejected: \{reply.lines.join(" / ")}")
}
///|
async fn[T : SmtpTransport] SmtpSession::auth_login(
self : SmtpSession[T],
user : String,
pass : String,
) -> Unit raise MailFailure {
self.trace.auth_used = "LOGIN"
let reply = self.command(encode_auth_login())
if reply.is_positive() {
return
}
guard reply.code == 334 else {
raise MailFailure::auth("AUTH LOGIN rejected: \{reply.lines.join(" / ")}")
}
let reply2 = self.sensitive_command(base64_encode_str(user))
if reply2.is_positive() {
return
}
guard reply2.code == 334 else {
raise MailFailure::auth(
"AUTH LOGIN username rejected: \{reply2.lines.join(" / ")}",
)
}
let reply3 = self.sensitive_command(base64_encode_str(pass))
guard reply3.is_positive() else {
raise MailFailure::auth(
"AUTH LOGIN password rejected: \{reply3.lines.join(" / ")}",
)
}
}
///|
async fn[T : SmtpTransport] SmtpSession::auth_cram_md5(
self : SmtpSession[T],
user : String,
pass : String,
) -> Unit raise MailFailure {
self.trace.auth_used = "CRAM-MD5"
let reply = self.command(encode_auth_cram_md5())
guard reply.code == 334 else {
raise MailFailure::auth(
"AUTH CRAM-MD5 rejected: \{reply.lines.join(" / ")}",
)
}
let challenge = reply.text.trim().to_owned()
let response = cram_md5_response(user, pass, challenge)
let reply2 = self.sensitive_command(response)
guard reply2.is_positive() else {
raise MailFailure::auth(
"AUTH CRAM-MD5 rejected: \{reply2.lines.join(" / ")}",
)
}
}
///|
/// Raise a classified error based on the reply code: permanent (5xx) failures
/// are `SmtpPermanent`, transient (4xx) failures are `SmtpTransient`, and
/// anything else is a generic protocol `Smtp` error (MM_SMTP_003).
fn raise_reply(reply : ServerReply, context : String) -> Unit raise MailFailure {
if reply.is_permanent() {
raise MailFailure::smtp_permanent("\{context}: \{reply.lines.join(" / ")}")
} else if reply.is_transient() {
raise MailFailure::smtp_transient("\{context}: \{reply.lines.join(" / ")}")
} else {
raise MailFailure::smtp(
MM_SMTP_003,
"\{context}: \{reply.lines.join(" / ")}",
)
}
}
///|
pub async fn[T : SmtpTransport] SmtpSession::mail_from(
self : SmtpSession[T],
reverse_path : String,
) -> Unit raise MailFailure {
let reply = self.command(encode_mail_from(reverse_path))
guard reply.is_positive() else {
raise_reply(reply, "MAIL FROM \{reverse_path} rejected")
}
self.state = MailFromDone
}
///|
pub async fn[T : SmtpTransport] SmtpSession::rcpt_to(
self : SmtpSession[T],
forward_path : String,
) -> Unit raise MailFailure {
let reply = self.command(encode_rcpt_to(forward_path))
guard reply.is_positive() else {
raise_reply(reply, "RCPT TO \{forward_path} rejected")
}
self.state = RcptDone
}
///|
/// Send the DATA command, the already dot-stuffed message lines, and the
/// terminating `.` (MM_SMTP_007).
pub async fn[T : SmtpTransport] SmtpSession::send_data(
self : SmtpSession[T],
lines : Array[String],
) -> Unit raise MailFailure {
let reply = self.command("DATA")
guard reply.is_intermediate() else { raise_reply(reply, "DATA not accepted") }
for line in lines {
self.transport.write_line(line)
}
let term = self.command(".")
guard term.is_positive() else {
raise_reply(term, "message rejected after DATA")
}
self.state = DataDone
}
///|
/// Send QUIT and close.
pub async fn[T : SmtpTransport] SmtpSession::quit(
self : SmtpSession[T],
) -> Unit raise MailFailure {
let reply = self.command("QUIT")
self.state = QuitDone
self.transport.close()
let _ = reply
}
///|
pub fn[T : SmtpTransport] SmtpSession::close(self : SmtpSession[T]) -> Unit {
self.transport.close()
self.state = Disconnected
}