如何在C++上实现 SMTP 客户端

介绍

简单邮件传输协议Simple Mail Transfer Protocol (SMTP) 是一种广泛使用的协议,用于在 TCP/IP 系统和用户之间传递电子邮件。

SMTP是电子邮件传输的重要组成部分,对于通信,我们始终需要协作和协商发送电子邮件的服务器和客户端。

什么是 SMTP 服务器?

SMTP服务器是一个应用程序,用于发送电子邮件并对来自接收服务器的响应代码做出反应,并且服务器将具有用于协商的IP地址或主机名。

SMTP 服务器必须与客户端通行此协议。现代 SMTP 服务器还必须考虑身份验证方法。对电子邮件进行身份验证是向接收服务器发出信号以表明您发送的电子邮件是合法的最佳方法之一。

什么是 SMTP 客户端?

SMTP 客户端允许使用 SMTP 服务器发送电子邮件通知。若要发送电子邮件,SMTP 客户端需要连接到将邮件发送给目标收件人的 SMTP 服务器。如今,SMTP服务器通常需要使用凭据进行客户端身份验证。

SMTP 客户端可以发送这些凭据以访问服务器。根据 SMTP 服务器的版本,可能还有其他身份验证方法登录、普通和 CRAM-MD5。

SMTP 客户端和服务器协商

必须实现到 SMTP 客户端中的核心逻辑是协商。

谈判的一个典型例子是:

客户端和服务器协商

谈判可分为三个阶段。第一个是在此阶段建立连接,我们连接到SMTP服务器并从服务器接收“READY”。这是我们通过不安全的通道发送“EHLO”或“HELLO”(这取决于服务器版本)以获取服务器支持的服务的信号。

通常,服务器发送命令“STARTTLS”以使用TLS或SSL从不安全的连接升级到安全连接。传输到安全通道是第二阶段,但如果您的 SMTP 服务器位于专用服务器上,例如,您需要来自允许访问服务器的组织的 VPN 或任何特殊情况,则此阶段可能不存在。第三阶段实际上已经确定了电子邮件的所有属性,例如发件人,收件人,CC收件人,密件抄送收件人,标题和消息

实现第一阶段

如果您查看“客户端和服务器协商”图片,您会看到服务器在响应消息之前返回一个数字。这些数字是来自服务器的响应状态,如失败或成功。我们的每个请求都有一个来自SMTP服务器的特定响应号,用于验证它是成功还是失败,它允许我们根据答案确定要做什么。

我们将为这些数字分配具体名称,以便于阅读。

/*
 * \brief response codes from host
 */
enum RessultCode {
  Okay = 250,
  Data = 354,
  Ready = 220,
  Goodbye = 221,
  Accepted = 334,
  AUTH_successful = 235
};

我将使用加速库来实现SMTP客户端。

首先,我们需要确定 SMTP 客户端的字段:

// header file
class SMTPClient {
  public: 
    typedef boost::asio::ssl::stream<boost::asio::ip::tcp::socket> SslSocket;
  ...
  private:
    std::string                   serverId_;
    std::stringstream             message_;
    boost::asio::io_service       service_;
    boost::asio::ssl::context     sslContext_;
    SslSocket                     sslSocket_;
    boost::asio::ip::tcp::socket& socket_;
    bool                          tlsEnabled_;
};

...
// source file

SMTPClient::SMTPClient() :
   ...
  , sslContext_(service_, asio::ssl::context::sslv3)
  , sslSocket_(service, sslContext_)
  , socket_(sslSocket_.next_layer()) {
}

我们需要两个套接字来实现安全和不安全的连接。在较新版本的提升中,已经有一个SSL套接字没有我的决心。需要 ssl::上下文来选择安全通道的安全方法。有SSL 2 & 3和TLS 1 & 2,甚至客户端和服务器规格,如果你知道你是一个客户端或服务器。

我使用了定义BOOST_ASIO_ENABLE_OLD_SSL构建应用程序,如果遇到问题,请尝试启用它(如果存在)。

其次,我们需要与服务器建立连接:

void SMTPClient::connect(const std::string& hostname, unsigned short port) {
   boost::system::error_code error = boost::asio::error::host_not_found;
   boost::asio::ip::tcp::resolver resolver(service_);
   boost::asio::ip::tcp::resolver::query query(hostname, boost::lexical_cast<std::string>(port));
   boost::asio::ip::tcp::resolver::iterator endpoint_iterator = resolver.resolve(query);
   boost::asio::ip::tcp::resolver::iterator end;

   while (error && endpoint_iterator != end) {
      socket_.close();
      socket_.connect(*endpoint_iterator++, error);
   }

   if (error) {
      std::cerr << "error: " << error.message());
   } else {
      handshake();
   }
}

tcp::解析器将查询向前解析为条目列表,然后,我们连接到这些查询。

实现第二阶段

该阶段描述如何建立安全通道。

void SMTPClient::handshake() {
  // Receiving the server identification
  std::string serverName = socket_.remote_endpoint().address().to_string();
  if (tlsEnabled_) {
    boost::system::error_code error;
    tlsEnabled_ = false;
    serverId_ = read(Ready);
    message_ << "EHLO " << serverName << "\r\n";
    write();
    read();
    message_ << "STARTTLS" << "\r\n";
    write();
    read(Ready);
    sslSocket_.handshake(SslSocket::client, error);
    if (error) {
      std::cerr << "Handshake error: " << error.message());
    }
    tlsEnabled_ = true;
  } else {
    serverId_ = read(Ready);
  }
  message_ << "EHLO " << serverName << "\r\n";
  write();
  read();
}

建立不安全的连接后,我们从服务器收到“就绪”代码,这是开始协商的信号。

