//
// Copyright (c) 2008, Brian Frank and Andy Frank
// Licensed under the Academic Free License version 3.0
//
// History:
//   29 Apr 08  Brian Frank  Creation
//

using inet

**
** SmtpClient implements the client side of SMTP (Simple
** Mail Transport Protocol) as specified by RFC 2821.
**
** See [pod doc]`pod-doc` and [examples]`examples::email-sending`.
**
class SmtpClient
{

//////////////////////////////////////////////////////////////////////////
// Configuration
//////////////////////////////////////////////////////////////////////////

  **
  ** DNS hostname of server.
  **
  Str? host

  **
  ** TCP port number of server, defaults to 25.
  **
  Int port := 25

  **
  ** Username to use for authentication, or null to skip
  ** authentication.
  **
  Str? username

  **
  ** Password to use for authentication, or null to skip
  ** authentication.
  **
  Str? password

  **
  ** Open connection using SSL/TLS (ensure port is configured properly).
  ** If false then the connection is opened plaintext, but may still be
  ** upgraded to TLS if server specifies STARTTLS.
  **
  Bool ssl

  ** The `inet::SocketConfig` to use for creating sockets.
  SocketConfig socketConfig := SocketConfig.cur

//////////////////////////////////////////////////////////////////////////
// Send
//////////////////////////////////////////////////////////////////////////

  **
  ** Return true if there is no open session.
  **
  Bool isClosed()
  {
    return sock == null
  }

  **
  ** Open a session to the SMTP server.  If username and
  ** password are configured, then SMTP authentication is
  ** attempted.  Throw SmtpErr if there is a protocol error.
  ** Throw IOErr is there is a network problem.
  **
  Void open()
  {
    // do sanity checking before opening the socket
    if (host == null) throw NullErr("host is null")

    // open the socket connection
    sock = TcpSocket(socketConfig)
    if (ssl) sock = sock.upgradeTls
    sock.connect(IpAddr(host), port)
    try
    {
      // read server hello
      res := readRes
      if (res.code != 220) throw SmtpErr.makeRes(res)

      // EHLO query the extensions supported
      writeReq("EHLO [$IpAddr.local.numeric]")
      res = readRes
      if (res.code != 250) throw SmtpErr.makeRes(res)
      readExts(res)

      // if we have starttls and no plaintext auth
      // options then upgrade the socket
      if (starttls && (auths == null || auths.isEmpty))
      {
        // tell server we're starting TLS
        writeReq("STARTTLS")
        res = readRes
        if (res.code != 220) throw SmtpErr.makeRes(res)

        // upgrade the socket to SSL/TLS
        sock = sock.upgradeTls

        // redo EHLO and SMTP handshake
        writeReq("EHLO [$IpAddr.local.numeric]")
        res = readRes
        if (res.code != 250) throw SmtpErr.makeRes(res)
        readExts(res)
      }

      // authenticate if configured
      if (username != null && password != null && auths != null && !auths.isEmpty)
        authenticate
    }
    catch (Err e)
    {
      close
      throw e
    }
  }

  **
  ** Close the session to the SMTP server.  Do nothing if
  ** session already closed.
  **
  Void close()
  {
    if (sock != null)
    {
      try { writeReq("QUIT") } catch {}
      try { sock.close } catch {}
      sock = null
    }
  }

  **
  ** Send the email to the SMTP server.  Throw SmtpErr if
  ** there is a protocol error.  Throw IOErr if there is
  ** a networking problem.  If the session is closed, then
  ** this call automatically opens the session and guarantees
  ** a close after it is complete.
  **
  Void send(Email email)
  {
    email.validate
    autoOpen := isClosed
    if (autoOpen) open
    try
    {
      // MAIL command
      writeReq("MAIL From:" + MimeUtil.toAddrSpec(email.from))
      res := readRes
      if (res.code != 250) throw SmtpErr.makeRes(res)

      // RCPT for each to address
      email.recipients.each |Str to|
      {
        writeReq("RCPT To:" + MimeUtil.toAddrSpec(to))
        res = readRes
        if (res.code != 250) throw SmtpErr.makeRes(res)
      }

      // DATA command
      writeReq("DATA")
      res = readRes
      if (res.code != 354) throw SmtpErr.makeRes(res)

      // encode email message
      email.encode(sock.out)
      sock.out.flush
      res = readRes
      if (res.code != 250) throw SmtpErr.makeRes(res)
    }
    finally
    {
      if (autoOpen) close
    }
  }

