Files
FeedLite/lib/mailer.php
T
laoyang 4033170ad7 修复:跨 tab 保存互相清空配置;补齐全流程邮件通知与统一模板 (#2)
## 一、用户报「我明明配过 SMTP,怎么全丢了、信也发不出」

**没有重置数据库**(installed_at 仍是 11-01 15:14,oss.*/site_name/admin.username 全在)。

真因是我上一轮「邮件拆独立 tab」挖的坑:站点设置与邮件通知**共用 act=settings**,
而保存函数把 smtp 字段无条件写库:

    foreach ($smtpText as $k) {
        fb_set_setting($k, trim((string)$post($k, \x27\x27)));   // 没提交 → 空串 → 覆盖
    }

在站点设置页保存附件设置时,该表单没有 smtp_* 字段,$post() 取到空串,
于是 host/user/from 被清成空。smtp.pass 因为单独做了「留空=不修改」而幸存,
所以现场是 pass 有值、其余全空 —— 这个组合本身就是 bug 的指纹。

修法:新增 $has() 判断「本次请求是否真的提交了该字段」,没提交就跳过。
按 tab 分两组保护:
- $isSiteForm:站点信息/页脚/附件/OSS,逐字段 has() 双保险
- $isMailForm:SMTP/通知开关,整段 + 逐字段

checkbox 尤其要保护:未提交会被当成「取消勾选」,
在站点设置页存一次就会把「有人回复时通知」全关掉。

## 二、notify.admin_new_thread 是假的(用户说「提交了反馈收不到邮件」)

这个设置项:白名单里有、后台有勾选框,**但没有任何代码实现它**。
grep 只命中三处(白名单/保存/界面),没有一处真正发信。

已在 new.php 提交成功后实现:查新帖 → 取所属软件名 → fb_notify_event(\x27new\x27)。

## 三、补齐全部事件,统一入口

此前只有 reply 和 close 两个事件,且两套模板风格不一致。
新增 fb_notify_event() 统一入口,处理「开关→收件人→节流→发信→日志」。

覆盖 8 类事件:reply / status / approve / hide / close / new。
- status 按状态给不同文案(待处理/处理中/已解决/关闭/不予考虑),
  「不予考虑」语气克制、必须带理由并留申诉入口
- approve / hide 原先完全不发信,现在补上
- 状态没变化时不发信(不打扰用户)

## 四、邮件模板重做

抽出 fb_mail_layout() 统一外壳,所有事件共用:
- table + 内联样式(Outlook/QQ邮箱/Gmail 兼容)
- 600px 卡片、顶部主色条、圆角徽标、字段表、引用块、callout、CTA 按钮
- 响应式(已验 375px 无溢出、无裁切)
- preheader 摘要、纯文本兜底链接、收件人提示

## 五、后台状态按钮(用户要求)

问题详情页顶部一排按钮,点一下立刻生效并自动发信:
「通过并公开」「处理中」「已解决」「不予考虑」「撤下」
- 当前状态对应的按钮置灰
- 「不予考虑」展开内联面板填理由(不用 window.prompt)
- 新增 setstatus 动作;「通过并公开」会同时置可见

## 六、notify_log 加 reason 列

原表只有 reply_id,无法区分事件类型。加 reason 并幂等迁移;
老库 reply_id 是 NOT NULL,非回复事件用 0 表示。

## 验证

邮件专项 73/73:
- 8 类事件模板全部完整 HTML、table 布局、徽标、页脚、无 script
- 8 个主题互不相同;状态徽标文案正确
- 总开关关闭时 admin_new_thread 仍可独立生效
- 节流只作用于 reply/status,close 不受影响
- SMTP 未启用时留失败日志(含 reason 与原因)
- C1-C5:站点页保存后 smtp.host/user/notify.enabled 均未被清空;
  邮件页保存后 oss.bucket 未被清空

反向验证:完整回退两层保护后 C1/C2/C3 全部 FAIL
(host 从 smtp.qiye.aliyun.com 变成空、notify.enabled 从 1 变 0),
证明测试能真正抓到该 bug,不是假通过。

回归:全新安装 33/33、老基线 25/25、附件 22/22。
2026-11-02 22:57:00 +08:00

944 lines
44 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
/**
* 极简 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, ''];
}
/** 渲染通知邮件正文 */
/**
* 统一的邮件模板引擎(工单 #2)
*
* 设计要点:
* 1. **表格布局 + 内联样式**。邮件客户端(Outlook / QQ 邮箱 / Gmail)对
* flex/grid/外部 CSS 的支持极不一致,只有 table + inline style 能稳定渲染。
* 2. **统一外壳**。此前 `fb_notify_html()` 和 `fb_notify_closed_html()` 各写一套
* 风格,颜色、间距、页脚都不一样。现在全部走 fb_mail_layout()。
* 3. **响应式**。桌面 600px,手机端靠 max-width:100% 自适应。
* 4. **暗色安全**。不用纯白背景配纯黑字,留出足够的对比度余量。
*
* 调用方式:
* echo fb_mail_layout([
* 'preheader' => '预览摘要(收件箱列表里显示的那行灰字)',
* 'badge' => ['text' => '处理中', 'color' => '#0a6cf5'],
* 'title' => '你的反馈已被受理',
* 'intro' => '老杨,你好:',
* 'blocks' => [ ... ], // 内容块,见下
* 'button' => ['text' => '查看详情', 'url' => 'https://...'],
* 'footer' => '此邮件由系统自动发送。',
* ]);
*/
if (!function_exists('fb_mail_esc')) {
/** 统一的 HTML 转义(模板里到处要用,短名字方便读) */
function fb_mail_esc($s) {
return htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8');
}
}
if (!function_exists('fb_mail_brand')) {
/** 取站点主色,用于邮件头与按钮。跟随「软件主题色」体系,但邮件里只用主色与浅底 */
function fb_mail_brand() {
$c = fb_color_norm((string)fb_cfg('mail_brand_color', ''));
if ($c === '') { $c = '#0057B8'; }
return $c;
}
}
if (!function_exists('fb_mail_block')) {
/**
* 渲染一个内容块。
*
* 支持类型:
* quote —— 引用块(回复内容、官方说明),左侧色条 + 浅底
* field —— 一组「标签 / 值」行(状态变更、时间等元信息)
* text —— 普通段落
* callout —— 带图标的提示条(成功=绿、提醒=黄、信息=蓝)
* raw —— 已转义好的 HTML,原样输出(谨慎使用)
*/
function fb_mail_block(array $b) {
$type = isset($b['type']) ? (string)$b['type'] : 'text';
$brand = fb_mail_brand();
if ($type === 'text') {
return '<p style="margin:0 0 14px;font-size:15px;line-height:1.75;color:#333b46">'
. (isset($b['html']) ? $b['html'] : fb_mail_esc(isset($b['text']) ? $b['text'] : ''))
. '</p>';
}
if ($type === 'quote') {
$who = isset($b['who']) ? (string)$b['who'] : '';
$body = nl2br(fb_mail_esc(isset($b['text']) ? $b['text'] : ''));
return '<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%"'
. ' style="margin:0 0 16px;border-collapse:separate">'
. '<tr><td style="padding:14px 18px;background:#f6f8fb;'
. 'border-left:3px solid ' . fb_mail_esc($brand) . ';border-radius:6px">'
. ($who !== ''
? '<div style="font-size:12.5px;color:#8a93a0;margin-bottom:8px">'
. fb_mail_esc($who) . '</div>' : '')
. '<div style="font-size:14.5px;line-height:1.75;color:#2f3742">' . $body . '</div>'
. '</td></tr></table>';
}
if ($type === 'field') {
$rows = isset($b['rows']) ? (array)$b['rows'] : [];
$out = '<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%"'
. ' style="margin:0 0 18px;border-collapse:collapse;'
. 'background:#fafbfc;border:1px solid #eceff3;border-radius:8px">';
$i = 0;
foreach ($rows as $label => $value) {
$pad = ($i === 0) ? '12px 16px 6px' : '6px 16px';
if ($i === count($rows) - 1) { $pad = '6px 16px 12px'; }
if (count($rows) === 1) { $pad = '12px 16px'; }
$out .= '<tr>'
. '<td style="padding:' . $pad . ';font-size:13px;color:#8a93a0;'
. 'width:88px;vertical-align:top;white-space:nowrap">' . fb_mail_esc($label) . '</td>'
. '<td style="padding:' . $pad . ';font-size:14px;color:#2f3742;'
. 'line-height:1.6;vertical-align:top">' . fb_mail_esc($value) . '</td>'
. '</tr>';
$i++;
}
return $out . '</table>';
}
if ($type === 'callout') {
$tone = isset($b['tone']) ? (string)$b['tone'] : 'info';
$map = [
'ok' => ['bg' => '#f0f9f2', 'line' => '#b7dfc2', 'icon' => '✓', 'color' => '#1e7b3c'],
'warn' => ['bg' => '#fff9ec', 'line' => '#f0dfae', 'icon' => '!', 'color' => '#8a6a15'],
'info' => ['bg' => '#f2f7ff', 'line' => '#c8dbf7', 'icon' => 'i', 'color' => '#1a5fb4'],
'muted' => ['bg' => '#f7f8fa', 'line' => '#e6e9ee', 'icon' => '·', 'color' => '#5c6572'],
];
$m = isset($map[$tone]) ? $map[$tone] : $map['info'];
$body = isset($b['html']) ? $b['html']
: nl2br(fb_mail_esc(isset($b['text']) ? $b['text'] : ''));
return '<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%"'
. ' style="margin:0 0 18px;border-collapse:separate">'
. '<tr><td style="padding:12px 16px;background:' . $m['bg'] . ';'
. 'border:1px solid ' . $m['line'] . ';border-radius:8px;font-size:14px;'
. 'line-height:1.7;color:' . $m['color'] . '">' . $body . '</td></tr></table>';
}
if ($type === 'raw') {
return isset($b['html']) ? (string)$b['html'] : '';
}
return '';
}
}
if (!function_exists('fb_mail_layout')) {
/**
* 邮件统一外壳。所有通知都套这一个,保证视觉一致。
*
* @param array $o 见文件头注释
* @return string 完整 HTML(可直接作为 text/html 正文)
*/
function fb_mail_layout(array $o) {
$brand = fb_mail_brand();
$site = (string)fb_cfg('site_name', '轻反馈');
$base = fb_base_url();
$preheader = isset($o['preheader']) ? (string)$o['preheader'] : '';
$badge = isset($o['badge']) ? (array)$o['badge'] : null;
$title = isset($o['title']) ? (string)$o['title'] : '';
$intro = isset($o['intro']) ? (string)$o['intro'] : '';
$blocks = isset($o['blocks']) ? (array)$o['blocks'] : [];
$button = isset($o['button']) ? (array)$o['button'] : null;
$link = isset($o['link']) ? (string)$o['link'] : '';
$footer = isset($o['footer']) ? (string)$o['footer']
: '此邮件由系统自动发送,请勿直接回复。';
$recipient = isset($o['recipient']) ? (string)$o['recipient'] : '';
// ---- 内容块 ----
$body = '';
foreach ($blocks as $b) {
if (is_array($b)) { $body .= fb_mail_block($b); }
}
// ---- 徽标 ----
$badgeHtml = '';
if ($badge && !empty($badge['text'])) {
$bc = isset($badge['color']) ? (string)$badge['color'] : $brand;
$badgeHtml = '<div style="margin:0 0 10px">'
. '<span style="display:inline-block;padding:3px 11px;border-radius:99px;'
. 'background:' . fb_mail_esc($bc) . ';color:#ffffff;font-size:12px;'
. 'font-weight:600;letter-spacing:.3px">' . fb_mail_esc($badge['text']) . '</span>'
. '</div>';
}
// ---- 按钮 ----
$btnHtml = '';
if ($button && !empty($button['url']) && !empty($button['text'])) {
$url = (string)$button['url'];
$btnHtml = '<table role="presentation" cellpadding="0" cellspacing="0" border="0"'
. ' style="margin:6px 0 20px"><tr>'
. '<td style="background:' . fb_mail_esc($brand) . ';border-radius:8px">'
. '<a href="' . fb_mail_esc($url) . '" target="_blank"'
. ' style="display:inline-block;padding:12px 26px;font-size:15px;font-weight:600;'
. 'color:#ffffff;text-decoration:none;border-radius:8px">'
. fb_mail_esc($button['text']) . '</a></td></tr></table>';
}
// ---- 纯文本兜底链接 ----
$linkHtml = '';
if ($link !== '') {
$linkHtml = '<p style="margin:0 0 16px;font-size:12.5px;line-height:1.6;color:#9aa2ad">'
. '如果按钮无法点击,请复制下面的链接到浏览器打开:<br>'
. '<span style="color:' . fb_mail_esc($brand) . ';word-break:break-all">'
. fb_mail_esc($link) . '</span></p>';
}
// ---- 收件人问候 ----
$introHtml = '';
if ($intro !== '') {
$introHtml = '<p style="margin:0 0 16px;font-size:15px;line-height:1.75;color:#333b46">'
. fb_mail_esc($intro) . '</p>';
}
return '<!DOCTYPE html>'
. '<html lang="zh-CN"><head><meta charset="UTF-8">'
. '<meta name="viewport" content="width=device-width,initial-scale=1">'
. '<meta name="x-apple-disable-message-reformatting">'
. '<title>' . fb_mail_esc($title) . '</title></head>'
. '<body style="margin:0;padding:0;background:#eef1f5;'
. '-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%">'
// 预处理器:收件箱里显示的那行摘要,兼带隐藏占位防止被正文抢先
. ($preheader !== ''
? '<div style="display:none;font-size:1px;color:#eef1f5;line-height:1px;'
. 'max-height:0;max-width:0;opacity:0;overflow:hidden">'
. fb_mail_esc($preheader) . '</div>' : '')
// 外层留白
. '<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%"'
. ' style="background:#eef1f5"><tr><td align="center" style="padding:28px 14px">'
// 卡片
. '<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="600"'
. ' style="width:100%;max-width:600px;background:#ffffff;border-radius:14px;'
. 'overflow:hidden;box-shadow:0 2px 10px rgba(20,30,50,.06)">'
// 顶部色条
. '<tr><td style="height:4px;background:' . fb_mail_esc($brand) . ';font-size:0;'
. 'line-height:0">&nbsp;</td></tr>'
// 站名
. '<tr><td style="padding:22px 32px 0">'
. '<div style="font-size:13px;font-weight:700;letter-spacing:.6px;'
. 'color:' . fb_mail_esc($brand) . ';text-transform:uppercase">'
. fb_mail_esc($site) . '</div></td></tr>'
// 标题
. '<tr><td style="padding:12px 32px 4px">' . $badgeHtml
. '<h1 style="margin:0;font-size:21px;line-height:1.45;font-weight:700;'
. 'color:#1b2430">' . fb_mail_esc($title) . '</h1></td></tr>'
// 正文
. '<tr><td style="padding:16px 32px 8px">'
. $introHtml . $body . $btnHtml . $linkHtml
. '</td></tr>'
// 页脚
. '<tr><td style="padding:20px 32px 26px;border-top:1px solid #eef0f3">'
. '<p style="margin:0 0 6px;font-size:12.5px;line-height:1.65;color:#9aa2ad">'
. fb_mail_esc($footer) . '</p>'
. ($recipient !== ''
? '<p style="margin:0;font-size:11.5px;line-height:1.6;color:#b3b9c2">'
. '这封邮件发给了 ' . fb_mail_esc($recipient) . '。'
. '如果这不是你,忽略即可。</p>' : '')
. '<p style="margin:8px 0 0;font-size:11.5px;color:#b3b9c2">'
. '<a href="' . fb_mail_esc($base) . '" target="_blank"'
. ' style="color:#9aa2ad;text-decoration:none">'
. fb_mail_esc($base) . '</a></p>'
. '</td></tr>'
. '</table>'
. '</td></tr></table>'
. '</body></html>';
}
}
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) {
// 转调统一入口(工单 #2):旧调用点保持不改,但行为、模板、日志全部统一。
list($ok, $err) = fb_notify_event($pdo, 'reply', $thread, [
'reply' => $reply,
'is_admin' => (bool)$isAdmin,
]);
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) {
// 转调统一入口(工单 #2)
return fb_notify_event($pdo, 'close', $thread, ['note' => $note]);
}
function fb_notify_log2(PDO $pdo, array $thread, $to, $reason, $ok, $err) {
// ⚠️ 老库的 notify_log.reply_id 是 NOT NULL,而「新反馈通知」没有回复 id。
// SQLite 不能 ALTER COLUMN 放宽约束,只能重建表 —— 为了不动用户已有数据,
// 这里用 reply_id=0 表示「非回复事件」,而不是 null。
try {
$pdo->prepare('INSERT INTO notify_log(thread_id, reply_id, to_email, reason, ok, err, created_at) '
. 'VALUES(?,?,?,?,?,?,?)')
->execute([
(int)$thread['id'],
0,
(string)$to,
(string)$reason,
(int)$ok,
$err === null ? null : (string)$err,
time(),
]);
} catch (Throwable $e) {
// reason 列可能不存在(老库未迁移),退化成原结构
try {
$pdo->prepare('INSERT INTO notify_log(thread_id, reply_id, to_email, ok, err, created_at) '
. 'VALUES(?,?,?,?,?,?)')
->execute([(int)$thread['id'], 0, (string)$to, (int)$ok,
$err === null ? null : (string)$err, time()]);
} catch (Throwable $e2) {
// 实在记不了就算了,不能因为记日志失败让用户的请求失败
}
}
}/**
* 各事件的邮件渲染(工单 #2)
*
* 每个事件返回 [主题, HTML]。
* 全部套 fb_mail_layout(),视觉统一;文案按事件差异化。
*/
/**
* 邮件通知事件(工单 #2)
*
* 全部事件:
* reply 有人回复了你的反馈 → 发帖人
* status 处理状态变了 → 发帖人
* approve 审核通过、已公开 → 发帖人
* hide 转为不公开 → 发帖人
* close 判定不是问题,关闭 → 发帖人(带说明)
* new 有新反馈提交 → 管理员
*
* 设计原则:
* 1. **一个总入口**。所有事件都走 fb_notify_event(),统一处理
* 「开关判断 → 收件人解析 → 节流 → 发信 → 记日志」,避免每个事件
* 各写一遍导致行为不一致(此前 close 和 reply 就是两套)。
* 2. **失败必留痕**。任何情况下都写 notify_log,便于后台排查
* 「为什么用户说没收到信」。
* 3. **不抛异常**。发信失败绝不能让用户的操作失败 —— 反馈已经存库了,
* 邮件只是附加动作。
*/
if (!function_exists('fb_notify_reason_label')) {
/** 事件的中文名,用于日志与模板徽标 */
function fb_notify_reason_label($reason) {
$map = [
'reply' => '有新回复',
'status' => '状态更新',
'approve' => '已发布',
'hide' => '转为不公开',
'close' => '已关闭',
'new' => '新反馈',
];
return isset($map[$reason]) ? $map[$reason] : '通知';
}
}
if (!function_exists('fb_notify_enabled_for')) {
/**
* 这个事件在当前配置下要不要发?
*
* 注意区别:
* - notify.enabled 是「给提交人发信」的总开关
* - notify.admin_new_thread 只管「有新反馈时通知管理员」
* 它独立于前者 —— 就算关了给用户的信,管理员也可以想收通知
*/
function fb_notify_enabled_for($reason) {
if ($reason === 'new') {
return (bool)fb_cfg('notify.admin_new_thread', false);
}
return (bool)fb_cfg('notify.enabled', true);
}
}
if (!function_exists('fb_notify_throttled')) {
/**
* 节流:同一帖 + 同一收件人 + 同一原因,在冷却窗口内只发一次。
*
* ⚠️ 只对 reply / status 这类「可能短时间内反复触发」的事件生效;
* close / new 是一次性动作,不节流(否则用户会完全收不到)。
*
* @return bool true = 应该跳过(正在冷却中)
*/
function fb_notify_throttled(PDO $pdo, $threadId, $to, $reason) {
if (!in_array($reason, ['reply', 'status'], true)) { return false; }
$cd = (int)fb_cfg('notify.cooldown_seconds', 300);
if ($cd <= 0) { return false; }
$st = $pdo->prepare(
'SELECT created_at FROM notify_log WHERE thread_id=? AND to_email=? AND reason=? '
. 'ORDER BY id DESC LIMIT 1');
$st->execute([(int)$threadId, (string)$to, (string)$reason]);
$last = $st->fetchColumn();
if ($last === false) { return false; }
return (time() - (int)$last) < $cd;
}
}
if (!function_exists('fb_notify_event')) {
/**
* 发送一个通知事件。
*
* @param PDO $pdo
* @param string $reason reply|status|approve|hide|close|new
* @param array $thread 帖子行
* @param array $extra 附加信息:
* - reply 回复行(reply 事件用)
* - is_admin 是否官方操作
* - old_status / new_status (status 事件用)
* - note 说明文字(close / status 用)
* @return array [bool 是否发出, string 失败原因]
*/
function fb_notify_event(PDO $pdo, $reason, array $thread, array $extra = []) {
$reason = (string)$reason;
// ---- 1. 开关 ----
if (!fb_notify_enabled_for($reason)) {
return [false, '通知未启用'];
}
// ---- 2. 收件人 ----
$isAdminNotice = ($reason === 'new');
if ($isAdminNotice) {
$to = trim((string)fb_cfg('admin_email', ''));
if ($to === '') { $to = trim((string)fb_cfg('admin.email', '')); }
} else {
$to = trim((string)(isset($thread['author_email']) ? $thread['author_email'] : ''));
}
if ($to === '' || !filter_var($to, FILTER_VALIDATE_EMAIL)) {
return [false, '收件邮箱无效'];
}
// ---- 3. 自己通知自己就别发了 ----
if (!$isAdminNotice && !empty($extra['is_admin']) === false
&& !empty($extra['reply']['author_email'])) {
if (strcasecmp(trim((string)$extra['reply']['author_email']), $to) === 0) {
return [false, '自己回复自己,不发'];
}
}
// ---- 4. 节流 ----
if (fb_notify_throttled($pdo, (int)$thread['id'], $to, $reason)) {
return [false, '冷却中,跳过'];
}
// ---- 5. SMTP 就绪吗 ----
$smtp = (array)fb_cfg('smtp', []);
if (empty($smtp['enabled']) || trim((string)$smtp['host']) === '') {
fb_notify_log2($pdo, $thread, $to, $reason, 0, 'SMTP 未启用或未配置服务器');
return [false, 'SMTP 未启用或未配置服务器'];
}
// ---- 6. 组装主题与正文 ----
list($subject, $html) = fb_notify_render($reason, $thread, $extra, $to);
// ---- 7. 发送 ----
list($ok, $err) = fb_smtp_send($smtp, $to, $subject, $html);
fb_notify_log2($pdo, $thread, $to, $reason, $ok ? 1 : 0, $ok ? null : $err);
return [$ok, $ok ? '' : (string)$err];
}
}
if (!function_exists('fb_notify_log2')) {
/**
* 记一条发信日志(带 reason)。
*
* 老表可能没有 reason 列 —— 用 try/catch 包一下,
* 万一列不存在就退化成不带 reason 的插入,绝不让记日志失败影响主流程。
*/
function fb_notify_log2(PDO $pdo, array $thread, $to, $reason, $ok, $err) {
try {
$pdo->prepare('INSERT INTO notify_log(thread_id, reply_id, to_email, reason, ok, err, created_at) '
. 'VALUES(?,?,?,?,?,?,?)')
->execute([
(int)$thread['id'],
null,
(string)$to,
(string)$reason,
(int)$ok,
$err === null ? null : (string)$err,
time(),
]);
} catch (Throwable $e) {
// 列可能不存在(老库未迁移),退化成原结构
try {
$pdo->prepare('INSERT INTO notify_log(thread_id, reply_id, to_email, ok, err, created_at) '
. 'VALUES(?,?,?,?,?,?)')
->execute([(int)$thread['id'], null, (string)$to, (int)$ok,
$err === null ? null : (string)$err, time()]);
} catch (Throwable $e2) {
// 实在记不了就算了,不能因此让用户的请求失败
}
}
}
}
if (!function_exists('fb_notify_thread_url')) {
function fb_notify_thread_url(array $thread) {
return fb_base_url() . '/thread.php?id=' . (int)$thread['id'];
}
}
if (!function_exists('fb_notify_status_meta')) {
/**
* 各处理状态对应的「邮件语气」。
*
* 为什么单独抽出来:用户明确要求不同状态给不同文案 ——
* 「不予考虑」要礼貌说明、不让人觉得被敷衍;
* 「处理中」要让人知道在跟进;
* 「已解决」要引导他来验证。
*/
function fb_notify_status_meta($key) {
$map = [
'open' => [
'badge' => '待处理', 'color' => '#5c6572', 'tone' => 'muted',
'title' => '你的反馈已收到,正在等待处理',
'intro' => '我们已经收到你的反馈,会尽快安排处理。',
'tip' => null,
],
'doing' => [
'badge' => '处理中', 'color' => '#0a6cf5', 'tone' => 'info',
'title' => '你反馈的问题正在处理中',
'intro' => '我们已经开始跟进你反馈的问题,有进展会第一时间告诉你。',
'tip' => '感谢你的耐心等待,暂时不需要你做任何操作。',
],
'resolved' => [
'badge' => '已解决', 'color' => '#1e7b3c', 'tone' => 'ok',
'title' => '你反馈的问题已解决',
'intro' => '好消息,你反馈的问题我们已经处理完成。',
'tip' => '如果问题仍然存在,直接在本页回复即可,我们会继续跟进。',
],
'closed' => [
'badge' => '已关闭', 'color' => '#8a6a15', 'tone' => 'warn',
'title' => '你反馈的问题已关闭',
'intro' => '你反馈的问题已经关闭。',
'tip' => null,
],
'wontfix' => [
'badge' => '不予考虑', 'color' => '#b4553c', 'tone' => 'warn',
'title' => '关于你反馈的问题,我们的处理意见',
'intro' => '感谢你的反馈。经核实,这条反馈我们暂时不会改动,原因如下。',
'tip' => '如果你认为这个判断有误,或者有补充信息,直接在本页回复即可。',
],
];
return isset($map[$key]) ? $map[$key] : $map['open'];
}
}
if (!function_exists('fb_notify_render')) {
/**
* 渲染某个事件的 [主题, HTML]。
*
* @param string $reason
* @param array $thread
* @param array $extra
* @param string $to 收件人(用于页脚「这封邮件发给了…」)
* @return array [string subject, string html]
*/
function fb_notify_render($reason, array $thread, array $extra, $to = '') {
$site = (string)fb_cfg('site_name', '轻反馈');
$url = fb_notify_thread_url($thread);
$title = (string)$thread['title'];
$name = (string)(isset($thread['author_name']) ? $thread['author_name'] : '');
$short = fb_excerpt($title, 42);
$isAdmin = !empty($extra['is_admin']);
$subject = '';
$layout = [];
/* ---------------- 1. 新回复 ---------------- */
if ($reason === 'reply') {
$reply = (array)(isset($extra['reply']) ? $extra['reply'] : []);
$rname = $isAdmin ? '官方' : (string)(isset($reply['author_name']) ? $reply['author_name'] : '');
$rbody = (string)(isset($reply['body']) ? $reply['body'] : '');
$isOff = $isAdmin ? '官方回复' : '新回复';
$subject = '【' . $site . '】' . $isOff . ':' . $short;
$blocks = [
['type' => 'text', 'text' => '你在「' . $site . '」提交的反馈有新的回复。'],
['type' => 'field', 'rows' => ['反馈标题' => $title]],
['type' => 'quote', 'who' => $rname . ' 回复:', 'text' => $rbody],
];
if ($isAdmin) {
$blocks[] = ['type' => 'callout', 'tone' => 'info',
'text' => '这是官方回复。如果还有疑问,直接在本页继续回复即可。'];
}
$layout = [
'preheader' => $rname . ':' . fb_excerpt($rbody, 60),
'badge' => ['text' => $isOff, 'color' => $isAdmin ? '#0057B8' : '#5c6572'],
'title' => $isAdmin ? '官方回复了你的反馈' : '有人回复了你的反馈',
'intro' => ($name !== '' ? $name . ',你好:' : '你好:'),
'blocks' => $blocks,
'button' => ['text' => '查看并回复', 'url' => $url],
'link' => $url,
'recipient' => $to,
];
}
/* ---------------- 2. 状态变更 ---------------- */
elseif ($reason === 'status') {
$newKey = (string)(isset($extra['new_status']) ? $extra['new_status'] : '');
$oldKey = (string)(isset($extra['old_status']) ? $extra['old_status'] : '');
$note = trim((string)(isset($extra['note']) ? $extra['note'] : ''));
$meta = fb_notify_status_meta($newKey);
$label = fb_status_label($newKey);
if ($label === '') { $label = $meta['badge']; }
$subject = '【' . $site . '】' . $short . ' —— ' . $label;
$rows = ['反馈标题' => $title];
if ($oldKey !== '' && $oldKey !== $newKey) {
$oldLabel = fb_status_label($oldKey);
if ($oldLabel !== '') { $rows['原状态'] = $oldLabel; }
}
$rows['当前状态'] = $label;
$blocks = [
['type' => 'text', 'text' => $meta['intro']],
['type' => 'field', 'rows' => $rows],
];
if ($note !== '') {
$blocks[] = ['type' => 'quote', 'who' => '官方说明:', 'text' => $note];
}
if (!empty($meta['tip'])) {
$blocks[] = ['type' => 'callout', 'tone' => $meta['tone'], 'text' => $meta['tip']];
}
$layout = [
'preheader' => '状态更新为「' . $label . '」',
'badge' => ['text' => $meta['badge'], 'color' => $meta['color']],
'title' => $meta['title'],
'intro' => ($name !== '' ? $name . ',你好:' : '你好:'),
'blocks' => $blocks,
'button' => ['text' => '查看详情', 'url' => $url],
'link' => $url,
'recipient' => $to,
];
}
/* ---------------- 3. 审核通过 ---------------- */
elseif ($reason === 'approve') {
$subject = '【' . $site . '】你的反馈已发布:' . $short;
$layout = [
'preheader' => '你的反馈已经公开可见',
'badge' => ['text' => '已发布', 'color' => '#1e7b3c'],
'title' => '你的反馈已通过审核并公开',
'intro' => ($name !== '' ? $name . ',你好:' : '你好:'),
'blocks' => [
['type' => 'text', 'text' => '你提交的反馈已通过审核,现在可以在公开列表中看到了。'],
['type' => 'field', 'rows' => ['反馈标题' => $title]],
['type' => 'callout', 'tone' => 'info',
'text' => '后续的处理进展与回复,我们会通过邮件通知你。'],
],
'button' => ['text' => '查看这条反馈', 'url' => $url],
'link' => $url,
'recipient' => $to,
];
}
/* ---------------- 4. 转为不公开 ---------------- */
elseif ($reason === 'hide') {
$note = trim((string)(isset($extra['note']) ? $extra['note'] : ''));
$subject = '【' . $site . '】你的反馈已转为不公开:' . $short;
$blocks = [
['type' => 'text', 'text' => '你提交的反馈已从公开列表中撤下,不再对其他人可见。'],
['type' => 'field', 'rows' => ['反馈标题' => $title]],
];
if ($note !== '') {
$blocks[] = ['type' => 'quote', 'who' => '官方说明:', 'text' => $note];
}
$blocks[] = ['type' => 'callout', 'tone' => 'muted',
'text' => '这条反馈本身仍然保留,你依然可以通过原链接访问和处理进展。'];
$layout = [
'preheader' => '这条反馈已不对其他人可见',
'badge' => ['text' => '不公开', 'color' => '#5c6572'],
'title' => '你的反馈已转为不公开',
'intro' => ($name !== '' ? $name . ',你好:' : '你好:'),
'blocks' => $blocks,
'button' => ['text' => '查看这条反馈', 'url' => $url],
'link' => $url,
'recipient' => $to,
];
}
/* ---------------- 5. 关闭(判定不是问题) ---------------- */
elseif ($reason === 'close') {
$note = trim((string)(isset($extra['note']) ? $extra['note'] : ''));
$body = (string)$thread['body'];
$mail = trim((string)fb_cfg('admin_email', ''));
$subject = '【' . $site . '】关于你反馈的问题《' . $short . '》';
$blocks = [
['type' => 'text',
'text' => '感谢你的反馈。我们已经核实过,这个情况并不是应用本身的问题,因此这条反馈已关闭(不会公开显示)。'],
['type' => 'field', 'rows' => ['反馈标题' => $title]],
];
if ($note !== '') {
$blocks[] = ['type' => 'quote', 'who' => '官方说明:', 'text' => $note];
}
$blocks[] = ['type' => 'quote', 'who' => '你当时反馈的内容:', 'text' => $body];
$blocks[] = [
'type' => 'callout', 'tone' => 'info',
'text' => ($mail !== ''
? '如果上面的说明没能解决你的疑问,直接回复本邮件即可,我们继续跟进;也可以发到 ' . $mail . '。'
: '如果上面的说明没能解决你的疑问,直接回复本邮件即可,我们继续跟进。'),
];
$layout = [
'preheader' => '关于你反馈的问题,我们的说明',
'badge' => ['text' => '已关闭', 'color' => '#8a6a15'],
'title' => '关于你反馈的问题,我们的说明',
'intro' => ($name !== '' ? $name . ',你好:' : '你好:'),
'blocks' => $blocks,
'recipient' => $to,
];
}
/* ---------------- 6. 新反馈(发给管理员) ---------------- */
elseif ($reason === 'new') {
$body = (string)$thread['body'];
$mail = (string)(isset($thread['author_email']) ? $thread['author_email'] : '');
$phone = trim((string)(isset($thread['author_phone']) ? $thread['author_phone'] : ''));
$swName = (string)(isset($extra['software_name']) ? $extra['software_name'] : '');
$subject = '【' . $site . '】新反馈:' . $short;
$rows = ['标题' => $title];
if ($swName !== '') { $rows['所属软件'] = $swName; }
$rows['提交人'] = $name !== '' ? $name : '(未留名)';
if ($mail !== '') { $rows['邮箱'] = $mail; }
if ($phone !== '') { $rows['电话'] = $phone; }
$blocks = [
['type' => 'text', 'text' => '有新的反馈提交,请及时处理。'],
['type' => 'field', 'rows' => $rows],
['type' => 'quote', 'who' => '反馈内容:', 'text' => $body],
['type' => 'callout', 'tone' => 'warn',
'text' => '这条反馈还需要审核,审核通过后才会公开显示。'],
];
$layout = [
'preheader' => $name . ':' . fb_excerpt($body, 60),
'badge' => ['text' => '新反馈', 'color' => '#b4553c'],
'title' => '收到一条新反馈',
'intro' => '你好:',
'blocks' => $blocks,
'button' => ['text' => '去后台处理', 'url' => fb_base_url() . '/admin.php?tab=thread&id=' . (int)$thread['id']],
'link' => fb_base_url() . '/admin.php?tab=thread&id=' . (int)$thread['id'],
'recipient' => $to,
'footer' => '此邮件由系统自动发送。想调整接收规则,请到后台「邮件通知」页修改。',
];
}
/* ---------------- 兜底 ---------------- */
else {
$subject = '【' . $site . '】' . $short;
$layout = [
'preheader' => '你有一条新的通知',
'title' => '你有一条新的通知',
'intro' => ($name !== '' ? $name . ',你好:' : '你好:'),
'blocks' => [['type' => 'text', 'text' => '你关注的反馈有更新。']],
'button' => ['text' => '查看详情', 'url' => $url],
'link' => $url,
'recipient' => $to,
];
}
return [$subject, fb_mail_layout($layout)];
}
}