///|
#derive(Eq, Debug)
pub(all) enum SmtpSecurity {
  Plain
  StartTls
  Tls
}

///|
#derive(Eq, Debug)
pub(all) enum SmtpAuthMechanism {
  Plain
  Login
  Xoauth2
}

///|
#derive(Eq, Debug)
pub(all) enum SmtpAuth {
  None
  Plain(String, String)
  Login(String, String)
  Xoauth2(String, String)
}

///|
#derive(Eq, Debug)
pub(all) enum SmtpPhase {
  Connect
  Greet
  Hello
  StartTls
  TlsHandshake
  Auth
  MailFrom
  RcptTo
  DataCommand
  MessageBody
  FinalResponse
  Quit
}

///|
#derive(Eq, Debug)
pub(all) enum SmtpTlsTrust {
  SystemRoot
  InsecureNoVerify
  CustomPemFile(String)
}

///|
#derive(Eq, Debug)
pub struct SmtpTimeouts {
  connect_ms : Int32
  greeting_ms : Int32
  command_ms : Int32
  data_ms : Int32
  idle_ms : Int32
}

///|
#derive(Eq, Debug)
pub struct SmtpRetryPolicy {
  max_retries : Int32
  retry_delay_ms : Int32
  retry_on_connection_failure : Bool
  retry_on_timeout : Bool
  retry_on_transient_failure : Bool
}

///|
#derive(Eq, Debug)
pub struct SmtpCapabilities {
  raw_lines : Array[String]
  auth_mechanisms : Array[SmtpAuthMechanism]
  supports_starttls : Bool
  supports_pipelining : Bool
  supports_8bitmime : Bool
  max_message_size : Int32?
}

///|
#derive(Eq, Debug)
pub struct SmtpConfig {
  host : String
  port : Int32
  hello_name : String
  security : SmtpSecurity
  auth : SmtpAuth
  tls_trust : SmtpTlsTrust
  timeouts : SmtpTimeouts
  retry_policy : SmtpRetryPolicy
  reuse_connection : Bool
}

///|
#derive(Eq, Debug)
pub suberror SmtpClientError {
  TransportClosed
  BusyTransport
  Timeout(SmtpPhase, Int32)
  ConnectionFailure(SmtpPhase, String)
  InvalidResponse(String)
  UnexpectedStatus(Int32, Int32, String)
  ServiceRejected(SmtpPhase, Int32, String, FailureClass)
  MissingExtension(String)
  MissingAuthMechanism(SmtpAuthMechanism)
  AuthFailed(String)
  ProtocolError(String)
}

///|
priv enum LiveSmtpConnection {
  Plain(PlainSmtpSession)
  Tls(TlsSmtpSession)
}

///|
pub struct SmtpTransport {
  priv config : SmtpConfig
  priv mut closed : Bool
  priv mut in_flight : Bool
  priv mut live_connection : LiveSmtpConnection?
  priv mut last_capabilities : SmtpCapabilities?
  priv mut last_activity_ms : Int64
}

///|
priv enum OutgoingPayload {
  Encoded(String)
  Structured(@core.Message)
}

///|
priv struct SendProgress {
  mut data_started : Bool
}

///|
pub fn SmtpTimeouts::new(
  connect_ms~ : Int32 = 10_000,
  greeting_ms~ : Int32 = 10_000,
  command_ms~ : Int32 = 10_000,
  data_ms~ : Int32 = 120_000,
  idle_ms~ : Int32 = 60_000,
) -> SmtpTimeouts {
  { connect_ms, greeting_ms, command_ms, data_ms, idle_ms, }
}

///|
pub fn SmtpRetryPolicy::new(
  max_retries~ : Int32 = 1,
  retry_delay_ms~ : Int32 = 250,
  retry_on_connection_failure~ : Bool = true,
  retry_on_timeout~ : Bool = true,
  retry_on_transient_failure~ : Bool = true,
) -> SmtpRetryPolicy {
  {
    max_retries: if max_retries < 0 {
      0
    } else {
      max_retries
    },
    retry_delay_ms: if retry_delay_ms < 0 {
      0
    } else {
      retry_delay_ms
    },
    retry_on_connection_failure,
    retry_on_timeout,
    retry_on_transient_failure,
  }
}

