Files
wordpress-kratos-yang/inc/theme-mail.php
T
laoyang 269364bed4 版本号 3.2.6 -> 3.2.7(纪念日全部默认不勾 + 彩色说明页 + 邮件彩色 + 双引用块)
哀悼默认值全部改为不勾选
- 九个纪念日的 default 全改成 0。之前法定那三个是 1,导致全新安装的站点
  一到日子就自己变黑白,而站主根本没同意过这件事。
- 后台多选组件的 std 全为 0,打开设置页一个都不勾。
- 「从未保存过」与「保存过但全不勾」两条路径都返回空数组,
  不能因为结果为空就回退到默认 —— 那等于用户取消勾选无效。

新增彩色说明页(inc/theme-mourning-page.php + pages/page-mourning.php)
- 站点变黑白后必须有个地方解释清楚,否则访客只看到一片灰、以为站点坏了。
- 三处入口:顶部提示条(wp_body_open,首屏可见)、页脚常驻链接、
  [yang_mourning] 短代码。
- 页面内容由真实配置驱动,不是写死的文案:没勾的日期不会出现,
  今天在列表里带「今天」标记,一个都没勾时明说「不会生效」。
- 页面必须保持彩色,否则解释「为什么是灰的」的页面自己也是灰的。
  取消灰度做了两层:filter:none 与 grayscale(0%) 都写,且带 !important;
  优先级放在 wp_head 的 2,晚于灰度样式的 1。
- 路由走 parse_request 优先级 5 而非 template_redirect:
  避免先走一遍 404 判定与 404 模板。带子目录安装时先剥掉前缀再比对。
- 不抢占用户自己建的页面:如果站里已有同名 slug 的页面就放手。
  否则用户会觉得「我建的页面坏了」,且没有任何提示。

邮件不受影响,且回复邮件同时给出双方内容
- 邮件是独立 HTML,不走 wp_head,结构上就不可能被灰度,已断言验证。
- theme-mail-template 的单个 quote 改成 quotes 列表,一个模板同时渲染
  「被回复的那条」与「新的回复」,最后一块用主色高亮(回复是重点),
  前面的用弱化色。只有一块时退化为主色,与旧行为一致。
- 通过 wpdiscuz_email_content 重建整封邮件,不需要改插件。
  审核通过通知用 wpdiscuz_comment_approved_email_content 走同一模板,
  但它只给 post_id、不给评论对象,能放的内容受此限制。

两个必须记下来的坑
- wpDiscuz 在过滤器之后还会做一次 html_entity_decode 再发信。
  esc_html 的输出会被那一步还原成真标签,评论里的 <script> 直接生效。
  所以返回前先把 & 编码一次。本地 stub 没有这一步,测试看不出来。
- header.php 从来没有调用 wp_body_open,挂在上面的东西全部静默失效。
  不调用不会报错,只会「什么都没发生」。已补在 <body> 之后。

yang_mail_text 保留换行
- 原来用 wp_strip_all_tags($t, true),第二个参数 remove_breaks 会把换行
  压成空格;而 strip_tags 遇到 <br> 是直接删掉不补换行的。
  结果整条评论被拍成一行,模板里的 nl2br 成了死代码。
- 改为先把块级边界降级成 \n,再去标签(remove_breaks=false)。
  收行内空白用 [^\S\n] 而不是 [ \t\f\v]:PCRE 的 \v 是垂直空白类,
  会把 \n 一起匹配掉。

