///|
/// SMTP-level failures. Messages never contain credentials: they carry the
/// stage and the server's status line, which is what an operator needs.
suberror SmtpFailed {
SmtpError(String)
} derive(Debug)
///|
pub extend SmtpFailed with @debug.Debug::{to_repr}
///|
/// One connection to the relay, plaintext until STARTTLS wraps it.
priv enum SmtpWire {
Plain(@socket.Tcp)
Secure(@tls.Tls)
}
///|
async fn SmtpWire::line(self : SmtpWire) -> String {
let text = match self {
Plain(conn) => conn.read_until("\r\n")
Secure(conn) => conn.read_until("\r\n")
}
match text {
Some(line) => line
None => raise SmtpError("connection closed by the server")
}
}
///|
async fn SmtpWire::send(self : SmtpWire, text : String) -> Unit {
let data = @utf8.encode(text)
match self {
Plain(conn) => conn.write(data)
Secure(conn) => conn.write(data)
}
}
///|
/// Read one reply (relays answer multiline as "250-...\r\n250 ...\r\n")
/// and verify its status code.
async fn SmtpWire::reply(self : SmtpWire, code : String) -> Unit {
let mut current = self.line()
while current.length() >= 4 && current[3].to_int() == 0x2D {
current = self.line()
}
if current.length() < 4 || !current.has_prefix(code) {
raise SmtpError("expected " + code + ", got: " + trim_reply(current))
}
}
///|
fn trim_reply(line : String) -> String {
let mut stop = line.length()
while stop > 0 {
let ch = line[stop - 1].to_int()
if ch == 0x0D || ch == 0x0A {
stop -= 1
} else {
break
}
}
line[:stop].to_owned()
}
///|
/// Deliver one RFC5322 message over SMTP with STARTTLS (or implicit TLS for
/// smtps://). None on acceptance; Some carries a credential-free reason.
pub async fn smtp_deliver(
target : Target,
delivery : Delivery,
mail : String,
ehlo_name : String,
timeout_ms : Int,
) -> String? {
let exchange = async fn() { smtp_session(target, delivery, mail, ehlo_name) }
let answer = try @async.with_timeout_opt(timeout_ms, exchange) catch {
e =>
match e {
SmtpError(reason) => return Some(reason)
other => return Some(other.to_string())
}
} noraise {
answer => answer
}
match answer {
None => Some("did not finish within " + timeout_ms.to_string() + "ms")
Some(failure) => failure
}
}
///|
async fn smtp_session(
target : Target,
delivery : Delivery,
mail : String,
ehlo_name : String,
) -> String? {
// smtp://host:port forces STARTTLS before credentials; smtps:// is TLS
// from the first byte — the same contract curl had.
let url = delivery.url
let secure = url.has_prefix("smtps://")
let prefix_len = if secure { 8 } else { 7 }
let rest = url[prefix_len:url.length()].to_owned()
let mut host = rest
let mut port = if secure { 465 } else { 25 }
for i, ch in rest {
if ch == ':' {
host = rest[:i].to_owned()
port = match parse_smtp_port(rest[i + 1:].to_owned()) {
Some(parsed) => parsed
None => port
}
break
}
if ch == '/' {
break
}
}
let conn = @socket.Tcp::connect_to_host(host, port~)
let mut wire : SmtpWire = if secure {
Secure(@tls.Tls::client(conn, host~))
} else {
Plain(conn)
}
wire.reply("220")
wire.send("EHLO " + ehlo_name + "\r\n")
wire.reply("250")
if !secure {
wire.send("STARTTLS\r\n")
wire.reply("220")
wire = Secure(@tls.Tls::client(conn, host~))
wire.send("EHLO " + ehlo_name + "\r\n")
wire.reply("250")
}
match target.username {
Some(username) if username != "" => {
let blob = "\u{0}" + username + "\u{0}" + target.password.unwrap_or("")
wire.send("AUTH PLAIN " + @base64.encode(@utf8.encode(blob)) + "\r\n")
wire.reply("235")
}
_ => ()
}
wire.send("MAIL FROM:<" + target.from.unwrap_or("") + ">\r\n")
wire.reply("250")
for recipient in target.to.unwrap_or([]) {
wire.send("RCPT TO:<" + recipient + ">\r\n")
wire.reply("250")
}
wire.send("DATA\r\n")
wire.reply("354")
wire.send(dot_stuff(mail) + "\r\n.\r\n")
wire.reply("250")
wire.send("QUIT\r\n")
None
}
///|
fn parse_smtp_port(text : String) -> Int? {
if text.is_empty() {
return None
}
let mut value = 0
for i in 0.. 0x39 {
return None
}
value = value * 10 + (ch - 0x30)
}
if value <= 0 || value > 65535 {
None
} else {
Some(value)
}
}
///|
/// SMTP transparency: a line starting with a dot gets another dot, and the
/// body travels with CRLF line endings.
fn dot_stuff(mail : String) -> String {
let lines = mail.split("\n")
let out = []
for line in lines {
let clean = trim_reply(line.to_owned())
let stuffed = if clean.has_prefix(".") { "." + clean } else { clean }
out.push(stuffed)
}
out.join("\r\n")
}