Files
FeedLite/lib/mailer.php
T
laoyang 835c8c05da 软件图标支持上传图片:存 OSS/本机,media.php 代理读取
用户要求图标可以自己上传一张真实图片,而不是只能填一个字符。

设计:字符和图片并存,图片优先
- software 加 icon_key(图片对象键),icon(字符)保留作兜底
- 有图片用图片,删掉图片自动退回字符,任何时刻都不会没有图标
- 前端 badge 与邮件头部同一套判断,页面和邮件永远一致

存储与读取
- 图标走独立键空间 icons/YYYY/MM/<16hex>.<ext>
  不复用附件那套:附件必须挂在 thread 下才给读,图标不属于任何帖子
- 写:仅管理员(后台动作链)+ finfo 真实类型校验 + 尺寸下限 + 2MB 上限
- 读:media.php 对 icons/ 只校验键形状与文件存在(图标本就公开),
  写入侧仍然严格,不会让本站变成能读整个桶的开放代理
- 优先用 media.php?k= 代理;OSS 下顺便借图片处理缩放,省下行

顺手修掉两个真实安全问题
1. fb_icon_key_ok 原来用 [a-z0-9]{2,5} 匹配扩展名,.php 正好能过
   (3 个字母)—— 测试反向验证时发现,已改为图片扩展名白名单
2. fb_upload_key_ok 有同样的问题,附件键也能构造出 .php
   虽然真上传还有 finfo 兜底,但键本身就不该长这样,一并收紧
3. fb_icon_delete 原来用 @ 吞掉错误,删除失败仍返回「已保存」,
   用户会以为旧图清了、实际留了个可访问的孤儿图。改为返回 bool 并如实提示

验证:键校验 7 类非法输入全拒;图标写入 OSS 成功、media.php 读回 200 image/png、
PNG 魔数正确、缩放生效;越权读取(穿越/.php/未入库附件/不存在)全部按预期拒绝;
表单 enctype/文件框/accept/预览渲染齐全;仿真 PHP 伪图与 SVG 均被拒;
16x16 小图被拒。
2026-11-03 08:13:21 +08:00

1138 lines
55 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')) {
/**
* 邮件主色。
*
* 🔴 多软件站点的核心要求:邮件颜色必须跟「这条反馈属于哪个软件」一致,
* 否则用户在收件箱里根本分不清是哪个软件的来信。
* 取值顺序:
* ① 调用方显式传入的软件行(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),没有才用「色块 + 字符」。
* 邮件客户端对 flex 支持参差,这里一律用 table + 内联样式;
* 图片必须给死宽高和 alt,否则 Outlook 里会撑变形。 */
$imgKey = isset($sw['icon_key']) ? trim((string)$sw['icon_key']) : '';
$imgUrl = $imgKey !== '' ? fb_icon_url($imgKey, 60) : '';
$cell = '';
if ($imgUrl !== '') {
$cell = '<img src="' . fb_mail_esc($imgUrl) . '" alt="' . fb_mail_esc($name) . '"'
. ' width="30" height="30"'
. ' style="width:30px;height:30px;border-radius:8px;display:block;border:0;outline:none;text-decoration:none">';
} else {
$cell = '<div style="width:30px;height:30px;background:' . fb_mail_esc($c) . ';'
. 'border-radius:8px;text-align:center;line-height:30px;'
. 'font-size:15px;color:#ffffff;font-weight:700">'
. '<span style="display:inline-block;line-height:30px">' . fb_mail_esc($icon) . '</span></div>';
}
return '<table role="presentation" cellpadding="0" cellspacing="0" border="0"'
. ' style="border-collapse:collapse"><tr>'
. '<td width="30" style="width:30px;vertical-align:middle">' . $cell . '</td>'
. '<td style="padding-left:9px;vertical-align:middle;font-size:15px;'
. 'font-weight:700;color:#1b2430;letter-spacing:.2px">'
. fb_mail_esc($name) . '</td>'
. '</tr></table>';
}
}
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) {
// 软件上下文:显式传入优先,否则用全局上下文(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 = '<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">'
. (isset($o['software']) && is_array($o['software'])
? fb_mail_sw_header($o['software'])
. '<div style="margin-top:7px;font-size:11.5px;letter-spacing:.4px;color:#9aa2ad">'
. fb_mail_esc($site) . '</div>'
: '<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 = [
'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')) {
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']);
/* ---- 软件上下文:整封邮件的配色、头部标识、主题前缀都靠它 ----
* 每条反馈都属于某个软件。邮件必须让用户一眼看出「这是哪个软件的来信」,
* 所以这里解析一次,后面所有分支共用:
* $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)];
}
}