///|
pub fn SmtpCapabilities::supports_auth(
  self : SmtpCapabilities,
  mechanism : SmtpAuthMechanism,
) -> Bool {
  self.auth_mechanisms.contains(mechanism)
}

///|
pub fn SmtpConfig::new(
  host : String,
  port : Int32,
  hello_name~ : String = "localhost",
  security~ : SmtpSecurity = Plain,
  auth~ : SmtpAuth = None,
  tls_trust~ : SmtpTlsTrust = SystemRoot,
  timeouts~ : SmtpTimeouts = SmtpTimeouts::new(),
  retry_policy~ : SmtpRetryPolicy = SmtpRetryPolicy::new(),
  reuse_connection~ : Bool = true,
) -> SmtpConfig {
  {
    host,
    port,
    hello_name,
    security,
    auth,
    tls_trust,
    timeouts,
    retry_policy,
    reuse_connection,
  }
}

///|
pub fn SmtpTransport::new(config : SmtpConfig) -> SmtpTransport {
  {
    config,
    closed: false,
    in_flight: false,
    live_connection: None,
    last_capabilities: None,
    last_activity_ms: 0L,
  }
}

///|
pub fn SmtpTransport::close(self : SmtpTransport) -> Unit {
  self.closed = true
  self.close_live_connection()
}

///|
pub fn SmtpTransport::capabilities(self : SmtpTransport) -> SmtpCapabilities? {
  self.last_capabilities
}

///|
pub async fn SmtpTransport::send(
  self : SmtpTransport,
  envelope : @core.Envelope,
  encoded_message : String,
) -> DeliveryOutcome {
  self.send_payload(envelope, Encoded(encoded_message))
}

///|
pub async fn SmtpTransport::send_message(
  self : SmtpTransport,
  envelope : @core.Envelope,
  message : @core.Message,
) -> DeliveryOutcome {
  self.send_payload(envelope, Structured(message))
}

///|
async fn SmtpTransport::send_payload(
  self : SmtpTransport,
  envelope : @core.Envelope,
  payload : OutgoingPayload,
) -> DeliveryOutcome {
  if self.closed {
    raise TransportClosed
  }
  if self.in_flight {
    raise BusyTransport
  }
  self.in_flight = true
  defer {
    self.in_flight = false
  }

  let mut attempt = 0
  for ;; {
    let progress = SendProgress::{ data_started: false, }
    try {
      errdefer self.close_live_connection()
      self.ensure_live_connection()
      let outcome = self.deliver_with_live_connection(
        envelope, payload, progress,
      )
      self.last_activity_ms = @async.now()
      if !self.config.reuse_connection {
        self.close_live_connection()
      }
      return outcome
    } catch {
      err => {
        guard should_retry_send(
          self.config.retry_policy,
          err,
          progress,
          attempt,
        ) else {
          raise err
        }
        attempt += 1
        if self.config.retry_policy.retry_delay_ms > 0 {
          @async.sleep(self.config.retry_policy.retry_delay_ms)
        }
      }
    }
  }
}

///|
async fn SmtpTransport::ensure_live_connection(self : SmtpTransport) -> Unit {
  if !self.config.reuse_connection {
    self.close_live_connection()
  } else if self.live_connection_is_stale() {
    self.close_live_connection()
  }
  if self.live_connection is Some(_) {
    return
  }
  let (connection, capabilities) = open_authenticated_connection(self.config)
  self.live_connection = Some(connection)
  self.last_capabilities = Some(capabilities)
  self.last_activity_ms = @async.now()
}

///|
fn SmtpTransport::live_connection_is_stale(self : SmtpTransport) -> Bool {
  if self.live_connection is None {
    return false
  }
  if self.config.timeouts.idle_ms <= 0 {
    return false
  }
  let idle_for = @async.now() - self.last_activity_ms
  idle_for > self.config.timeouts.idle_ms.to_int64()
}

///|
fn SmtpTransport::close_live_connection(self : SmtpTransport) -> Unit {
  match self.live_connection {
    Some(Plain(session)) => {
      session.io.close()
      self.live_connection = None
    }
    Some(Tls(session)) => {
      session.close()
      self.live_connection = None
    }
    None => ()
  }
}

