198 lines
6.2 KiB
PHP
198 lines
6.2 KiB
PHP
<?php
|
||
// 纯 PHP socket 实现的 SMTP 发信类,无需 Composer / PHPMailer。
|
||
// 支持 SSL(465)与 STARTTLS(587)以及无加密,使用 AUTH LOGIN 鉴权。
|
||
if (!defined('IN_APP')) {
|
||
http_response_code(403);
|
||
exit('Forbidden');
|
||
}
|
||
|
||
class SmtpMailer
|
||
{
|
||
public $log = [];
|
||
public $lastError = '';
|
||
|
||
private $host;
|
||
private $port;
|
||
private $enc; // '' | 'ssl' | 'tls'
|
||
private $user;
|
||
private $pass;
|
||
|
||
public function __construct($host, $port, $enc, $user, $pass)
|
||
{
|
||
$this->host = (string) $host;
|
||
$this->port = (int) $port;
|
||
$this->enc = (string) $enc;
|
||
$this->user = (string) $user;
|
||
$this->pass = (string) $pass;
|
||
}
|
||
|
||
/**
|
||
* 发送邮件。
|
||
* @param string $to 收件人,多个用逗号分隔
|
||
* @param string $subject 主题
|
||
* @param string $bodyHtml 正文(HTML)
|
||
* @param string $fromEmail 发件人邮箱
|
||
* @param string $fromName 发件人名称
|
||
* @return bool
|
||
*/
|
||
public function send($to, $subject, $bodyHtml, $fromEmail, $fromName)
|
||
{
|
||
if ($this->host === '') {
|
||
$this->lastError = 'SMTP 主机未配置';
|
||
return false;
|
||
}
|
||
$port = $this->port ?: ($this->enc === 'ssl' ? 465 : 587);
|
||
$timeout = 20;
|
||
$scheme = $this->enc === 'ssl' ? 'ssl' : 'tcp';
|
||
$opts = [];
|
||
if ($this->enc === 'ssl' || $this->enc === 'tls') {
|
||
$opts['ssl'] = [
|
||
'verify_peer' => false,
|
||
'verify_peer_name' => false,
|
||
'allow_self_signed' => true,
|
||
];
|
||
}
|
||
$ctx = $opts ? stream_context_create($opts) : null;
|
||
|
||
$errno = 0;
|
||
$errstr = '';
|
||
$sock = @stream_socket_client(
|
||
$scheme . '://' . $this->host . ':' . $port,
|
||
$errno, $errstr, $timeout, STREAM_CLIENT_CONNECT, $ctx
|
||
);
|
||
if (!$sock) {
|
||
$this->lastError = "无法连接 SMTP 服务器({$this->host}:{$port}):$errstr ($errno)";
|
||
return false;
|
||
}
|
||
stream_set_timeout($sock, $timeout);
|
||
|
||
// 读取服务商标语 220
|
||
if ($this->talk($sock, null, [220]) === false) {
|
||
fclose($sock);
|
||
return false;
|
||
}
|
||
|
||
$hostHeader = isset($_SERVER['HTTP_HOST']) ? $_SERVER['HTTP_HOST'] : 'localhost';
|
||
|
||
// EHLO
|
||
if ($this->talk($sock, 'EHLO ' . $hostHeader, [250]) === false) {
|
||
fclose($sock);
|
||
return false;
|
||
}
|
||
|
||
// STARTTLS 协商
|
||
if ($this->enc === 'tls') {
|
||
if ($this->talk($sock, 'STARTTLS', [220]) === false) {
|
||
fclose($sock);
|
||
return false;
|
||
}
|
||
if (!@stream_socket_enable_crypto($sock, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) {
|
||
$this->lastError = 'STARTTLS 加密协商失败';
|
||
fclose($sock);
|
||
return false;
|
||
}
|
||
if ($this->talk($sock, 'EHLO ' . $hostHeader, [250]) === false) {
|
||
fclose($sock);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// AUTH LOGIN
|
||
if ($this->talk($sock, 'AUTH LOGIN', [334]) === false) {
|
||
fclose($sock);
|
||
return false;
|
||
}
|
||
if ($this->talk($sock, base64_encode($this->user), [334]) === false) {
|
||
fclose($sock);
|
||
return false;
|
||
}
|
||
if ($this->talk($sock, base64_encode($this->pass), [235]) === false) {
|
||
fclose($sock);
|
||
return false;
|
||
}
|
||
|
||
// 信封
|
||
if ($this->talk($sock, 'MAIL FROM:<' . $fromEmail . '>', [250]) === false) {
|
||
fclose($sock);
|
||
return false;
|
||
}
|
||
$recipients = array_filter(array_map('trim', explode(',', $to)));
|
||
foreach ($recipients as $rcpt) {
|
||
$this->talk($sock, 'RCPT TO:<' . $rcpt . '>', [250, 251]);
|
||
}
|
||
|
||
// 信体
|
||
if ($this->talk($sock, 'DATA', [354]) === false) {
|
||
fclose($sock);
|
||
return false;
|
||
}
|
||
$data = $this->buildData($to, $subject, $bodyHtml, $fromEmail, $fromName);
|
||
fwrite($sock, $data);
|
||
if ($this->talk($sock, '.', [250]) === false) {
|
||
fclose($sock);
|
||
return false;
|
||
}
|
||
$this->talk($sock, 'QUIT', [221]);
|
||
fclose($sock);
|
||
return true;
|
||
}
|
||
|
||
private function buildData($to, $subject, $body, $fromEmail, $fromName)
|
||
{
|
||
$eol = "\r\n";
|
||
$headers = [];
|
||
$headers[] = 'From: ' . $this->encodeHeader($fromName) . ' <' . $fromEmail . '>';
|
||
$headers[] = 'To: ' . $to;
|
||
$headers[] = 'Subject: ' . $this->encodeHeader($subject);
|
||
$headers[] = 'Date: ' . date('r');
|
||
$headers[] = 'MIME-Version: 1.0';
|
||
$headers[] = 'Content-Type: text/html; charset=UTF-8';
|
||
$headers[] = 'Content-Transfer-Encoding: base64';
|
||
$msg = implode($eol, $headers) . $eol . $eol;
|
||
$msg .= chunk_split(base64_encode($body), 76, $eol);
|
||
return $msg;
|
||
}
|
||
|
||
private function encodeHeader($s)
|
||
{
|
||
if (preg_match('/[^\x00-\x7F]/', (string) $s)) {
|
||
return '=?UTF-8?B?' . base64_encode($s) . '?=';
|
||
}
|
||
return $s;
|
||
}
|
||
|
||
/**
|
||
* 与服务器交互:发送命令并读取响应。
|
||
* @param resource $sock
|
||
* @param string|null $cmd 为 null 时仅读取(用于读取初始标语)
|
||
* @param array $expectCodes 期望的状态码数组
|
||
* @return string|false
|
||
*/
|
||
private function talk($sock, $cmd, $expectCodes)
|
||
{
|
||
if ($cmd !== null) {
|
||
fwrite($sock, $cmd . "\r\n");
|
||
$this->log[] = 'C: ' . $cmd;
|
||
}
|
||
$resp = '';
|
||
while (true) {
|
||
$line = @fgets($sock, 515);
|
||
if ($line === false) {
|
||
break;
|
||
}
|
||
$resp .= $line;
|
||
// 末位第 4 个字符为空格表示响应结束(多行响应以 "- " 续接)
|
||
if (isset($line[3]) && $line[3] === ' ') {
|
||
break;
|
||
}
|
||
}
|
||
$this->log[] = 'S: ' . trim($resp);
|
||
$code = (int) substr($resp, 0, 3);
|
||
if (!in_array($code, (array) $expectCodes, true)) {
|
||
$this->lastError = '服务器返回错误:' . trim($resp);
|
||
return false;
|
||
}
|
||
return $resp;
|
||
}
|
||
}
|