Files
FeedLite/lib/mailer.php
T
laoyang 8b3e49e091 邮件:带私人链接时统一加「这个链接只有您能打开,请不要转发」提醒
用户要求:每次发这种链接,都要在链接上/下给他一个提示 ——
这个链接是独有的,不要转给别人、不要让别人看到,是跟踪反馈的唯一凭证。

实现:在 fb_mail_layout() 里加 $privNoteHtml,
layout 传 personal=true 才渲染,位置在「按钮之后、纯文本链接之前」。
这样只要新增邮件是发给提交人的,标一下就自动带上,不会漏。

覆盖 7 处(全部发给提交人的):
  回复通知 / 状态变更 / 审核通过 / 转为不公开 / 关闭说明 / 提交回执 / 兜底
不覆盖 1 处(管理员通知,按钮指向后台,不是私人凭证)

提醒文案(斟酌过,讲清缘由而不只是禁止):
  标题:这个链接只有您能打开,请不要转发
  正文:它等同于您在这条反馈上的身份凭证 —— 谁拿到这个链接,
        谁就能查看和处理这条反馈。请只自己使用,不要发给别人,
        也不要发到公开的地方。如果不需要继续跟踪,删掉这封邮件即可。
  → 说清「是什么」而不是只喊「别外传」,用户才会真的照做;
    补一句退路(删掉邮件即可),避免像是免责声明。

验证:7 类邮件全部含提醒且都在页脚之前;管理员通知不含;
像素级检查确认视觉顺序为「蓝色按钮 y=719 → 黄色提醒块 y=874..982」,
即提醒在按钮下方、正文区内,不是埋在邮件底部。
2026-11-03 10:08:49 +08:00