  **
  ** Write a request line to the server.
  **
  private Void writeReq(Str req)
  {
    sock.out.print(req).print("\r\n").flush
    if (log.isDebug) log.debug("c: $req")
  }

  **
  ** Read a single or multi-line reply from the server.
  **
  private SmtpRes readRes()
  {
    res := SmtpRes()

    while (true)
    {
      line := sock.in.readLine
      try
      {
        res.code = line[0..2].toInt
        if (line.size <= 4) { res.lines.add(""); break }
        res.lines.add(line[4..-1])
        if (line[3] != '-') break
      }
      catch (Err e)
      {
        throw IOErr("Invalid SMTP reply '$line'")
      }
    }

    if (log.isDebug)
    {
      res.lines.each |Str line, Int i|
      {
        sep := i < res.lines.size-1 ? "-" : " "
        log.debug("s: $res.code$sep$line")
      }
    }

    return res
  }

  **
  ** Query the reply lines to figure out which extensions
  ** the server supports that we might use.
  **
  private Void readExts(SmtpRes res)
  {
    res.lines.each |Str line|
    {
      toks := line.upper.split
      switch (toks[0])
      {
        case "AUTH":
          auths = toks[1..-1]
        case "STARTTLS":
          starttls = true
      }
    }
  }

//////////////////////////////////////////////////////////////////////////
// Authentication
//////////////////////////////////////////////////////////////////////////

  **
  ** Authenticate using the strongest mechanism
  ** which both the server and myself support.
  **
  Void authenticate()
  {
    if (auths.contains("CRAM-MD5")) { authCramMd5; return }
    if (auths.contains("LOGIN"))    { authLogin;   return }
    if (auths.contains("PLAIN"))    { authPlain;   return }
    throw Err("No AUTH mechanism available: $auths")
  }

  **
  ** Authenticate using CRAM-MD5 mechanism.
  **
  Void authCramMd5()
  {
    // submit auth request which returns nonce
    writeReq("AUTH CRAM-MD5")
    res := readRes
    if (res.code != 334) throw SmtpErr.makeRes(res)

    // generate HMAC from nonce and password
    nonce := Buf.fromBase64(res.line.trim)
    hmac := nonce.hmac("MD5", password.toBuf)
    cred := "$username $hmac.toHex.lower"

    // submit username space digest
    writeReq(cred.toBuf.toBase64)
    res = readRes
    if (res.code != 235) throw SmtpErr.makeRes(res)
  }

  **
  ** Authenticate using LOGIN mechanism.
  **
  Void authLogin()
  {
    // auth
    writeReq("AUTH LOGIN")
    res := readRes
    if (res.code != 334 || res.line != "VXNlcm5hbWU6") throw SmtpErr.makeRes(res)

    // username
    writeReq(username.toBuf.toBase64)
    res = readRes
    if (res.code != 334 || res.line != "UGFzc3dvcmQ6") throw SmtpErr.makeRes(res)

    // password
    writeReq(password.toBuf.toBase64)
    res = readRes
    if (res.code != 235) throw SmtpErr.makeRes(res)
  }

  **
  ** Authenticate using PLAIN mechanism.
  **
  Void authPlain()
  {
    // not tested against real SMTP server
    creds := Buf().write(0).print(username).write(0).print(password)
    writeReq("AUTH PLAIN $creds.toBase64")
    res := readRes
    if (res.code != 235) throw SmtpErr.makeRes(res)
  }

//////////////////////////////////////////////////////////////////////////
// Fields
//////////////////////////////////////////////////////////////////////////

  ** Log for tracing
  @NoDoc Log log := Log.get("smtp")

  private TcpSocket? sock  // Socket if open or null if closed
  private Str[]? auths     // SASL auth mechanisms supported by server
  private Bool starttls    // was STARTTLS specified
}

**************************************************************************
** SmtpRes
**************************************************************************

internal class SmtpRes
{
  Void dump(OutStream out := Env.cur.out)
  {
    lines.each |Str line, Int i|
    {
      sep := i < lines.size-1 ? "-" : " "
      out.print(code).print(sep).printLine(line)
    }
  }

  override Str toStr() { return "$code $lines.last" }

  Str line() { return lines.last }

  Int code
  Str[] lines := Str[,]
}