257 lines
13 KiB
PHP
257 lines
13 KiB
PHP
<?php
|
||||
|
|
/**
|
|||
|
|
* 极简 SMTP 客户端(无第三方依赖)+ 回复通知逻辑
|
|||
|
|
*/
|
|||
|
|
if (!defined('FB_ROOT')) { http_response_code(403); exit('forbidden'); }
|
|||
|
|
|
|||
|
|
/** 读取一行 SMTP 响应(支持多行 250-... ) */
|
|||
|
|
function fb_smtp_read($fp) {
|
|||
|
|
$data = '';
|
|||
|
|
while (($line = fgets($fp, 1024)) !== false) {
|
|||
|
|
$data .= $line;
|
|||
|
|
if (strlen($line) >= 4 && $line[3] === ' ') { break; }
|
|||
|
|
}
|
|||
|
|
return $data;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 发送命令并校验响应码 */
|
|||
|
|
function fb_smtp_cmd($fp, $cmd, $codes, &$err, $hideLog = false) {
|
|||
|
|
if ($cmd !== null && $cmd !== '') {
|
|||
|
|
fwrite($fp, $cmd . "\r\n");
|
|||
|
|
}
|
|||
|
|
$resp = fb_smtp_read($fp);
|
|||
|
|
$code = (int)substr(ltrim($resp), 0, 3);
|
|||
|
|
$want = is_array($codes) ? $codes : [(int)$codes];
|
|||
|
|
if (!in_array($code, $want, true)) {
|
|||
|
|
$err = ($hideLog ? '(命令内容已隐藏) ' : '') . trim($resp);
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 邮件头编码(UTF-8 -> =?UTF-8?B?...?=) */
|
|||
|
|
function fb_mail_header($s) {
|
|||
|
|
return '=?UTF-8?B?' . base64_encode((string)$s) . '?=';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 发送一封 HTML 邮件
|
|||
|
|
* @return array [bool ok, string err]
|
|||
|
|
*/
|
|||
|
|
function fb_smtp_send(array $s, $to, $subject, $html) {
|
|||
|
|
$err = '';
|
|||
|
|
$host = (string)$s['host'];
|
|||
|
|
$port = (int)$s['port'];
|
|||
|
|
$secure = strtolower(isset($s['secure']) ? (string)$s['secure'] : '');
|
|||
|
|
$timeout= (int)(isset($s['timeout']) ? $s['timeout'] : 15);
|
|||
|
|
$verify = !isset($s['verify_ssl']) || $s['verify_ssl'];
|
|||
|
|
|
|||
|
|
if ($host === '' || $port <= 0) { return [false, 'SMTP 参数不完整']; }
|
|||
|
|
if (!filter_var($to, FILTER_VALIDATE_EMAIL)) { return [false, '收件地址非法']; }
|
|||
|
|
|
|||
|
|
$sslOpt = $verify
|
|||
|
|
? ['verify_peer' => true, 'verify_peer_name' => true]
|
|||
|
|
: ['verify_peer' => false, 'verify_peer_name' => false, 'allow_self_signed' => true];
|
|||
|
|
$ctx = stream_context_create(['ssl' => $sslOpt]);
|
|||
|
|
$remote = (($secure === 'ssl' || $secure === 'smtps') ? 'ssl://' : '') . $host . ':' . $port;
|
|||
|
|
|
|||
|
|
$fp = @stream_socket_client($remote, $eno, $estr, $timeout, STREAM_CLIENT_CONNECT, $ctx);
|
|||
|
|
if (!$fp) { return [false, '连接失败: ' . $estr . ' (' . $eno . ')']; }
|
|||
|
|
stream_set_timeout($fp, $timeout);
|
|||
|
|
|
|||
|
|
$hello = isset($_SERVER['HTTP_HOST']) ? (string)$_SERVER['HTTP_HOST'] : 'localhost';
|
|||
|
|
$hello = preg_replace('/[^A-Za-z0-9\.\-]/', '', $hello);
|
|||
|
|
if ($hello === '') { $hello = 'localhost'; }
|
|||
|
|
|
|||
|
|
if (!fb_smtp_cmd($fp, null, 220, $err)) { fclose($fp); return [false, '服务器问候失败: ' . $err]; }
|
|||
|
|
if (!fb_smtp_cmd($fp, 'EHLO ' . $hello, 250, $err)) { fclose($fp); return [false, 'EHLO 失败: ' . $err]; }
|
|||
|
|
|
|||
|
|
if ($secure === 'tls' || $secure === 'starttls') {
|
|||
|
|
if (!fb_smtp_cmd($fp, 'STARTTLS', 220, $err)) { fclose($fp); return [false, 'STARTTLS 失败: ' . $err]; }
|
|||
|
|
if (!@stream_socket_enable_crypto($fp, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) {
|
|||
|
|
fclose($fp); return [false, 'TLS 握手失败'];
|
|||
|
|
}
|
|||
|
|
if (!fb_smtp_cmd($fp, 'EHLO ' . $hello, 250, $err)) { fclose($fp); return [false, 'EHLO(TLS) 失败: ' . $err]; }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
$user = (string)(isset($s['user']) ? $s['user'] : '');
|
|||
|
|
if ($user !== '') {
|
|||
|
|
if (!fb_smtp_cmd($fp, 'AUTH LOGIN', 334, $err)) { fclose($fp); return [false, 'AUTH LOGIN 不受支持: ' . $err]; }
|
|||
|
|
if (!fb_smtp_cmd($fp, base64_encode($user), 334, $err, true)) { fclose($fp); return [false, '用户名未被接受: ' . $err]; }
|
|||
|
|
if (!fb_smtp_cmd($fp, base64_encode((string)$s['pass']), 235, $err, true)) { fclose($fp); return [false, '认证失败(账号 / 密码 / 授权码): ' . $err]; }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
$from = !empty($s['from']) ? (string)$s['from'] : $user;
|
|||
|
|
if (!filter_var($from, FILTER_VALIDATE_EMAIL)) { fclose($fp); return [false, '发件地址非法']; }
|
|||
|
|
|
|||
|
|
if (!fb_smtp_cmd($fp, 'MAIL FROM:<' . $from . '>', 250, $err)) { fclose($fp); return [false, 'MAIL FROM 失败: ' . $err]; }
|
|||
|
|
if (!fb_smtp_cmd($fp, 'RCPT TO:<' . $to . '>', [250, 251], $err)) { fclose($fp); return [false, 'RCPT TO 失败: ' . $err]; }
|
|||
|
|
if (!fb_smtp_cmd($fp, 'DATA', 354, $err)) { fclose($fp); return [false, 'DATA 失败: ' . $err]; }
|
|||
|
|
|
|||
|
|
$fromName = isset($s['from_name']) ? (string)$s['from_name'] : '';
|
|||
|
|
$headers = [];
|
|||
|
|
$headers[] = 'Date: ' . date('r');
|
|||
|
|
$headers[] = 'From: ' . ($fromName !== '' ? fb_mail_header($fromName) . ' ' : '') . '<' . $from . '>';
|
|||
|
|
$headers[] = 'To: <' . $to . '>';
|
|||
|
|
$headers[] = 'Subject: ' . fb_mail_header($subject);
|
|||
|
|
$headers[] = 'MIME-Version: 1.0';
|
|||
|
|
$headers[] = 'Content-Type: text/html; charset=UTF-8';
|
|||
|
|
$headers[] = 'Content-Transfer-Encoding: base64';
|
|||
|
|
$headers[] = 'Message-ID: <' . bin2hex(random_bytes(12)) . '@' . $hello . '>';
|
|||
|
|
$headers[] = 'X-Mailer: FeedLite';
|
|||
|
|
|
|||
|
|
// 正文用 base64,天然不含以 "." 开头的行,因此无需 dot-stuffing
|
|||
|
|
$body = chunk_split(base64_encode($html), 76, "\r\n");
|
|||
|
|
$data = rtrim(implode("\r\n", $headers) . "\r\n\r\n" . $body, "\r\n");
|
|||
|
|
|
|||
|
|
if (fwrite($fp, $data . "\r\n.\r\n") === false) { fclose($fp); return [false, '写入邮件正文失败']; }
|
|||
|
|
if (!fb_smtp_cmd($fp, null, 250, $err)) { fclose($fp); return [false, '投递失败: ' . $err]; }
|
|||
|
|
|
|||
|
|
@fb_smtp_cmd($fp, 'QUIT', 221, $e2);
|
|||
|
|
fclose($fp);
|
|||
|
|
return [true, ''];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 渲染通知邮件正文 */
|
|||
|
|
function fb_notify_html($thread, $reply, $isAdmin) {
|
|||
|
|
$base = fb_base_url();
|
|||
|
|
$url = $base . '/thread.php?id=' . (int)$thread['id'];
|
|||
|
|
$site = (string)fb_cfg('site_name');
|
|||
|
|
$name = (string)$thread['author_name'];
|
|||
|
|
$title = (string)$thread['title'];
|
|||
|
|
$rname = $isAdmin ? '官方' : (string)$reply['author_name'];
|
|||
|
|
$rbody = nl2br(htmlspecialchars((string)$reply['body'], ENT_QUOTES, 'UTF-8'));
|
|||
|
|
$safeName = htmlspecialchars($name, ENT_QUOTES, 'UTF-8');
|
|||
|
|
$safeTitle = htmlspecialchars($title, ENT_QUOTES, 'UTF-8');
|
|||
|
|
$safeRName = htmlspecialchars($rname, ENT_QUOTES, 'UTF-8');
|
|||
|
|
|
|||
|
|
return '<div style="font-family:-apple-system,Segoe UI,Microsoft YaHei,sans-serif;font-size:15px;line-height:1.7;color:#1f2329">'
|
|||
|
|
. '<p>' . $safeName . ',你好:</p>'
|
|||
|
|
. '<p>你在「' . htmlspecialchars($site, ENT_QUOTES, 'UTF-8') . '」提交的问题 <strong>《' . $safeTitle . '》</strong> 有了新的回复:</p>'
|
|||
|
|
. '<blockquote style="margin:12px 0;padding:12px 16px;border-left:3px solid #0a6cf5;background:#f5f8ff;border-radius:6px">'
|
|||
|
|
. '<div style="color:#6b7280;font-size:13px;margin-bottom:6px">' . $safeRName . ' 回复:</div>'
|
|||
|
|
. $rbody
|
|||
|
|
. '</blockquote>'
|
|||
|
|
. '<p><a href="' . htmlspecialchars($url, ENT_QUOTES, 'UTF-8') . '" style="display:inline-block;background:#0a6cf5;color:#fff;text-decoration:none;padding:9px 18px;border-radius:8px">查看并回复</a></p>'
|
|||
|
|
. '<p style="color:#6b7280;font-size:13px">若按钮无法点击,请复制链接到浏览器打开:<br>' . htmlspecialchars($url, ENT_QUOTES, 'UTF-8') . '</p>'
|
|||
|
|
. '<hr style="border:none;border-top:1px solid #e5e7eb;margin:18px 0">'
|
|||
|
|
. '<p style="color:#9aa2ad;font-size:12px">此邮件由系统自动发送,请勿直接回复。</p>'
|
|||
|
|
. '</div>';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 有人回复后,给发帖人发送通知邮件
|
|||
|
|
* @return string 空串=未发送/已发送成功;非空=失败原因
|
|||
|
|
*/
|
|||
|
|
function fb_notify_reply(PDO $pdo, array $thread, array $reply, $isAdmin) {
|
|||
|
|
$cfg = fb_cfg();
|
|||
|
|
$nt = isset($cfg['notify']) ? (array)$cfg['notify'] : [];
|
|||
|
|
if (empty($nt['enabled'])) { return ''; }
|
|||
|
|
if (empty($nt['on_every_reply']) && !$isAdmin) { return ''; }
|
|||
|
|
|
|||
|
|
$to = trim((string)$thread['author_email']);
|
|||
|
|
if ($to === '' || !filter_var($to, FILTER_VALIDATE_EMAIL)) { return ''; }
|
|||
|
|
|
|||
|
|
// 自己回复自己:不发
|
|||
|
|
if (!$isAdmin && !empty($reply['author_email'])
|
|||
|
|
&& strcasecmp(trim((string)$reply['author_email']), $to) === 0) {
|
|||
|
|
return '';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 节流:同一帖同一收件人 N 秒内不重复发
|
|||
|
|
$cd = (int)(isset($nt['cooldown_seconds']) ? $nt['cooldown_seconds'] : 0);
|
|||
|
|
if ($cd > 0) {
|
|||
|
|
$st = $pdo->prepare('SELECT created_at FROM notify_log WHERE thread_id=? AND to_email=? ORDER BY id DESC LIMIT 1');
|
|||
|
|
$st->execute([(int)$thread['id'], $to]);
|
|||
|
|
$last = $st->fetchColumn();
|
|||
|
|
if ($last !== false && (time() - (int)$last) < $cd) { return ''; }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
$smtp = isset($cfg['smtp']) ? (array)$cfg['smtp'] : [];
|
|||
|
|
if (empty($smtp['enabled'])) {
|
|||
|
|
fb_notify_log($pdo, $thread, $reply, $to, 0, 'SMTP 未启用');
|
|||
|
|
return 'SMTP 未启用';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
$subject = '【' . $cfg['site_name'] . '】有人回复了你的问题:' . fb_excerpt($thread['title'], 40);
|
|||
|
|
$html = fb_notify_html($thread, $reply, $isAdmin);
|
|||
|
|
|
|||
|
|
list($ok, $err) = fb_smtp_send($smtp, $to, $subject, $html);
|
|||
|
|
fb_notify_log($pdo, $thread, $reply, $to, $ok ? 1 : 0, $ok ? null : $err);
|
|||
|
|
return $ok ? '' : $err;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 记录通知尝试 */
|
|||
|
|
function fb_notify_log(PDO $pdo, array $thread, array $reply, $to, $ok, $err) {
|
|||
|
|
try {
|
|||
|
|
$st = $pdo->prepare('INSERT INTO notify_log(thread_id, reply_id, to_email, created_at, ok, err) VALUES(?,?,?,?,?,?)');
|
|||
|
|
$st->execute([(int)$thread['id'], (int)$reply['id'], $to, time(), $ok ? 1 : 0, $err]);
|
|||
|
|
} catch (Exception $ex) {
|
|||
|
|
// 静默
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ---------------- 关闭通知(判定"不是问题",写给提问人的说明邮件) ---------------- */
|
|||
|
|
|
|||
|
|
/** 关闭通知的邮件正文 */
|
|||
|
|
function fb_notify_closed_html(array $thread, string $note) {
|
|||
|
|
$site = (string)fb_cfg('site_name');
|
|||
|
|
$name = htmlspecialchars((string)$thread['author_name'], ENT_QUOTES, 'UTF-8');
|
|||
|
|
$title = htmlspecialchars((string)$thread['title'], ENT_QUOTES, 'UTF-8');
|
|||
|
|
$body = nl2br(htmlspecialchars((string)$thread['body'], ENT_QUOTES, 'UTF-8'));
|
|||
|
|
$noteH = nl2br(htmlspecialchars($note, ENT_QUOTES, 'UTF-8'));
|
|||
|
|
$mail = htmlspecialchars((string)fb_cfg('admin_email'), ENT_QUOTES, 'UTF-8');
|
|||
|
|
|
|||
|
|
return '<div style="font-family:-apple-system,Segoe UI,Microsoft YaHei,sans-serif;font-size:15px;line-height:1.7;color:#1f2329">'
|
|||
|
|
. '<p>' . $name . ',你好:</p>'
|
|||
|
|
. '<p>感谢你反馈 <strong>《' . $title . '》</strong>。我们已经核实过,'
|
|||
|
|
. '<strong>该情况并不是应用的问题</strong>,因此这条反馈已被关闭(不会公开显示)。</p>'
|
|||
|
|
. '<div style="margin:14px 0;padding:12px 16px;border-left:3px solid #0a6cf5;background:#f5f8ff;border-radius:6px">'
|
|||
|
|
. '<div style="color:#6b7280;font-size:13px;margin-bottom:6px">官方说明:</div>'
|
|||
|
|
. $noteH
|
|||
|
|
. '</div>'
|
|||
|
|
. '<p style="color:#6b7280;font-size:13px">你当时反馈的内容:</p>'
|
|||
|
|
. '<div style="margin:8px 0 16px;padding:10px 14px;background:#f7f8fa;border-radius:6px;color:#4b5563;font-size:14px">'
|
|||
|
|
. $body
|
|||
|
|
. '</div>'
|
|||
|
|
. ($mail !== '' ? '<p>如果上面的说明没能解决你的疑问,可以直接回复本邮件,我们会继续跟进;'
|
|||
|
|
. '也可以发邮件到 ' . $mail . ' 补充说明。</p>' : '<p>如果上面的说明没能解决你的疑问,可以直接回复本邮件,我们会继续跟进。</p>')
|
|||
|
|
. '<hr style="border:none;border-top:1px solid #e5e7eb;margin:18px 0">'
|
|||
|
|
. '<p style="color:#9aa2ad;font-size:12px">此邮件由「' . htmlspecialchars($site, ENT_QUOTES, 'UTF-8') . '」自动发送。</p>'
|
|||
|
|
. '</div>';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 关闭问题(判定"不是问题")时,把说明邮件发给提问人
|
|||
|
|
* @return array [bool 是否发送成功, string 失败原因(空串=成功或未启用)]
|
|||
|
|
*/
|
|||
|
|
function fb_notify_closed(PDO $pdo, array $thread, string $note) {
|
|||
|
|
$cfg = fb_cfg();
|
|||
|
|
$sm = isset($cfg['smtp']) ? (array)$cfg['smtp'] : [];
|
|||
|
|
if (empty($sm['enabled'])) {
|
|||
|
|
// 仅记录,不算失败
|
|||
|
|
try {
|
|||
|
|
$st = $pdo->prepare('INSERT INTO notify_log(thread_id, reply_id, to_email, created_at, ok, err) VALUES(?,?,?,?,?,?)');
|
|||
|
|
$st->execute([(int)$thread['id'], 0, (string)$thread['author_email'], time(), 0, 'SMTP 未启用,关闭通知未发送']);
|
|||
|
|
} catch (Exception $ex) { /* 静默 */ }
|
|||
|
|
return [false, 'SMTP 未启用,邮件未发送(已在配置里关闭)'];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
$to = (string)$thread['author_email'];
|
|||
|
|
if (!filter_var($to, FILTER_VALIDATE_EMAIL)) {
|
|||
|
|
return [false, '提问人邮箱不可用,未发送'];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
$tpl = (string)fb_cfg('moderation.close_mail_subject', '关于你反馈的问题《%s》');
|
|||
|
|
$subject = sprintf($tpl, (string)$thread['title']);
|
|||
|
|
$html = fb_notify_closed_html($thread, $note);
|
|||
|
|
|
|||
|
|
list($ok, $err) = fb_smtp_send($sm, $to, $subject, $html);
|
|||
|
|
try {
|
|||
|
|
$st = $pdo->prepare('INSERT INTO notify_log(thread_id, reply_id, to_email, created_at, ok, err) VALUES(?,?,?,?,?,?)');
|
|||
|
|
$st->execute([(int)$thread['id'], 0, $to, time(), $ok ? 1 : 0, $ok ? null : $err]);
|
|||
|
|
} catch (Exception $ex) { /* 静默 */ }
|
|||
|
|
|
|||
|
|
return [$ok, $ok ? '' : $err];
|
|||
|
|
}
|