///|
async fn SmtpTransport::deliver_with_live_connection(
  self : SmtpTransport,
  envelope : @core.Envelope,
  payload : OutgoingPayload,
  progress : SendProgress,
) -> DeliveryOutcome {
  match self.live_connection {
    Some(Plain(session)) =>
      match payload {
        Encoded(encoded_message) =>
          smtp_send_envelope_and_data(
            session,
            envelope,
            encoded_message,
            self.config.timeouts,
            progress,
          )
        Structured(message) =>
          smtp_send_message_and_data_streaming(
            session,
            PlainSmtpDataWriter::{ session, at_line_start: true, },
            envelope,
            message,
            self.config.timeouts,
            progress,
          )
      }
    Some(Tls(session)) =>
      match payload {
        Encoded(encoded_message) =>
          smtp_send_envelope_and_data(
            session,
            envelope,
            encoded_message,
            self.config.timeouts,
            progress,
          )
        Structured(message) =>
          smtp_send_message_and_data_streaming(
            session,
            TlsSmtpDataWriter::{ session, at_line_start: true, },
            envelope,
            message,
            self.config.timeouts,
            progress,
          )
      }
    None => raise ProtocolError("no active smtp connection")
  }
}

///|
async fn open_authenticated_connection(
  config : SmtpConfig,
) -> (LiveSmtpConnection, SmtpCapabilities) {
  match config.security {
    Plain => {
      let conn = execute_phase(Connect, config.timeouts.connect_ms, async fn() {
        @socket.Tcp::connect_to_host(config.host, port=config.port)
      })
      errdefer conn.close()
      let session = PlainSmtpSession::{ io: conn, }
      let capabilities = smtp_greet_and_hello(session, config, true)
      smtp_auth(session, config.auth, capabilities, config.timeouts)
      (Plain(session), capabilities)
    }
    Tls => {
      let conn = execute_phase(Connect, config.timeouts.connect_ms, async fn() {
        @socket.Tcp::connect_to_host(config.host, port=config.port)
      })
      let tls = {
        errdefer conn.close()
        execute_phase(TlsHandshake, config.timeouts.connect_ms, async fn() {
          @tls.Tls::client(
            conn,
            host=config.host,
            trust=smtp_tls_trust_to_async(config.tls_trust),
          )
        })
      }
      let session = TlsSmtpSession::{ io: tls, tcp: conn, }
      errdefer session.close()
      let capabilities = smtp_greet_and_hello(session, config, true)
      smtp_auth(session, config.auth, capabilities, config.timeouts)
      (Tls(session), capabilities)
    }
    StartTls => {
      let conn = execute_phase(Connect, config.timeouts.connect_ms, async fn() {
        @socket.Tcp::connect_to_host(config.host, port=config.port)
      })
      let tls = {
        errdefer conn.close()
        let plain = PlainSmtpSession::{ io: conn, }
        let negotiated = smtp_greet_and_hello(plain, config, false)
        guard negotiated.supports_starttls else {
          raise MissingExtension("STARTTLS")
        }
        ignore(
          send_command_expect_status(
            plain,
            "STARTTLS",
            220,
            StartTls,
            config.timeouts.command_ms,
          ),
        )
        execute_phase(TlsHandshake, config.timeouts.connect_ms, async fn() {
          @tls.Tls::client(
            plain.io,
            host=config.host,
            trust=smtp_tls_trust_to_async(config.tls_trust),
          )
        })
      }
      let session = TlsSmtpSession::{ io: tls, tcp: conn, }
      errdefer session.close()
      let capabilities = send_hello(
        session,
        config.hello_name,
        false,
        config.timeouts.command_ms,
      )
      smtp_auth(session, config.auth, capabilities, config.timeouts)
      (Tls(session), capabilities)
    }
  }
}

///|
fn auth_mechanism_for(auth : SmtpAuth) -> SmtpAuthMechanism? {
  match auth {
    None => None
    Plain(_, _) => Some(Plain)
    Login(_, _) => Some(Login)
    Xoauth2(_, _) => Some(Xoauth2)
  }
}

///|
fn require_auth_mechanism(
  capabilities : SmtpCapabilities,
  mechanism : SmtpAuthMechanism,
) -> Unit raise {
  if capabilities.auth_mechanisms.is_empty() {
    raise MissingExtension("AUTH")
  }
  if !capabilities.supports_auth(mechanism) {
    raise MissingAuthMechanism(mechanism)
  }
}