在“开始使用”命令后,我们需要进行握手。

建立安全连接后,我们再次需要重复初始“EHLO”以从SMTP服务器获取所有可用的服务。

SSL 握手期间会发生什么情况?

在 SSL 握手过程中,客户端和服务器将一起执行以下操作:

  • 指定他们将使用哪个版本的 SSL(SSL 1、2、3 等)
  • 决定他们将使用哪些密码套件
  • 通过服务器的公钥和 SSL 证书颁发机构的数字签名验证服务器的身份
  • 生成会话密钥,以便在握手完成后使用对称加密

SSL握手涉及多个步骤,因为客户端和服务器交换完成握手所需的信息并使进一步的对话成为可能。

如何授权给SMTP服务器?

您的连接中可能不存在身份验证步骤。

有两种身份验证协议 – 普通和登录。不同之处在于如何将凭据发送到服务器。登录名和密码在两条不同的消息中单独发送或一起发送。

void SMTPClient::connect(
  const std::string& hostname, 
  unsigned short port,
  const std::string& username,
  const std::string& password,
  AuthenticationProtocol protocol) {
  
  connect(hostname, port);
  switch (protocol) {
    case LOGIN:
      authLogin(username, password);
      break;
    case PLAIN:
      authPlain(username, password);
      break;
    default:
      break;
  }
}

void SMTPClient::authPlain(const std::string& user, const std::string& password) {
  std::string auth_hash = base64_encode('\000' + user + '\000' + password);

  message_ << "AUTH PLAIN\r\n";
  write();
  read(Accepted);
  message_ << auth_hash << "\r\n";
  write();
  read(AUTH_successful);
}

void SMTPClient::authLogin(const std::string& user, const std::string& password) {
  std::string user_hash = base64_encode(user);
  std::string pswd_hash = base64_encode(password);

  message_ << "AUTH LOGIN\r\n";
  write();
  read(Accepted);
  message_ << user_hash;
  write();
  read(Accepted);
  message_ << pswd_hash;
  write();
  read(AUTH_successful);
}

实现第三阶段

最后,我们开始实现一种实际上向某人发送电子邮件的机制。

我们需要添加所有发件人,收件人和邮件正文:

void SMTPClient::send(const Mail& mail) {
  newMail(mail);
  recipients(mail);
  body(mail);
}

添加发件人:

void SMTPClient::newMail(const Mail& mail) {
  const Mail::User& sender = mail.getSender();
  message_ << "MAIL FROM: <" << sender.address() << ">\r\n";
  write();
  read();
}

添加收件人:

void SMTPClient::recipients(const Mail& mail) {
  const Mail::Recipients& recipients = mail.getRecipients();
  Mail::Recipients::const_iterator it  = recipients.begin();

  for (; it != recipients.end() ; ++it) {
    message_ << "RCPT TO: <" << it->address() << ">\r\n";
    write();
    read();
  }
}

上面的信息描述了一个真正的发送者和接收者,但是我们再次将该信息添加到body方法(见下文),它是电子邮件中的可见信息。

因此,我们可以向“A”发送电子邮件,但在电子邮件中显示“B”,甚至更改发件人。

添加正文:

void SMTPClient::body(const Mail& mail) {
  // Notify that we're starting the DATA stream
  message_ << "DATA\r\n";
  write();
  read(Data);
  // Setting the headers
  const Mail::User& sender = mail.getSender();
  addresses("To: ", mail);
  address("From: ", sender);
  // Send the content type if necessary
  const std::string& contentType = mail.getContentType();
  if (not contentType.empty()) {
    message_ << "MIME-Version: 1.0\r\n";
    message_ << "Content-Type: " << contentType << "\r\n";
  }
  // Send the subject
  message_ << "Subject: " << mail.getSubject() << "\r\n";
  // Send the body and finish the DATA stream
  message_ << mail.getBody()   << "\r\n.\r\n";
  write();
  read();
}

如何从套接字写入和读取?

最后但同样重要的 ?

tlsEnable_是一个标志,我们是否需要安全连接。

void SMTPClient::write() {
  boost::system::error_code error;
  const std::string str = message_.str();
  const boost::asio::const_buffers_1 buffer = boost::asio::buffer(str);

  if (tlsEnabled_) {
    sslSocket_.write_some(buffer, error);
  } else {
    socket_.write_some(buffer, error);
  }

  if (error) {
    std::cerr << "error: " << error.message();
  }
  message_.str(std::string());
}

我们使用限制为 256 的缓冲区;对于消息来说就足够了。

std::string SMTPService::read(RessultCode expectedReturn) {
  boost::array<char, 256> buffer;
  std::size_t bytesReceived = 0;
  
  try {
    if (tlsEnabled_) {
      bytesReceived = sslSocket_.read_some(boost::asio::buffer(buffer));
    } else {
      bytesReceived = socket_.receive(boost::asio::buffer(buffer));
    }
  } catch (const std::exception& ex) {
    std::cerr << "Exception: " << ex.what();
  }

  std::string answer;

  if (bytesReceived == 0) {
    std::cerr << "The server closed the connection.";
  }
  std::copy(buffer.begin(), buffer.begin() + bytesReceived, std::back_inserter(answer));

  unsigned short returnValue = atoi(answer.substr(0, 3).c_str());
       
  if (static_cast<unsigned short>(returnValue) != expectedReturn) {
    std::cerr << "Expected answer status to be " << expectedReturn << ", received " << answer);
  }
  return answer;
}

结论

这是发送电子邮件的最简单方法之一。如果你的项目允许使用外部库,你可以使用libquickmaillibcurlVMime。取决于库,但您的行数可能相同。