说明页对比度修正(WCAG AA)
- 金色 #b8860b 在白底只有 3.06:1,当装饰色没问题,当 12-15px 文字偏虚。
  拆成 --ym-gold(装饰)与 --ym-gold-text(#946600,4.75:1,仍属金色)。
- 分组小标题 #9aa5b1 只有 2.36:1,改用 #5f6b78(5.12:1)。
- 「今天」标签白字压金底 3.25:1,改用 #8a6100(5.54:1)。
2026-10-23 14:43:47 +08:00

453 lines
19 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
/**
* 邮件治理:统一由主题接管
*
* ── 要解决的问题 ──────────────────────────────────────────────
* 一条评论提交后,收件人收到多封内容重叠的邮件。排查结论是**发信源不止一个**:
*
* 1. WordPress 核心
* · comments_notify = 1 ⇒ 有新评论就通知管理员
* · moderation_notify = 1 ⇒ 有评论待审就通知管理员
* 两者在「评论需审核」时会**同时**命中,管理员一次收到两封。
*
* 2. wpDiscuz 插件(class.WpdiscuzHelperOptimization.php:199-208)
* 一条回复会依次触发:
* sendMailToMentionedUsers 被 @ 的人
* notifyPostSubscribers 订阅了本文的人
* notifyFollowers 关注了作者的人
* notifyAllCommentSubscribers 订阅了全部评论的人
* notifyCommentSubscribers 被回复的那一层作者
* 同一个人如果既订阅了文章又被回复,就收到两封。
* 另外 subscriptionType = 3 表示「订阅全站」,范围最大。
*
* 3. 本主题自己(旧 inc/theme-smtp.php 里的 comment_notify / comment_approved)
* 直接挂在 comment_post 与 comment_unapproved_to_approved 上,
* 与 wpDiscuz 的 notifyCommentSubscribers **完全重复**。
* 而且它用的是一段硬编码 HTML,与插件模板风格完全不一致。
*
* 4. 插件兜底队列(Akismet 的 schedule_email_fallback / schedule_approval_fallback)
* 命中垃圾判定后异步补发审核结果,同样走 wp_mail。
*
* ── 处理原则 ──────────────────────────────────────────────────
* 用户要求「只要主题配置了 SMTP,就一律用主题发邮件,其他的都禁用掉」。
* 注意**不能只关发信通道**:把 phpmailer 抢过来只解决了「谁发」,
* 没解决「发几封」。所以这里分两层:
*
* 第一层 通道:拦住所有 wp_mail 调用,强制套用主题 SMTP 配置。
* 若站点装了别的 SMTP 插件,主题优先;主题没配则完全放手。
*
* 第二层 去重:在通道层做「同一收件人 + 同一事件」的短期去重,
* 并主动关掉重复的钩子、屏蔽核心的旧式通知。
*
* 第二层是关键。只做第一层的话,邮件依然会重复,只是都从同一个 SMTP 发出。
*
* @author Yongquan Yang <yangyq.net>
* @license MIT License
*/
if (!defined('ABSPATH')) {
exit;
}
/**
* 主题 SMTP 是否配置完整且启用
*
* 判断「完整」很重要:只勾了开关但主机没填,会让所有邮件静默失败
* (phpmailer 抛异常被吞),比让它回退到 PHP mail() 更糟。
*/
function yang_mail_smtp_ready()
{
if (!kratos_option('m_smtp', false)) {
return false;
}
$host = trim((string) kratos_option('m_host', ''));
$user = trim((string) kratos_option('m_username', ''));
return ($host !== '' && $user !== '');
}
/**
* 接管 phpmailer 配置
*
* 优先级 9999:其他 SMTP 插件(若以后装了)一般挂在 10,这样主题最后落地,
* 保证「主题优先」。同时先重置掉可能被别的插件设过的值,避免残留半套配置
* (例如别的插件设了 Host 但没设 SMTPSecure,混在一起会静默失败)。
*/
function yang_mail_configure_phpmailer($phpmailer)
{
if (!yang_mail_smtp_ready()) {
return;
}
$phpmailer->isSMTP();
$phpmailer->SMTPAuth = true;
$phpmailer->CharSet = 'UTF-8';
$phpmailer->Host = trim((string) kratos_option('m_host'));
$phpmailer->Port = (int) kratos_option('m_port', 465);
$phpmailer->Username = trim((string) kratos_option('m_username'));
$phpmailer->Password = (string) kratos_option('m_passwd');
$sec = strtolower(trim((string) kratos_option('m_sec', 'ssl')));
// 空串表示明文,但 PHPMailer 对空串和 'none' 处理不同,显式归一
$phpmailer->SMTPSecure = ($sec === '' || $sec === 'none') ? '' : $sec;
// 发件人必须与登录账号一致,否则 QQ / 163 会以「发件人不符」拒收
$phpmailer->From = $phpmailer->Username;
$phpmailer->FromName = get_bloginfo('name');
// 保留 SMTP 的调试信息但不输出,便于 wp_mail_failed 时定位
$phpmailer->SMTPDebug = 0;
$phpmailer->Timeout = 15;
// 有些服务器 TLS 证书链不全,本地判断:只有显式要求时才放宽
if (kratos_option('m_insecure', false)) {
$phpmailer->SMTPOptions = array(
'ssl' => array(
'verify_peer' => false,
'verify_peer_name' => false,
'allow_self_signed' => true,
),
);
}
}
add_action('phpmailer_init', 'yang_mail_configure_phpmailer', 9999);
/**
* 统一 Content-Type 与发件人
*
* 各个环节自己拼 headers 时格式五花八门(有的只写 `Content-Type: text/html`,
* 有的漏 charset),导致中文乱码。这里统一成 UTF-8 HTML,并强制 From。
*/
function yang_mail_normalize_headers($args)
{
if (!yang_mail_smtp_ready()) {
return $args;
}
$from_email = trim((string) kratos_option('m_username'));
// 清掉调用方自己塞的 From,改为统一 From
if (!empty($args['headers'])) {
$raw = $args['headers'];
$list = is_array($raw) ? $raw : preg_split('/\r\n|\r|\n/', (string) $raw);
$kept = array();
foreach ($list as $line) {
$line = trim((string) $line);
if ($line === '') {
continue;
}
// 丢弃 From / Content-Type / MIME-Version,下面统一重建
if (preg_match('/^(from|content-type|mime-version)\s*:/i', $line)) {
continue;
}
$kept[] = $line;
}
$args['headers'] = $kept;
} else {
$args['headers'] = array();
}
$args['headers'][] = 'From: ' . get_bloginfo('name') . ' <' . $from_email . '>';
$args['headers'][] = 'Content-Type: text/html; charset=UTF-8';
return $args;
}
add_filter('wp_mail', 'yang_mail_normalize_headers', 99);
/**
* 邮件去重
*
* 同一收件人 + 同一主题 + 同一正文,在 90 秒内只发一次。
*
* 为什么按「主题 + 正文」而不是只按主题:同一个人可能在同一分钟内被通知
* 两个不同的回复,那是两件真事,不能合并。而这里要压掉的恰恰是
* 「同一件事被多个来源各发一遍」——那种情况下主题和正文往往高度相似。
*
* 用 transient 而不是静态变量:评论通知可能跨请求(wpDiscuz 走 AJAX,
* 审核通知走后台请求),静态变量跨不过去。
*
* 90 秒的取值:一次评论提交引发的所有通知都在同一秒内发生;而用户手动
* 重发(比如 SMTP 失败后重试)通常几分钟后,不该被吞。90 秒足够宽也足够窄。
*/
function yang_mail_dedupe($args)
{
if (!yang_mail_smtp_ready()) {
return $args;
}
// 允许临时关掉(调试、批量补发)
if (apply_filters('yang_mail_dedupe_enabled', true) !== true) {
return $args;
}
$to = $args['to'];
if (is_array($to)) {
$to = implode(',', $to);
}
$to = strtolower(trim((string) $to));
$sig = md5($to . '|' . $args['subject'] . '|' . wp_strip_all_tags((string) $args['message']));
$key = 'yang_mail_dd_' . $sig;
if (get_transient($key) !== false) {
// 标记为已发送过:把收件人清空,wp_mail 会返回 false 但不会真发。
// 不能直接 return null —— 那会让 wp_mail 当作被短路,调用方拿到的
// 返回值含义会变(有的插件据此判断失败并重试,反而更糟)。
if (defined('WP_DEBUG') && WP_DEBUG) {
error_log('[yang_mail] 去重拦截: ' . $to . ' / ' . $args['subject']);
}
$args['to'] = '';
return $args;
}
set_transient($key, 1, 90);
return $args;
}
add_filter('wp_mail', 'yang_mail_dedupe', 100);
/**
* 空收件人直接短路
*
* 上一步的去重把 to 置空,这里拦住,避免 phpmailer 报「no recipient」。
* 优先级放在 pre_wp_mail 上比改 wp_mail 更干净——不侵入核心流程。
*/
function yang_mail_short_circuit($return, $atts)
{
$to = is_array($atts['to']) ? implode(',', $atts['to']) : (string) $atts['to'];
if (trim($to) === '') {
return true; // 假装成功,避免调用方重试
}
return $return;
}
add_filter('pre_wp_mail', 'yang_mail_short_circuit', 10, 2);
/**
* 关掉核心的重复通知
*
* ── 为什么要关 ──
* 站点用了 wpDiscuz,评论通知由插件负责(它能带订阅、退订链接、
* 能按订阅范围精确投递)。核心那套 `wp_notify_postauthor` / `wp_notify_moderator`
* 是给原生评论系统设计的纯文本通知,在 wpDiscuz 场景下是**纯重复**:
* 管理员会同时收到「核心:新评论待审」和「插件:新评论」,内容重叠。
*
* ── 为什么不直接改后台选项 ──
* 改 `comments_notify` / `moderation_notify` 是「用户设置」,主题替用户改
* 数据库里的设置属于越权,而且用户下次进后台一勾又回来了。
* 用 filter 拦截是**可逆的**:把这段代码删掉立刻恢复原状,不动用户数据。
*
* ── 只关通知,不关审核 ──
* 注意这里只拦 `notify_*` 两个 filter,评论审核流程(comment_moderation)
* 完全不受影响,评论该进待审还是进待审。
*/
function yang_mail_disable_core_notify($maybe, $comment_id)
{
if (!yang_mail_smtp_ready()) {
return $maybe;
}
// 用户在主题选项里可以保留核心通知(默认关)
if (kratos_option('m_keep_core_notify', false)) {
return $maybe;
}
return false;
}
add_filter('notify_postauthor', 'yang_mail_disable_core_notify', 99, 2);
add_filter('notify_moderator', 'yang_mail_disable_core_notify', 99, 2);
/**
* 关掉旧主题自己那两条评论邮件
*
* 旧版 inc/theme-smtp.php 挂了 comment_notify(回复通知)和
* comment_approved(审核通过通知)。这两条与 wpDiscuz 完全重复,
* 而且模板是硬编码的,风格与插件不一致。
*
* 新版 theme-smtp.php 已经不再注册这两个钩子;这里再加一道保险,
* 防止升级时旧文件残留导致重复注册(WordPress 升级是整目录替换,
* 理论上不会残留,但 history 上确实踩过文件漂移)。
*/
function yang_mail_remove_legacy_hooks()
{
if (!yang_mail_smtp_ready()) {
return;
}
remove_action('comment_post', 'comment_notify');
remove_action('comment_unapproved_to_approved', 'comment_approved');
}
add_action('init', 'yang_mail_remove_legacy_hooks', 99);
/**
* 发一封「测试邮件」,用主题模板渲染
*
* 后台「邮件配置」里的测试按钮走这条,便于用户确认模板效果与 SMTP 连通性。
*/
function yang_mail_send_test($to = null)
{
if ($to === null) {
$to = get_bloginfo('admin_email');
}
if (!is_email($to)) {
return new WP_Error('bad_email', __('收件地址无效', 'kratos'));
}
$body = yang_mail_render(array(
'title' => sprintf(__('%s 邮件配置测试', 'kratos'), get_bloginfo('name')),
'preheader' => __('这是一封测试邮件,说明 SMTP 配置可用。', 'kratos'),
'badge' => __('测试邮件', 'kratos'),
'heading' => __('邮件配置测试成功', 'kratos'),
'intro' => __('如果你收到了这封邮件,说明主题的 SMTP 配置可用,评论通知也会以同样的样式发出。', 'kratos'),
'rows' => array(
array('label' => __('发信服务器', 'kratos'), 'value' => kratos_option('m_host')),
array('label' => __('端口', 'kratos'), 'value' => kratos_option('m_port')),
array('label' => __('加密方式', 'kratos'), 'value' => strtoupper((string) kratos_option('m_sec', 'ssl'))),
array('label' => __('发件账号', 'kratos'), 'value' => kratos_option('m_username'), 'strong' => true),
array('label' => __('发送时间', 'kratos'), 'value' => date_i18n('Y-m-d H:i:s')),
),
'cta_text' => __('访问站点', 'kratos'),
'cta_url' => home_url('/'),
'note' => __('这是手动触发的测试邮件,可以忽略。', 'kratos'),
));
return wp_mail($to, sprintf(__('[%s] 邮件配置测试', 'kratos'), get_bloginfo('name')), $body);
}
/**
* 抵消 wpDiscuz 在过滤器之后又做的那一次 html_entity_decode
*
* ── 为什么必须要这一步 ────────────────────────────────────────
* `wpdiscuz_email_content` 这个过滤器**不是最后一站**。插件拿到返回值后
* 还会执行:
*
* $message = html_entity_decode($message, ENT_QUOTES);
* wp_mail($email, $subject, $message, $headers, $attachments);
*
* (utils/class.WpdiscuzHelperEmail.php:494 附近)
*
* 于是我们在模板里用 esc_html() 做的转义会被整体还原:
* 用户评论里的 `<script>alert(1)</script>` 会变回可执行标签,
* 未闭合的 `<div>` 会吃掉整封邮件的布局。
*
* 这**在本地用桩环境测不出来** —— 桩环境没有插件那一步解码,
* 断言只会看到"已转义"而全绿。只有读插件源码才发现。
*
* 解法:把 `&` 全部预编码成 `&amp;`。这样插件解码一次之后,
* 恰好还原成我们想要的转义状态(decode("&amp;lt;") === "&lt;")。
* 只处理 `&` 就够,因为其它实体的风险都源于 `&` 被还原。
*/
function yang_mail_escape_for_wpdiscuz($html)
{
return str_replace('&', '&amp;', $html);
}
/**
* 把 wpDiscuz 的通知邮件接管成主题模板
*
* ── 为什么要接管 ──────────────────────────────────────────────
* wpDiscuz 自己的模板只印「新评论内容」,收件人看到一句陌生人说的话,
* 完全想不起自己在哪篇文章下说过什么。要理解上下文,必须**同时给出
* 被回复的那条评论**。
*
* 它的过滤器 `wpdiscuz_email_content` 能拿到 `$comment`(新评论对象)
* 和 `$emailData`(订阅记录),足以重建整封邮件,所以不需要改插件。
*
* ── 两个模板还是一个模板 ──────────────────────────────────────
* 用一个。评论通知和「审核通过」通知的差别只有标题、徽标、引言和
* 上面那两段引用,正文骨架完全一样。做成两套模板会立刻分叉,
* 以后改一次配色要改两处。
*
* @param string $message 插件生成的 HTML(丢弃)
* @param WP_Comment $comment 新评论
* @param array $email_data 订阅记录
* @return string
*/
function yang_mail_render_comment_notify($message, $comment, $email_data = array())
{
if (!yang_mail_smtp_ready() || !($comment instanceof WP_Comment)) {
return $message;
}
$site = get_bloginfo('name');
$post_id = (int) $comment->comment_post_ID;
$post = get_post($post_id);
$title = $post ? get_the_title($post) : __('(文章已删除)', 'kratos');
$link = get_comment_link($comment->comment_ID);
$author = $comment->comment_author ? $comment->comment_author : __('匿名访客', 'kratos');
// 新评论这一段(一定是最后一块,模板会把它高亮成主色)
$quotes = array();
// 被回复的原评论:有父评论才取,取不到就不放,宁缺毋滥
$parent_id = (int) $comment->comment_parent;
if ($parent_id > 0) {
$parent = get_comment($parent_id);
if ($parent) {
$p_author = $parent->comment_author ? $parent->comment_author : __('匿名访客', 'kratos');
$quotes[] = array(
'label' => sprintf(__('你之前说的(%s)', 'kratos'), $p_author),
'content' => yang_mail_text($parent->comment_content, 1200),
'meta' => mysql2date('Y-m-d H:i', $parent->comment_date),
);
}
}
$quotes[] = array(
'label' => sprintf(__('%s 回复说', 'kratos'), $author),
'content' => yang_mail_text($comment->comment_content, 1200),
'meta' => mysql2date('Y-m-d H:i', $comment->comment_date),
);
// 引言随场景变化:有父评论是「有人回复你」,没有是「有新评论」
$intro = $parent_id > 0
? sprintf(__('你在《%s》下的评论收到了一条回复。', 'kratos'), $title)
: sprintf(__('《%s》下有新的评论。', 'kratos'), $title);
$rows = array(
array('label' => __('文章', 'kratos'), 'value' => $title),
array('label' => __('评论者', 'kratos'), 'value' => $author, 'strong' => true),
array('label' => __('时间', 'kratos'), 'value' => mysql2date('Y-m-d H:i', $comment->comment_date)),
);
$body = yang_mail_render(array(
'title' => sprintf(__('%s · 新回复', 'kratos'), $site),
'preheader' => sprintf(__('%s 在《%s》下回复了评论。', 'kratos'), $author, $title),
'badge' => $parent_id > 0 ? __('新回复', 'kratos') : __('新评论', 'kratos'),
'heading' => $parent_id > 0 ? __('有人回复了你的评论', 'kratos') : __('文章有新评论', 'kratos'),
'intro' => $intro,
'quotes' => $quotes,
'rows' => $rows,
'cta_text' => __('查看完整讨论', 'kratos'),
'cta_url' => $link,
'note' => __('你收到这封邮件,是因为你订阅了这篇文章的评论,或你的评论被回复了。', 'kratos'),
));
return yang_mail_escape_for_wpdiscuz($body);
}
add_filter('wpdiscuz_email_content', 'yang_mail_render_comment_notify', 20, 3);
/**
* 审核通过通知也套同一套模板
*
* 这个过滤器的参数是 ($message, $post_id, $email),拿不到评论对象,
* 所以只能给「你的评论已通过审核」这种通用内容 —— 这是插件接口的限制,
* 不是这里偷懒。
*/
function yang_mail_render_approved_notify($message, $post_id, $email)
{
if (!yang_mail_smtp_ready()) {
return $message;
}
$title = get_the_title($post_id);
return yang_mail_escape_for_wpdiscuz(yang_mail_render(array(
'title' => sprintf(__('%s · 评论已通过审核', 'kratos'), get_bloginfo('name')),
'preheader' => sprintf(__('你在《%s》下的评论已通过审核并公开显示。', 'kratos'), $title),
'badge' => __('已通过', 'kratos'),
'heading' => __('你的评论已通过审核', 'kratos'),
'intro' => sprintf(__('你在《%s》下提交的评论已经审核通过,现在其他读者可以看到它了。', 'kratos'), $title),
'rows' => array(
array('label' => __('文章', 'kratos'), 'value' => $title),
),
'cta_text' => __('去看看', 'kratos'),
'cta_url' => get_permalink($post_id),
'note' => __('这是评论审核结果通知,无需回复。', 'kratos'),
)));
}
add_filter('wpdiscuz_comment_approved_email_content', 'yang_mail_render_approved_notify', 20, 3);