///|
async fn[X] execute_phase(
  phase : SmtpPhase,
  timeout_ms : Int32,
  work : async () -> X,
) -> X {
  try {
    if timeout_ms > 0 {
      @async.with_timeout(timeout_ms, work)
    } else {
      work()
    }
  } catch {
    @async.TimeoutError => raise Timeout(phase, timeout_ms)
    TransportClosed as err => raise err
    BusyTransport as err => raise err
    Timeout(_, _) as err => raise err
    InvalidResponse(_) as err => raise err
    UnexpectedStatus(_, _, _) as err => raise err
    ServiceRejected(_, _, _, _) as err => raise err
    MissingExtension(_) as err => raise err
    MissingAuthMechanism(_) as err => raise err
    AuthFailed(_) as err => raise err
    err => raise ConnectionFailure(phase, err.to_string())
  }
}

///|
priv trait SmtpSession {
  async fn read_line(Self) -> String
  async fn write_text(Self, String) -> Unit
}

///|
priv struct PlainSmtpSession {
  io : @socket.Tcp
}

///|
impl SmtpSession for PlainSmtpSession with fn read_line(self) {
  match self.io.read_until("\r\n") {
    Some(line) => line
    None => raise ProtocolError("connection closed while reading SMTP response")
  }
}

///|
impl SmtpSession for PlainSmtpSession with fn write_text(self, text) {
  self.io.write(text)
}

///|
priv struct TlsSmtpSession {
  io : @tls.Tls
  tcp : @socket.Tcp
}

///|
fn TlsSmtpSession::close(self : TlsSmtpSession) -> Unit {
  // Tls::close releases TLS resources but does not close its underlying TCP.
  self.io.close()
  self.tcp.close()
}

///|
impl SmtpSession for TlsSmtpSession with fn read_line(self) {
  match self.io.read_until("\r\n") {
    Some(line) => line
    None => raise ProtocolError("connection closed while reading SMTP response")
  }
}

///|
impl SmtpSession for TlsSmtpSession with fn write_text(self, text) {
  self.io.write(text)
}

///|
priv struct PlainSmtpDataWriter {
  session : PlainSmtpSession
  mut at_line_start : Bool
}

///|
impl @codec.MimeWriter for PlainSmtpDataWriter with fn write_text(self, text) {
  let (stuffed, at_line_start) = dot_stuff_chunk(text, self.at_line_start)
  self.at_line_start = at_line_start
  SmtpSession::write_text(self.session, stuffed)
}

///|
priv struct TlsSmtpDataWriter {
  session : TlsSmtpSession
  mut at_line_start : Bool
}

///|
impl @codec.MimeWriter for TlsSmtpDataWriter with fn write_text(self, text) {
  let (stuffed, at_line_start) = dot_stuff_chunk(text, self.at_line_start)
  self.at_line_start = at_line_start
  SmtpSession::write_text(self.session, stuffed)
}

///|
fn empty_capabilities() -> SmtpCapabilities {
  {
    raw_lines: [],
    auth_mechanisms: [],
    supports_starttls: false,
    supports_pipelining: false,
    supports_8bitmime: false,
    max_message_size: (None : Int32?),
  }
}

///|
async fn[S : SmtpSession] smtp_greet_and_hello(
  session : S,
  config : SmtpConfig,
  fallback_ok : Bool,
) -> SmtpCapabilities {
  ignore(read_expect_status(session, 220, Greet, config.timeouts.greeting_ms))
  send_hello(
    session,
    config.hello_name,
    fallback_ok,
    config.timeouts.command_ms,
  )
}

///|
async fn[S : SmtpSession] send_hello(
  session : S,
  hello_name : String,
  fallback_ok : Bool,
  timeout_ms : Int32,
) -> SmtpCapabilities {
  write_line(session, "EHLO " + hello_name, Hello, timeout_ms)
  let first_line = read_line(session, Hello, timeout_ms)
  let code = parse_status_code_or_raise(first_line)
  if code == 250 {
    let lines = collect_multiline_response(
      session,
      first_line,
      Hello,
      timeout_ms,
    )
    return parse_capabilities(lines)
  }
  if fallback_ok && code >= 500 {
    ignore(
      send_command_expect_status(
        session,
        "HELO " + hello_name,
        250,
        Hello,
        timeout_ms,
      ),
    )
    return empty_capabilities()
  }
  let response = format_status_line(first_line)
  if code >= 400 && code < 600 {
    raise ServiceRejected(Hello, code, response, classify_smtp_status(code))
  }
  raise UnexpectedStatus(250, code, response)
}

