= 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')) { /** * 邮件主色。 * * 🔴 多软件站点的核心要求:邮件颜色必须跟「这条反馈属于哪个软件」一致, * 否则用户在收件箱里根本分不清是哪个软件的来信。 * 取值顺序: * ① 调用方显式传入的软件行(fb_mail_layout(['software' => ...])) * ② 当前全局软件($GLOBALS['__fb_mail_sw'],由 fb_notify_render 设置) * ③ 配置里的 mail_brand_color(单软件站点的老配置,保持兼容) * ④ 兜底蓝 */ function fb_mail_brand() { $sw = null; if (isset($GLOBALS['__fb_mail_sw']) && is_array($GLOBALS['__fb_mail_sw'])) { $sw = $GLOBALS['__fb_mail_sw']; } if ($sw) { list($c) = fb_software_colors($sw); if ($c !== '') { return $c; } } $c = fb_color_norm((string)fb_cfg('mail_brand_color', '')); if ($c === '') { $c = '#0057B8'; } return $c; } } if (!function_exists('fb_mail_sw_ctx')) { /** 设置/读取「当前邮件所属软件」,返回之前的上下文(便于嵌套调用后还原) */ function fb_mail_sw_ctx($sw = null, $set = false) { $prev = isset($GLOBALS['__fb_mail_sw']) ? $GLOBALS['__fb_mail_sw'] : null; if ($set) { $GLOBALS['__fb_mail_sw'] = $sw; } return $prev; } } if (!function_exists('fb_mail_sw_header')) { /** * 邮件头部的「软件标识」:色块图标 + 软件名。 * 用 table + 内联样式写,兼容 Outlook 等不认 flex 的客户端。 */ function fb_mail_sw_header($sw) { if (!is_array($sw) || empty($sw['name'])) { return ''; } list($c) = fb_software_colors($sw); if ($c === '') { $c = '#0057B8'; } $icon = fb_software_icon($sw); $name = (string)$sw['name']; /* 有上传的图标图片就用图片(管理员自己的 logo),没有才用字符。 * 一律不加背景色块 —— 图标本身已经能说明是哪个软件, * 再垫个色块反而糊在一起(与前端 fb_software_badge 保持同一观感)。 * 邮件客户端对 flex 支持参差,这里一律用 table + 内联样式; * 图片必须给死宽高,否则 Outlook 里会撑变形。 */ $imgKey = isset($sw['icon_key']) ? trim((string)$sw['icon_key']) : ''; $imgUrl = $imgKey !== '' ? fb_icon_url($imgKey, 60) : ''; $cell = ''; if ($imgUrl !== '') { $cell = '' . fb_mail_esc($name) . ''; } else { $cell = '
' . '' . fb_mail_esc($icon) . '
'; } return '' . '' . '' . '
' . $cell . '' . fb_mail_esc($name) . '
'; } } 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 '

' . (isset($b['html']) ? $b['html'] : fb_mail_esc(isset($b['text']) ? $b['text'] : '')) . '

'; } if ($type === 'quote') { $who = isset($b['who']) ? (string)$b['who'] : ''; $body = nl2br(fb_mail_esc(isset($b['text']) ? $b['text'] : '')); return '' . '
' . ($who !== '' ? '
' . fb_mail_esc($who) . '
' : '') . '
' . $body . '
' . '
'; } if ($type === 'field') { $rows = isset($b['rows']) ? (array)$b['rows'] : []; $out = ''; $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 .= '' . '' . '' . ''; $i++; } return $out . '
' . fb_mail_esc($label) . '' . fb_mail_esc($value) . '
'; } 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 '' . '
' . $body . '
'; } 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) { // 软件上下文:显式传入优先,否则用全局上下文(fb_notify_render 会设好) $sw = isset($o['software']) && is_array($o['software']) ? $o['software'] : null; if ($sw) { fb_mail_sw_ctx($sw, true); } // 让本函数内 fb_mail_brand() 取到软件色 $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 = '
' . '' . fb_mail_esc($badge['text']) . '' . '
'; } // ---- 按钮 ---- $btnHtml = ''; if ($button && !empty($button['url']) && !empty($button['text'])) { $url = (string)$button['url']; $btnHtml = '' . '
' . '' . fb_mail_esc($button['text']) . '
'; } // ---- 纯文本兜底链接 ---- $linkHtml = ''; if ($link !== '') { $linkHtml = '