1200 lines
60 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),没有才用字符。
* 一律不加背景色块 —— 图标本身已经能说明是哪个软件,
* 再垫个色块反而糊在一起(与前端 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) . '。'
. '如果这不是您,忽略即可。</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">'
. '<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')) {
/**
* 邮件里给提问人的链接 —— **一律带 token**,不带 id 裸链。
*
* 为什么:邮件只发给提问人自己,而 token 只有他和我们(库里)知道。
* 带上它,即使这条反馈还没审核通过、甚至最终被关闭,他点进去也能看到
* 完整详情与进度。早期给的是 thread.php?id=N,未公开时点进去只看到
* 「不存在」或「等待审核」,等于邮件里的链接是废的。
*/
function fb_notify_thread_url(array $thread) {
return fb_thread_link($thread, true);
}
}
if (!function_exists('fb_notify_status_meta')) {
/**
* 各处理状态对应的「邮件语气」。
*
* 为什么单独抽出来:用户明确要求不同状态给不同文案 ——
* 「不予考虑」要礼貌说明、不让人觉得被敷衍;
* 「处理中」要让人知道在跟进;
* 「已解决」要引导他来验证。
*/
function fb_notify_status_meta($key) {
$map = [
/* 「未公开」:用户刚提交时就是这个状态。
* 一般不单独发信(提交回执已经说了「等待审核」),
* 但万一从别的状态退回来,也要有话可说,不能落到兜底文案。 */
'hidden' => [
'badge' => '未公开', 'color' => '#6b7684', 'tone' => 'muted',
'title' => '您的反馈已提交,正在等待审核',
'intro' => '管理员会尽快查看您的反馈,确认后才会公开显示。',
'tip' => '审核结果我们会通过邮件通知您,无需反复回来查看。',
],
'open' => [
'badge' => '待处理', 'color' => '#5c6572', 'tone' => 'muted',
'title' => '您的反馈已收到,正在等待处理',
'intro' => '我们已经收到您的反馈,会尽快安排处理。',
'tip' => null,
],
'doing' => [
'badge' => '处理中', 'color' => '#0a6cf5', 'tone' => 'info',
'title' => '您反馈的问题正在处理中',
'intro' => '我们已经开始跟进您反馈的问题,有进展会第一时间告诉您。',
'tip' => '感谢您的耐心等待,暂时不需要您做任何操作。',
],
'resolved' => [
'badge' => '已完成', 'color' => '#1e7b3c', 'tone' => 'ok',
'title' => '您反馈的问题已完成并上线',
'intro' => '好消息:您反馈的内容我们已经处理完成,并已发布上线。',
'tip' => '建议更新到最新版本后试一下。如果问题仍然存在,直接在本页回复即可,我们会继续跟进。',
],
'closed' => [
'badge' => '已关闭', 'color' => '#8a6a15', 'tone' => 'warn',
'title' => '您反馈的问题已关闭',
'intro' => '您反馈的问题已经关闭。',
'tip' => null,
],
'wontfix' => [
'badge' => '不予考虑', 'color' => '#b4553c', 'tone' => 'warn',
'title' => '关于您反馈的问题,我们的处理意见',
'intro' => '感谢您的反馈。经核实,这条反馈我们暂时不会改动,原因如下。',
'tip' => '如果您认为这个判断有误,或者有补充信息,直接在本页回复即可。',
],
];
return isset($map[$key]) ? $map[$key] : $map['open'];
}
}
if (!function_exists('fb_notify_render')) {
/**
* 渲染某个事件的 [主题, HTML]。
*
* @param string $reason
* @param array $thread
* @param array $extra
* @param string $to 收件人(用于页脚「这封邮件发给了…」)
* @return array [string subject, string html]
*/
function fb_notify_render($reason, array $thread, array $extra, $to = '') {
$site = (string)fb_cfg('site_name', '轻反馈');
$url = fb_notify_thread_url($thread);
$title = (string)$thread['title'];
$name = (string)(isset($thread['author_name']) ? $thread['author_name'] : '');
$short = fb_excerpt($title, 42);
$isAdmin = !empty($extra['is_admin']);
/* ---- 软件上下文:整封邮件的配色、头部标识、主题前缀都靠它 ----
* 每条反馈都属于某个软件。邮件必须让用户一眼看出「这是哪个软件的来信」,
* 所以这里解析一次,后面所有分支共用:
* $swName 软件名(进主题行前缀)
* $sw 软件行(进邮件头标识与配色)
*
* 🔴 允许 $extra['software'] 显式指定软件行:
* ① 后台「发送测试邮件」可以指定「按某个软件的样子发」,便于预览配色;
* ② 测试脚本能在不往库里插数据的前提下验证配色切换;
* ③ 万一 threads.software_id 指向已被删除的软件,调用方仍能给出正确上下文。
* 没有显式指定时,才按 software_id 回查、再回落默认软件。 */
$sw = (isset($extra['software']) && is_array($extra['software'])) ? $extra['software'] : null;
if (!$sw) { $sw = fb_software_of_thread($thread); }
$swName = $sw ? (string)$sw['name'] : '';
fb_mail_sw_ctx($sw, true);
// 主题统一加软件名前缀,收件箱列表里不点开就能分辨
$pfx = ($swName !== '') ? '【' . $swName . '】' : '';
$subject = '';
$layout = [];
/* ---------------- 1. 新回复 ---------------- */
if ($reason === 'reply') {
$reply = (array)(isset($extra['reply']) ? $extra['reply'] : []);
$rname = $isAdmin ? '官方' : (string)(isset($reply['author_name']) ? $reply['author_name'] : '');
$rbody = (string)(isset($reply['body']) ? $reply['body'] : '');
$isOff = $isAdmin ? '官方回复' : '新回复';
$subject = $pfx . $isOff . ':' . $short;
/* 像博客评论通知那样带上上下文:
* 用户可能同时提过好几条反馈,只给「回复内容」根本想不起来是哪一条。
* 所以固定三段:① 您反馈了什么 ② 谁回复了什么 ③ 去哪里继续。 */
$origBody = (string)(isset($thread['body']) ? $thread['body'] : '');
$blocks = [
['type' => 'text', 'text' => ($swName !== '' ? '您在「' . $swName . '」提交的反馈' : '您提交的反馈')
. '有新的回复。'],
['type' => 'field', 'rows' => ['反馈标题' => $title]],
];
if (trim($origBody) !== '') {
$blocks[] = ['type' => 'quote', 'who' => '您当时反馈的内容:', 'text' => fb_excerpt($origBody, 220)];
}
$blocks[] = ['type' => 'quote', 'who' => $rname . ' 回复:', 'text' => $rbody];
if ($isAdmin) {
$blocks[] = ['type' => 'callout', 'tone' => 'info',
'text' => '这是官方回复。如果还有疑问,直接在本页继续回复即可。'];
}
$layout = [
'software' => $sw,
'preheader' => $rname . ':' . fb_excerpt($rbody, 60),
'badge' => ['text' => $isOff, 'color' => $isAdmin ? fb_mail_brand() : '#5c6572'],
'title' => $isAdmin ? '官方回复了您的反馈' : '有人回复了您的反馈',
'intro' => ($name !== '' ? $name . ',您好:' : '您好:'),
'blocks' => $blocks,
'button' => ['text' => '查看并回复', 'url' => $url],
'link' => $url,
'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'],
'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') {
$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,
'personal' => true, // 发给提交人
'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,
'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', ''));
$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 . '。'
: '如果上面的说明没能解决您的疑问,直接回复本邮件即可,我们继续跟进。'),
];
/* 这条已经「已关闭 + 不公开」,不带 token 的链接打不开 ——
* 必须给带 token 的私人链接,用户才能回来看上下文、继续补充。
* 注意:button 是 layout 层属性,不是 block 类型。 */
$closeBtnUrl = fb_thread_link($thread, true);
$layout = [
'software' => $sw,
'preheader' => '关于您反馈的问题,我们的说明',
'badge' => ['text' => '已关闭', 'color' => '#8a6a15'],
'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 . ' 收到一条新反馈' : '收到一条新反馈'),
'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,
'personal' => true, // 发给提交人(回执)
'recipient' => $to,
];
}
/* ---------------- 兜底 ---------------- */
else {
$subject = $pfx . $short;
$layout = [
'software' => $sw,
'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)];
}
}