///|
fn parse_capabilities(lines : Array[String]) -> SmtpCapabilities {
  let auth_mechanisms : Array[SmtpAuthMechanism] = []
  let mut supports_starttls = false
  let mut supports_pipelining = false
  let mut supports_8bitmime = false
  let mut max_message_size : Int32? = None
  for line in lines {
    let normalized = line.trim().to_upper()
    if normalized == "STARTTLS" {
      supports_starttls = true
    } else if normalized == "PIPELINING" {
      supports_pipelining = true
    } else if normalized == "8BITMIME" {
      supports_8bitmime = true
    } else if normalized.has_prefix("SIZE ") {
      max_message_size = parse_positive_int(normalized[5:].trim().to_owned())
    } else if normalized.has_prefix("AUTH ") {
      for token in normalized[5:].split(" ") {
        match parse_auth_mechanism_name(token.to_owned()) {
          Some(mechanism) =>
            if !auth_mechanisms.contains(mechanism) {
              auth_mechanisms.push(mechanism)
            }
          None => ()
        }
      }
    }
  }
  {
    raw_lines: lines,
    auth_mechanisms,
    supports_starttls,
    supports_pipelining,
    supports_8bitmime,
    max_message_size,
  }
}

///|
fn parse_auth_mechanism_name(name : String) -> SmtpAuthMechanism? {
  match name {
    "PLAIN" => Some(Plain)
    "LOGIN" => Some(Login)
    "XOAUTH2" => Some(Xoauth2)
    _ => None
  }
}

///|
async fn[S : SmtpSession] smtp_auth(
  session : S,
  auth : SmtpAuth,
  capabilities : SmtpCapabilities,
  timeouts : SmtpTimeouts,
) -> Unit {
  match auth_mechanism_for(auth) {
    Some(mechanism) => require_auth_mechanism(capabilities, mechanism)
    None => ()
  }
  match auth {
    None => ()
    Plain(username, password) => {
      let payload = encode_plain_auth(username, password)
      let line = send_command_capture(
        session,
        "AUTH PLAIN " + payload,
        Auth,
        timeouts.command_ms,
      )
      expect_auth_success(line)
    }
    Login(username, password) => {
      let line = send_command_capture(
        session,
        "AUTH LOGIN",
        Auth,
        timeouts.command_ms,
      )
      expect_auth_challenge(line)
      let username_line = send_command_capture(
        session,
        encode_base64_text(username),
        Auth,
        timeouts.command_ms,
      )
      expect_auth_challenge(username_line)
      let password_line = send_command_capture(
        session,
        encode_base64_text(password),
        Auth,
        timeouts.command_ms,
      )
      expect_auth_success(password_line)
    }
    Xoauth2(username, token) => {
      let payload = encode_xoauth2(username, token)
      let line = send_command_capture(
        session,
        "AUTH XOAUTH2 " + payload,
        Auth,
        timeouts.command_ms,
      )
      expect_auth_success(line)
    }
  }
}

///|
fn encode_plain_auth(username : String, password : String) -> String {
  encode_base64_text("\u{0000}" + username + "\u{0000}" + password)
}

///|
fn encode_xoauth2(username : String, token : String) -> String {
  encode_base64_text(
    "user=" + username + "\u{0001}auth=Bearer " + token + "\u{0001}\u{0001}",
  )
}

///|
fn encode_base64_text(text : String) -> String {
  @moonbitlang/core/encoding/base64.encode(
    @moonbitlang/core/encoding/utf8.encode(text),
  )
}

///|
fn expect_auth_success(line : String) -> Unit raise {
  let code = parse_status_code_or_raise(line)
  if code != 235 {
    raise AuthFailed(format_status_line(line))
  }
}

///|
fn expect_auth_challenge(line : String) -> Unit raise {
  let code = parse_status_code_or_raise(line)
  if code != 334 {
    raise AuthFailed(format_status_line(line))
  }
}