' . '如果按钮无法点击,请复制下面的链接到浏览器打开:
' . '' . fb_mail_esc($link) . '

'; } // ---- 收件人问候 ---- $introHtml = ''; if ($intro !== '') { $introHtml = '

' . fb_mail_esc($intro) . '

'; } return '' . '' . '' . '' . '' . fb_mail_esc($title) . '' . '' // 预处理器:收件箱里显示的那行摘要,兼带隐藏占位防止被正文抢先 . ($preheader !== '' ? '
' . fb_mail_esc($preheader) . '
' : '') // 外层留白 . '
' // 卡片 . '' // 顶部色条 . '' // 站名 / 软件标识 // 有软件上下文时:上排是「图标 + 软件名」(这是用户最需要一眼认出的信息), // 下排再补一行小字站点名。没有软件时退回原来的纯站名写法。 . '' // 标题 . '' // 正文 . '' // 页脚 . '' . '
 
' . (isset($o['software']) && is_array($o['software']) ? fb_mail_sw_header($o['software']) . '
' . fb_mail_esc($site) . '
' : '
' . fb_mail_esc($site) . '
') . '
' . $badgeHtml . '

' . fb_mail_esc($title) . '

' . $introHtml . $body . $btnHtml . $linkHtml . '
' . '

' . fb_mail_esc($footer) . '

' . ($recipient !== '' ? '

' . '这封邮件发给了 ' . fb_mail_esc($recipient) . '。' . '如果这不是您,忽略即可。

' : '') . '

' . '' . fb_mail_esc($base) . '

' . '
' . '
' . ''; } } function fb_notify_html($thread, $reply, $isAdmin) { $base = fb_base_url(); // 发给提问人的信,链接必须带 token,否则他看不了自己那条未公开的反馈 $url = fb_thread_link($thread, true); $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 '
' . '

' . $safeName . ',您好:

' . '

您在「' . htmlspecialchars($site, ENT_QUOTES, 'UTF-8') . '」提交的问题 《' . $safeTitle . '》 有了新的回复:

' . '
' . '
' . $safeRName . ' 回复:
' . $rbody . '
' . '

查看并回复

' . '

若按钮无法点击,请复制链接到浏览器打开:
' . htmlspecialchars($url, ENT_QUOTES, 'UTF-8') . '

' . '
' . '

此邮件由系统自动发送,请勿直接回复。

' . '
'; } /** * 有人回复后,给发帖人发送通知邮件 * @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 '
' . '

' . $name . ',您好:

' . '

感谢您反馈 《' . $title . '》。我们已经核实过,' . '该情况并不是应用的问题,因此这条反馈已被关闭(不会公开显示)。

' . '
' . '
官方说明:
' . $noteH . '
' . '

您当时反馈的内容:

' . '
' . $body . '
' . ($mail !== '' ? '

如果上面的说明没能解决您的疑问,可以直接回复本邮件,我们会继续跟进;' . '也可以发邮件到 ' . $mail . ' 补充说明。

' : '

如果上面的说明没能解决您的疑问,可以直接回复本邮件,我们会继续跟进。

') . '
' . '

此邮件由「' . htmlspecialchars($site, ENT_QUOTES, 'UTF-8') . '」自动发送。

