= 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 '

' . (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) { $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) . '
' : '') // 外层留白 . '
' // 卡片 . '' // 顶部色条 . '' // 站名 . '' // 标题 . '' // 正文 . '' // 页脚 . '' . '
 
' . '
' . 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(); $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 '
' . '

' . $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 = [ '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; /* 🔴 铁律:任何一条退出路径都必须写 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')) { 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)]; } }