///|
async fn[S : SmtpSession] smtp_send_envelope_and_data(
  session : S,
  envelope : @core.Envelope,
  encoded_message : String,
  timeouts : SmtpTimeouts,
  progress : SendProgress,
) -> DeliveryOutcome {
  ignore(
    send_command_expect_status(
      session,
      "MAIL FROM:<" + envelope.mail_from.address + ">",
      250,
      MailFrom,
      timeouts.command_ms,
    ),
  )
  let accepted : Array[@core.Mailbox] = []
  let rejected : Array[@core.Mailbox] = []
  let mut rejection_response = "550 all recipients rejected"
  for recipient in envelope.rcpt_to {
    let line = send_command_capture(
      session,
      "RCPT TO:<" + recipient.address + ">",
      RcptTo,
      timeouts.command_ms,
    )
    let code = parse_status_code_or_raise(line)
    if code >= 200 && code < 300 {
      accepted.push(recipient)
    } else if code >= 400 && code < 600 {
      rejected.push(recipient)
      rejection_response = format_status_line(line)
    } else {
      raise InvalidResponse(line)
    }
  }
  if accepted.is_empty() {
    return DeliveryOutcome::partial([], rejected, [], rejection_response)
  }
  ignore(
    send_command_expect_status(
      session,
      "DATA",
      354,
      DataCommand,
      timeouts.command_ms,
    ),
  )
  progress.data_started = true
  write_text(
    session,
    dot_stuff_message(encoded_message) + "\r\n.\r\n",
    MessageBody,
    timeouts.data_ms,
  )
  let final_line = read_expect_status(
    session,
    250,
    FinalResponse,
    timeouts.command_ms,
  )
  let response = format_status_line(final_line)
  if rejected.is_empty() {
    DeliveryOutcome::all_accepted(accepted, response~)
  } else {
    DeliveryOutcome::partial(accepted, rejected, [], response)
  }
}

///|
async fn[S : SmtpSession, W : @codec.MimeWriter] smtp_send_message_and_data_streaming(
  session : S,
  writer : W,
  envelope : @core.Envelope,
  message : @core.Message,
  timeouts : SmtpTimeouts,
  progress : SendProgress,
) -> DeliveryOutcome {
  ignore(
    send_command_expect_status(
      session,
      "MAIL FROM:<" + envelope.mail_from.address + ">",
      250,
      MailFrom,
      timeouts.command_ms,
    ),
  )
  let accepted : Array[@core.Mailbox] = []
  let rejected : Array[@core.Mailbox] = []
  let mut rejection_response = "550 all recipients rejected"
  for recipient in envelope.rcpt_to {
    let line = send_command_capture(
      session,
      "RCPT TO:<" + recipient.address + ">",
      RcptTo,
      timeouts.command_ms,
    )
    let code = parse_status_code_or_raise(line)
    if code >= 200 && code < 300 {
      accepted.push(recipient)
    } else if code >= 400 && code < 600 {
      rejected.push(recipient)
      rejection_response = format_status_line(line)
    } else {
      raise InvalidResponse(line)
    }
  }
  if accepted.is_empty() {
    return DeliveryOutcome::partial([], rejected, [], rejection_response)
  }
  ignore(
    send_command_expect_status(
      session,
      "DATA",
      354,
      DataCommand,
      timeouts.command_ms,
    ),
  )
  progress.data_started = true
  execute_phase(MessageBody, timeouts.data_ms, async fn() {
    @codec.write_to(writer, message)
    session.write_text("\r\n.\r\n")
  })
  let final_line = read_expect_status(
    session,
    250,
    FinalResponse,
    timeouts.command_ms,
  )
  let response = format_status_line(final_line)
  if rejected.is_empty() {
    DeliveryOutcome::all_accepted(accepted, response~)
  } else {
    DeliveryOutcome::partial(accepted, rejected, [], response)
  }
}

///|
fn should_retry_send(
  retry_policy : SmtpRetryPolicy,
  err : Error,
  progress : SendProgress,
  attempt : Int32,
) -> Bool {
  if progress.data_started {
    return false
  }
  if attempt >= retry_policy.max_retries {
    return false
  }
  match err {
    ConnectionFailure(_, _) => retry_policy.retry_on_connection_failure
    Timeout(_, _) => retry_policy.retry_on_timeout
    ServiceRejected(_, _, _, failure_class) =>
      retry_policy.retry_on_transient_failure && failure_class == Transient
    _ => false
  }
}