' . '
'; } /** * 关闭问题(判定"不是问题")时,把说明邮件发给提问人 * @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 = [ 'receipt' => '已收到反馈', '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) { // 「新反馈」按 admin_new_thread 独立开关;「提交回执」按 receipt_enabled 独立开关 // (回执是发给提问人的,跟「有人回复时通知提交人」是两件不同的事, // 有人可能只想要回执不想要回复通知,所以不能共用一个开关) if ($reason === 'new') { return (bool)fb_cfg('notify.admin_new_thread', false); } if ($reason === 'receipt') { return (bool)fb_cfg('notify.receipt_enabled', true); } 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; /* 🔴 铁律:任何一条退出路径都必须写 notify_log。 * * 原先前 4 个判断(开关 / 收件人 / 自己回自己 / 冷却)直接 return 不记日志, * 结果就是「用户说没收到信」时后台查不到任何记录 —— 因为压根就没进发信那一步。 * 2026-11-03 排查时 notify_log 全表为空,一度被误判成「发信链路坏了」, * 实际是这些静默 return 造成的黑箱。日志必须回答「为什么没发」。 */ $log = function ($to, $ok, $err) use ($pdo, $thread, $reason) { fb_notify_log2($pdo, $thread, $to, $reason, $ok, $err); }; // ---- 1. 开关 ---- if (!fb_notify_enabled_for($reason)) { $log('', 0, '通知未启用(该事件的开关是关的)'); return [false, '通知未启用']; } // ---- 2. 收件人 ---- $isAdminNotice = ($reason === 'new'); if ($isAdminNotice) { $to = trim((string)fb_cfg('admin_email', '')); if ($to === '') { $to = trim((string)fb_cfg('admin.email', '')); } // 还没填官方邮箱时回落到 SMTP 发件地址 —— 至少信能到管理员手上, // 而不是整个「新反馈通知」功能静默失效(实际就发生过:管理员从不收信, // 后台也没有任何提示,直到来测才发现)。 if ($to === '') { $to = trim((string)fb_cfg('smtp.user', '')); } if ($to === '') { $to = trim((string)fb_cfg('smtp.from', '')); } } else { $to = trim((string)(isset($thread['author_email']) ? $thread['author_email'] : '')); } if ($to === '' || !filter_var($to, FILTER_VALIDATE_EMAIL)) { $why = $isAdminNotice ? '收件邮箱无效:官方邮箱与 SMTP 发件地址都没填,新反馈通知发不出去' : '收件邮箱无效:提交人没留邮箱或格式不对'; $log($to, 0, $why); return [false, $why]; } // ---- 3. 自己通知自己就别发了 ---- if (!$isAdminNotice && !empty($extra['is_admin']) === false && !empty($extra['reply']['author_email'])) { if (strcasecmp(trim((string)$extra['reply']['author_email']), $to) === 0) { $log($to, 0, '自己回复自己,不发'); return [false, '自己回复自己,不发']; } } // ---- 4. 节流 ---- if (fb_notify_throttled($pdo, (int)$thread['id'], $to, $reason)) { $log($to, 0, '冷却中跳过(notify.cooldown_seconds=' . (int)fb_cfg('notify.cooldown_seconds', 300) . ')'); return [false, '冷却中,跳过']; } // ---- 5. SMTP 就绪吗 ---- $smtp = (array)fb_cfg('smtp', []); if (empty($smtp['enabled']) || trim((string)$smtp['host']) === '') { $log($to, 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); $log($to, $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')) { /** * 邮件里给提问人的链接 —— **一律带 token**,不带 id 裸链。 * * 为什么:邮件只发给提问人自己,而 token 只有他和我们(库里)知道。 * 带上它,即使这条反馈还没审核通过、甚至最终被关闭,他点进去也能看到 * 完整详情与进度。早期给的是 thread.php?id=N,未公开时点进去只看到 * 「不存在」或「等待审核」,等于邮件里的链接是废的。 */ function fb_notify_thread_url(array $thread) { return fb_thread_link($thread, true); } } if (!function_exists('fb_notify_status_meta')) { /** * 各处理状态对应的「邮件语气」。 * * 为什么单独抽出来:用户明确要求不同状态给不同文案 —— * 「不予考虑」要礼貌说明、不让人觉得被敷衍; * 「处理中」要让人知道在跟进; * 「已解决」要引导他来验证。 */ function fb_notify_status_meta($key) { $map = [ /* 「未公开」:用户刚提交时就是这个状态。 * 一般不单独发信(提交回执已经说了「等待审核」), * 但万一从别的状态退回来,也要有话可说,不能落到兜底文案。 */ 'hidden' => [ 'badge' => '未公开', 'color' => '#6b7684', 'tone' => 'muted', 'title' => '您的反馈已提交,正在等待审核', 'intro' => '管理员会尽快查看您的反馈,确认后才会公开显示。', 'tip' => '审核结果我们会通过邮件通知您,无需反复回来查看。', ], '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']); /* ---- 软件上下文:整封邮件的配色、头部标识、主题前缀都靠它 ---- * 每条反馈都属于某个软件。邮件必须让用户一眼看出「这是哪个软件的来信」, * 所以这里解析一次,后面所有分支共用: * $swName 软件名(进主题行前缀) * $sw 软件行(进邮件头标识与配色) * * 🔴 允许 $extra['software'] 显式指定软件行: * ① 后台「发送测试邮件」可以指定「按某个软件的样子发」,便于预览配色; * ② 测试脚本能在不往库里插数据的前提下验证配色切换; * ③ 万一 threads.software_id 指向已被删除的软件,调用方仍能给出正确上下文。 * 没有显式指定时,才按 software_id 回查、再回落默认软件。 */ $sw = (isset($extra['software']) && is_array($extra['software'])) ? $extra['software'] : null; if (!$sw) { $sw = fb_software_of_thread($thread); } $swName = $sw ? (string)$sw['name'] : ''; fb_mail_sw_ctx($sw, true); // 主题统一加软件名前缀,收件箱列表里不点开就能分辨 $pfx = ($swName !== '') ? '【' . $swName . '】' : ''; $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 = $pfx . $isOff . ':' . $short; /* 像博客评论通知那样带上上下文: * 用户可能同时提过好几条反馈,只给「回复内容」根本想不起来是哪一条。 * 所以固定三段:① 您反馈了什么 ② 谁回复了什么 ③ 去哪里继续。 */ $origBody = (string)(isset($thread['body']) ? $thread['body'] : ''); $blocks = [ ['type' => 'text', 'text' => ($swName !== '' ? '您在「' . $swName . '」提交的反馈' : '您提交的反馈') . '有新的回复。'], ['type' => 'field', 'rows' => ['反馈标题' => $title]], ]; if (trim($origBody) !== '') { $blocks[] = ['type' => 'quote', 'who' => '您当时反馈的内容:', 'text' => fb_excerpt($origBody, 220)]; } $blocks[] = ['type' => 'quote', 'who' => $rname . ' 回复:', 'text' => $rbody]; if ($isAdmin) { $blocks[] = ['type' => 'callout', 'tone' => 'info', 'text' => '这是官方回复。如果还有疑问,直接在本页继续回复即可。']; } $layout = [ 'software' => $sw, 'preheader' => $rname . ':' . fb_excerpt($rbody, 60), 'badge' => ['text' => $isOff, 'color' => $isAdmin ? fb_mail_brand() : '#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 = $pfx . $short . ' —— ' . $label; $rows = ['反馈标题' => $title]; if ($swName !== '') { $rows['所属软件'] = $swName; } 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']]; } /* 「已解决」要真的推用户一把去验证: * 软件配了下载地址就给一个下载按钮 —— 只说「修好了」用户不知道去哪拿新版本。 */ $dlUrl = $sw ? trim((string)(isset($sw['download_url']) ? $sw['download_url'] : '')) : ''; $btnText = '查看详情'; $btnUrl = $url; if ($newKey === 'resolved' && $dlUrl !== '') { $blocks[] = ['type' => 'callout', 'tone' => 'ok', 'text' => '新版本已经发布,建议下载最新版后验证一下。']; $btnText = '下载最新版本'; $btnUrl = $dlUrl; } $layout = [ 'software' => $sw, 'preheader' => '状态更新为「' . $label . '」', 'badge' => ['text' => $meta['badge'], 'color' => $meta['color']], 'title' => $meta['title'], 'intro' => ($name !== '' ? $name . ',您好:' : '您好:'), 'blocks' => $blocks, 'button' => ['text' => $btnText, 'url' => $btnUrl], // 下载按钮之外,始终保留「查看这条反馈」的原文链接 'link' => ($btnUrl === $url) ? $url : ('下载地址:' . $dlUrl . "\n" . '查看反馈:' . $url), 'recipient' => $to, ]; } /* ---------------- 3. 审核通过 ---------------- */ elseif ($reason === 'approve') { $subject = $pfx . '您的反馈已发布:' . $short; $layout = [ 'software' => $sw, 'preheader' => '您的反馈已经公开可见', 'badge' => ['text' => '已发布', 'color' => '#1e7b3c'], 'title' => '您的反馈已通过审核并公开', 'intro' => ($name !== '' ? $name . ',您好:' : '您好:'), 'blocks' => [ ['type' => 'text', 'text' => '您提交的反馈已通过审核,现在可以在' . ($swName !== '' ? '「' . $swName . '」的' : '') . '公开列表中看到了。'], ['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 = $pfx . '您的反馈已转为不公开:' . $short; $blocks = [ ['type' => 'text', 'text' => '您提交的反馈已从公开列表中撤下,不再对其他人可见。'], ['type' => 'field', 'rows' => ['反馈标题' => $title]], ]; if ($note !== '') { $blocks[] = ['type' => 'quote', 'who' => '官方说明:', 'text' => $note]; } $blocks[] = ['type' => 'callout', 'tone' => 'muted', 'text' => '这条反馈本身仍然保留,您依然可以通过原链接访问和处理进展。']; $layout = [ 'software' => $sw, '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 = $pfx . '关于您反馈的问题《' . $short . '》'; $blocks = [ ['type' => 'text', 'text' => '感谢您的反馈。我们已经核实过,这个情况并不是' . ($swName !== '' ? '「' . $swName . '」' : '应用') . '本身的问题,因此这条反馈已关闭(不会公开显示)。'], ['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 = [ 'software' => $sw, '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'] : '')); // 软件名优先取实际软件行(比 extra 里传进来的更可靠) $swName = $swName !== '' ? $swName : (string)(isset($extra['software_name']) ? $extra['software_name'] : ''); $subject = $pfx . '新反馈:' . $short; $rows = []; if ($swName !== '') { $rows['所属软件'] = $swName; } $rows['标题'] = $title; $rows['提交人'] = $name !== '' ? $name : '(未留名)'; if ($mail !== '') { $rows['邮箱'] = $mail; } if ($phone !== '') { $rows['电话'] = $phone; } $adminUrl = fb_base_url() . '/admin.php?tab=thread&id=' . (int)$thread['id']; $blocks = [ ['type' => 'text', 'text' => ($swName !== '' ? '「' . $swName . '」' : '') . '有一条新的反馈提交,请到管理后台处理。'], ['type' => 'field', 'rows' => $rows], ['type' => 'quote', 'who' => '反馈内容:', 'text' => $body], ['type' => 'callout', 'tone' => 'warn', 'text' => '这条反馈还需要审核,审核通过后才会公开显示。'], ]; $layout = [ 'software' => $sw, 'preheader' => $name . ':' . fb_excerpt($body, 60), 'badge' => ['text' => '新反馈', 'color' => '#b4553c'], 'title' => ($swName !== '' ? $swName . ' 收到一条新反馈' : '收到一条新反馈'), 'intro' => '您好:', 'blocks' => $blocks, 'button' => ['text' => '去后台处理这条反馈', 'url' => $adminUrl], 'link' => $adminUrl, 'recipient' => $to, 'footer' => '此邮件由系统自动发送。想调整接收规则,请到后台「邮件通知」页修改。', ]; } /* ---------------- 7. 提交回执(发给提问人) ---------------- */ elseif ($reason === 'receipt') { $body = (string)$thread['body']; $subject = $pfx . '已收到您的反馈:' . $short; $blocks = [ ['type' => 'text', 'text' => '我们已收到您在' . ($swName !== '' ? '「' . $swName . '」' : '本站') . '提交的反馈,感谢您的反馈!'], ['type' => 'field', 'rows' => ['反馈标题' => $title]], ['type' => 'quote', 'who' => '您提交的内容:', 'text' => fb_excerpt($body, 400)], ['type' => 'callout', 'tone' => 'info', 'text' => '这条反馈正在等待管理员审核,请耐心等待。审核通过后会在列表中公开显示。'], ['type' => 'text', 'text' => '审核结果与后续处理进展,我们都会通过邮件通知您,无需反复回来查看。'], ]; $layout = [ 'software' => $sw, 'preheader' => '已收到您的反馈,正在等待审核', 'badge' => ['text' => '已收到', 'color' => fb_mail_brand()], 'title' => '已收到您的反馈', 'intro' => ($name !== '' ? $name . ',您好:' : '您好:'), 'blocks' => $blocks, 'button' => ['text' => '查看这条反馈', 'url' => $url], 'link' => $url, 'recipient' => $to, ]; } /* ---------------- 兜底 ---------------- */ else { $subject = $pfx . $short; $layout = [ 'software' => $sw, 'preheader' => '您有一条新的通知', 'title' => '您有一条新的通知', 'intro' => ($name !== '' ? $name . ',您好:' : '您好:'), 'blocks' => [['type' => 'text', 'text' => '您关注的反馈有更新。']], 'button' => ['text' => '查看详情', 'url' => $url], 'link' => $url, 'recipient' => $to, ]; } return [$subject, fb_mail_layout($layout)]; } }