Files
FeedLite/lib/mailer.php
T

1200 lines
60 KiB
PHP
Raw Normal View History

<?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'],
2026-11-03 09:00:05 +08:00
* '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 = '<img src="' . fb_mail_esc($imgUrl) . '" alt="' . fb_mail_esc($name) . '"'
. ' width="30" height="30"'
. ' style="width:30px;height:30px;display:block;border:0;outline:none;text-decoration:none">';
} else {
$cell = '<div style="font-size:22px;line-height:30px;text-align:center;color:#1b2430">'
. '<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>';
}
/* ---- 私人链接安全提醒 ----
*
* 用户 2026-11-03:「每次给他发这种链接的时候,都要在链接下面或上面
* 给他一个提示:这个链接是你独有的,不要转给别人。」
*
* 为什么必须提醒:这个链接里带 track_token,它就是**唯一凭证**——
* 谁拿到这串东西,谁就能看(甚至回复)这条反馈。而本站不做登录,
* 我们没法事后判断"是本人还是捡到链接的人"。
* 只要邮件里带了私人链接(personal=true),这条提醒就必须出现。
*
* 措辞取舍:
* - 说清"是什么"(只有你能看的入口),而不只是"别外传"——只讲禁止,
* 用户会觉得是免责声明;讲清缘由他才会真的照做。
* - 不让用户恐慌:补一句"不需要的话可以删掉这封邮件",给出退路。
* - 管理员后台通知里的链接不是私人凭证,不加这段(personal=false)。 */
$privNoteHtml = '';
if (!empty($o['personal'])) {
$privNoteHtml = '<table role="presentation" cellpadding="0" cellspacing="0" border="0"'
. ' width="100%" style="margin:0 0 18px">'
. '<tr><td style="padding:11px 14px;background:#fff8e6;'
. 'border-left:3px solid #d99b1f;border-radius:6px">'
. '<p style="margin:0 0 5px;font-size:13px;font-weight:600;color:#6b5313">'
. '这个链接只有您能打开,请不要转发</p>'
. '<p style="margin:0;font-size:12.5px;line-height:1.7;color:#7a6420">'
. '它等同于您在这条反馈上的身份凭证 —— 谁拿到这个链接,谁就能查看和处理这条反馈。'
. '请只自己使用,不要发给别人,也不要发到公开的地方。'
. '如果不需要继续跟踪,删掉这封邮件即可。</p>'
. '</td></tr></table>';
}
// ---- 收件人问候 ----
$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 . $privNoteHtml . $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) . '。'
2026-11-03 09:00:05 +08:00
. '如果这不是您,忽略即可。</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();
// 发给提问人的信,链接必须带 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 '<div style="font-family:-apple-system,Segoe UI,Microsoft YaHei,sans-serif;font-size:15px;line-height:1.7;color:#1f2329">'
2026-11-03 09:00:05 +08:00
. '<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">'
2026-11-03 09:00:05 +08:00
. '<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>'
2026-11-03 09:00:05 +08:00
. '<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>'
2026-11-03 09:00:05 +08:00
. ($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)
*
* 全部事件:
2026-11-03 09:00:05 +08:00
* 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',
2026-11-03 09:00:05 +08:00
'title' => '您的反馈已收到,正在等待处理',
'intro' => '我们已经收到您的反馈,会尽快安排处理。',
'tip' => null,
],
'doing' => [
'badge' => '处理中', 'color' => '#0a6cf5', 'tone' => 'info',
2026-11-03 09:00:05 +08:00
'title' => '您反馈的问题正在处理中',
'intro' => '我们已经开始跟进您反馈的问题,有进展会第一时间告诉您。',
'tip' => '感谢您的耐心等待,暂时不需要您做任何操作。',
],
'resolved' => [
'badge' => '已完成', 'color' => '#1e7b3c', 'tone' => 'ok',
2026-11-03 09:00:05 +08:00
'title' => '您反馈的问题已完成并上线',
'intro' => '好消息:您反馈的内容我们已经处理完成,并已发布上线。',
'tip' => '建议更新到最新版本后试一下。如果问题仍然存在,直接在本页回复即可,我们会继续跟进。',
],
'closed' => [
'badge' => '已关闭', 'color' => '#8a6a15', 'tone' => 'warn',
2026-11-03 09:00:05 +08:00
'title' => '您反馈的问题已关闭',
'intro' => '您反馈的问题已经关闭。',
'tip' => null,
],
'wontfix' => [
'badge' => '不予考虑', 'color' => '#b4553c', 'tone' => 'warn',
2026-11-03 09:00:05 +08:00
'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;
/* 像博客评论通知那样带上上下文:
* 用户可能同时提过好几条反馈,只给「回复内容」根本想不起来是哪一条。
2026-11-03 09:00:05 +08:00
* 所以固定三段:① 您反馈了什么 ② 谁回复了什么 ③ 去哪里继续。 */
$origBody = (string)(isset($thread['body']) ? $thread['body'] : '');
$blocks = [
2026-11-03 09:00:05 +08:00
['type' => 'text', 'text' => ($swName !== '' ? '您在「' . $swName . '」提交的反馈' : '您提交的反馈')
. '有新的回复。'],
['type' => 'field', 'rows' => ['反馈标题' => $title]],
];
if (trim($origBody) !== '') {
2026-11-03 09:00:05 +08:00
$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'],
2026-11-03 09:00:05 +08:00
'title' => $isAdmin ? '官方回复了您的反馈' : '有人回复了您的反馈',
'intro' => ($name !== '' ? $name . ',您好:' : '您好:'),
'blocks' => $blocks,
'button' => ['text' => '查看并回复', 'url' => $url],
'link' => $url,
'personal' => true, // 发给提交人:带 token 的私人链接,需提醒勿外传
'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'],
2026-11-03 09:00:05 +08:00
'intro' => ($name !== '' ? $name . ',您好:' : '您好:'),
'blocks' => $blocks,
'button' => ['text' => $btnText, 'url' => $btnUrl],
// 下载按钮之外,始终保留「查看这条反馈」的原文链接
'link' => ($btnUrl === $url) ? $url
: ('下载地址:' . $dlUrl . "\n" . '查看反馈:' . $url),
'personal' => true, // 发给提交人
'recipient' => $to,
];
}
/* ---------------- 3. 审核通过 ---------------- */
elseif ($reason === 'approve') {
2026-11-03 09:00:05 +08:00
$subject = $pfx . '您的反馈已发布:' . $short;
$layout = [
'software' => $sw,
2026-11-03 09:00:05 +08:00
'preheader' => '您的反馈已经公开可见',
'badge' => ['text' => '已发布', 'color' => '#1e7b3c'],
2026-11-03 09:00:05 +08:00
'title' => '您的反馈已通过审核并公开',
'intro' => ($name !== '' ? $name . ',您好:' : '您好:'),
'blocks' => [
2026-11-03 09:00:05 +08:00
['type' => 'text', 'text' => '您提交的反馈已通过审核,现在可以在'
. ($swName !== '' ? '「' . $swName . '」的' : '') . '公开列表中看到了。'],
['type' => 'field', 'rows' => ['反馈标题' => $title]],
['type' => 'callout', 'tone' => 'info',
2026-11-03 09:00:05 +08:00
'text' => '后续的处理进展与回复,我们会通过邮件通知您。'],
],
'button' => ['text' => '查看并跟踪这条反馈', 'url' => $url],
'link' => $url,
'personal' => true, // 发给提交人
'recipient' => $to,
];
}
/* ---------------- 4. 转为不公开 ---------------- */
elseif ($reason === 'hide') {
$note = trim((string)(isset($extra['note']) ? $extra['note'] : ''));
2026-11-03 09:00:05 +08:00
$subject = $pfx . '您的反馈已转为不公开:' . $short;
$blocks = [
2026-11-03 09:00:05 +08:00
['type' => 'text', 'text' => '您提交的反馈已从公开列表中撤下,不再对其他人可见。'],
['type' => 'field', 'rows' => ['反馈标题' => $title]],
];
if ($note !== '') {
$blocks[] = ['type' => 'quote', 'who' => '官方说明:', 'text' => $note];
}
$blocks[] = ['type' => 'callout', 'tone' => 'muted',
2026-11-03 09:00:05 +08:00
'text' => '这条反馈本身仍然保留,您依然可以通过原链接访问和处理进展。'];
$layout = [
'software' => $sw,
'preheader' => '这条反馈已不对其他人可见',
'badge' => ['text' => '不公开', 'color' => '#5c6572'],
2026-11-03 09:00:05 +08:00
'title' => '您的反馈已转为不公开',
'intro' => ($name !== '' ? $name . ',您好:' : '您好:'),
'blocks' => $blocks,
'button' => ['text' => '查看这条反馈', 'url' => $url],
'link' => $url,
'personal' => true, // 发给提交人(转为不公开)
'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', ''));
2026-11-03 09:00:05 +08:00
$subject = $pfx . '关于您反馈的问题《' . $short . '》';
$blocks = [
['type' => 'text',
2026-11-03 09:00:05 +08:00
'text' => '感谢您的反馈。我们已经核实过,这个情况并不是'
. ($swName !== '' ? '「' . $swName . '」' : '应用') . '本身的问题,因此这条反馈已关闭(不会公开显示)。'],
['type' => 'field', 'rows' => ['反馈标题' => $title]],
];
if ($note !== '') {
$blocks[] = ['type' => 'quote', 'who' => '官方说明:', 'text' => $note];
}
2026-11-03 09:00:05 +08:00
$blocks[] = ['type' => 'quote', 'who' => '您当时反馈的内容:', 'text' => $body];
$blocks[] = [
'type' => 'callout', 'tone' => 'info',
'text' => ($mail !== ''
2026-11-03 09:00:05 +08:00
? '如果上面的说明没能解决您的疑问,直接回复本邮件即可,我们继续跟进;也可以发到 ' . $mail . '。'
: '如果上面的说明没能解决您的疑问,直接回复本邮件即可,我们继续跟进。'),
];
/* 这条已经「已关闭 + 不公开」,不带 token 的链接打不开 ——
* 必须给带 token 的私人链接,用户才能回来看上下文、继续补充。
* 注意:button 是 layout 层属性,不是 block 类型。 */
$closeBtnUrl = fb_thread_link($thread, true);
$layout = [
'software' => $sw,
2026-11-03 09:00:05 +08:00
'preheader' => '关于您反馈的问题,我们的说明',
'badge' => ['text' => '已关闭', 'color' => '#8a6a15'],
2026-11-03 09:00:05 +08:00
'title' => '关于您反馈的问题,我们的说明',
'intro' => ($name !== '' ? $name . ',您好:' : '您好:'),
'blocks' => $blocks,
'button' => ['text' => '查看这条反馈', 'url' => $closeBtnUrl],
'personal' => true, // 发给提交人(关闭说明)
'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 . ' 收到一条新反馈' : '收到一条新反馈'),
2026-11-03 09:00:05 +08:00
'intro' => '您好:',
'blocks' => $blocks,
'button' => ['text' => '去后台处理这条反馈', 'url' => $adminUrl],
'link' => $adminUrl,
'recipient' => $to,
'footer' => '此邮件由系统自动发送。想调整接收规则,请到后台「邮件通知」页修改。',
];
}
/* ---------------- 7. 提交回执(发给提问人) ---------------- */
elseif ($reason === 'receipt') {
$body = (string)$thread['body'];
2026-11-03 09:00:05 +08:00
$subject = $pfx . '已收到您的反馈:' . $short;
$blocks = [
2026-11-03 09:00:05 +08:00
['type' => 'text', 'text' => '我们已收到您在'
. ($swName !== '' ? '「' . $swName . '」' : '本站')
2026-11-03 09:00:05 +08:00
. '提交的反馈,感谢您的反馈!'],
['type' => 'field', 'rows' => ['反馈标题' => $title]],
2026-11-03 09:00:05 +08:00
['type' => 'quote', 'who' => '您提交的内容:', 'text' => fb_excerpt($body, 400)],
['type' => 'callout', 'tone' => 'info',
'text' => '这条反馈正在等待管理员审核,请耐心等待。审核通过后会在列表中公开显示。'],
2026-11-03 09:00:05 +08:00
['type' => 'text', 'text' => '审核结果与后续处理进展,我们都会通过邮件通知您,无需反复回来查看。'],
];
$layout = [
'software' => $sw,
2026-11-03 09:00:05 +08:00
'preheader' => '已收到您的反馈,正在等待审核',
'badge' => ['text' => '已收到', 'color' => fb_mail_brand()],
2026-11-03 09:00:05 +08:00
'title' => '已收到您的反馈',
'intro' => ($name !== '' ? $name . ',您好:' : '您好:'),
'blocks' => $blocks,
'button' => ['text' => '查看这条反馈', 'url' => $url],
'link' => $url,
'personal' => true, // 发给提交人(回执)
'recipient' => $to,
];
}
/* ---------------- 兜底 ---------------- */
else {
$subject = $pfx . $short;
$layout = [
'software' => $sw,
2026-11-03 09:00:05 +08:00
'preheader' => '您有一条新的通知',
'title' => '您有一条新的通知',
'intro' => ($name !== '' ? $name . ',您好:' : '您好:'),
'blocks' => [['type' => 'text', 'text' => '您关注的反馈有更新。']],
'button' => ['text' => '查看详情', 'url' => $url],
'link' => $url,
'personal' => true, // 兜底同样是发给提交人的
'recipient' => $to,
];
}
return [$subject, fb_mail_layout($layout)];
}
}