///|
async fn[S : SmtpSession] send_command_expect_status(
  session : S,
  command : String,
  expected : Int32,
  phase : SmtpPhase,
  timeout_ms : Int32,
) -> String {
  let line = send_command_capture(session, command, phase, timeout_ms)
  let code = parse_status_code_or_raise(line)
  if code != expected {
    raise_status_mismatch(expected, code, line, phase)
  }
  line
}

///|
async fn[S : SmtpSession] send_command_capture(
  session : S,
  command : String,
  phase : SmtpPhase,
  timeout_ms : Int32,
) -> String {
  write_line(session, command, phase, timeout_ms)
  read_line(session, phase, timeout_ms)
}

///|
async fn[S : SmtpSession] read_expect_status(
  session : S,
  expected : Int32,
  phase : SmtpPhase,
  timeout_ms : Int32,
) -> String {
  let line = read_line(session, phase, timeout_ms)
  let code = parse_status_code_or_raise(line)
  if code != expected {
    raise_status_mismatch(expected, code, line, phase)
  }
  line
}

///|
async fn[S : SmtpSession] write_line(
  session : S,
  command : String,
  phase : SmtpPhase,
  timeout_ms : Int32,
) -> Unit {
  write_text(session, command + "\r\n", phase, timeout_ms)
}

///|
async fn[S : SmtpSession] write_text(
  session : S,
  text : String,
  phase : SmtpPhase,
  timeout_ms : Int32,
) -> Unit {
  execute_phase(phase, timeout_ms, async fn() { session.write_text(text) })
}

///|
async fn[S : SmtpSession] read_line(
  session : S,
  phase : SmtpPhase,
  timeout_ms : Int32,
) -> String {
  execute_phase(phase, timeout_ms, async fn() { session.read_line() })
}

///|
fn raise_status_mismatch(
  expected : Int32,
  actual : Int32,
  line : String,
  phase : SmtpPhase,
) -> Unit raise {
  let response = format_status_line(line)
  if actual >= 400 && actual < 600 {
    raise ServiceRejected(phase, actual, response, classify_smtp_status(actual))
  }
  raise UnexpectedStatus(expected, actual, response)
}

///|
async fn[S : SmtpSession] collect_multiline_response(
  session : S,
  first_line : String,
  phase : SmtpPhase,
  timeout_ms : Int32,
) -> Array[String] {
  let lines : Array[String] = []
  let mut current = first_line
  for ;; {
    lines.push(parse_response_text(current))
    if response_is_final_line(current) {
      return lines
    }
    current = read_line(session, phase, timeout_ms)
  }
}

///|
fn response_is_final_line(line : String) -> Bool {
  line.length() >= 4 && line.code_unit_at(3).to_int() == ' '.to_int()
}

///|
fn parse_response_text(line : String) -> String {
  if line.length() <= 4 {
    ""
  } else {
    line[4:].to_owned()
  }
}

///|
fn parse_status_code_or_raise(line : String) -> Int32 raise {
  match parse_status_code(line) {
    Some(status) => status
    None => raise InvalidResponse(line)
  }
}

///|
fn format_status_line(line : String) -> String {
  line.trim().to_owned()
}

///|
fn parse_positive_int(text : String) -> Int32? {
  if text == "" {
    return None
  }
  let mut value = 0
  for ch in text.iter() {
    let unit = ch.to_int()
    if !is_ascii_digit_unit(unit) {
      return None
    }
    value = value * 10 + digit_value_unit(unit)
  }
  Some(value)
}

///|
fn dot_stuff_message(message : String) -> String {
  let stuffed = message.replace_all(old="\r\n.", new="\r\n..")
  if stuffed.has_prefix(".") {
    "." + stuffed
  } else {
    stuffed
  }
}

///|
fn dot_stuff_chunk(text : String, at_line_start : Bool) -> (String, Bool) {
  let builder = StringBuilder()
  let mut line_start = at_line_start
  for ch in text.iter() {
    if line_start && ch == '.' {
      builder.write_char('.')
    }
    builder.write_char(ch)
    if ch == '\n' {
      line_start = true
    } else if ch != '\r' {
      line_start = false
    }
  }
  (builder.to_string(), line_start)
}

///|
fn smtp_tls_trust_to_async(
  trust : SmtpTlsTrust,
) -> @moonbitlang/async/tls.TrustedRoot {
  match trust {
    SystemRoot => SystemRoot
    InsecureNoVerify => NoVerification
    CustomPemFile(path) => CustomPemFile(path)
  }
}