Files
freeshop/includes/mailer.php
T
2026-08-14 10:55:06 +08:00

198 lines
6.2 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
// 纯 PHP socket 实现的 SMTP 发信类,无需 Composer / PHPMailer。
// 支持 SSL465)与 STARTTLS587)以及无加密,使用 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;
}
}