Files
wordpress-kratos-yang/inc/theme-mail.php
T
laoyang 37c52948c4 版本号 3.2.5 -> 3.2.6(统一邮件出口 + 邮件模板 + 纪念日自动哀悼 + 去掉广告位)
评论邮件重复的根因是发信源不止一个,分两层处理:一层管「谁发」,一层管「发几封」。
只做前者的话邮件照样重复,只是都从同一个 SMTP 出去。

邮件治理(新增 inc/theme-mail.php)
- phpmailer_init 优先级 9999 接管通道,保证主题配置优先于其它 SMTP 插件;
  接管前先重置可能被别的插件设过的字段,避免残留半套配置。
- From 强制等于 SMTP 登录账号。QQ / 163 会以「发件人不符」拒收。
- wp_mail 优先级 99 统一 From 与 Content-Type;调用方自带的 From 与
  Content-Type 被剥离后重建,自定义 header 保留。
- wp_mail 优先级 100 做短期去重:同一收件人 + 同主题 + 同正文,90 秒内只发一次。
  用 transient 是因为评论通知跨请求(wpDiscuz 走 AJAX、审核通知走后台请求),
  静态变量跨不过去。90 秒足够宽(一次提交引发的通知都在同一秒)也足够窄
  (手动重发通常隔几分钟,不该被吞)。
- 被去重的那次把 to 置空并由 pre_wp_mail 返回 true,而不是 return null:
  返回 null 会改变 wp_mail 返回值语义,有的插件据此判定失败并重试,反而更多封。
- 用 filter 关掉核心的 notify_postauthor / notify_moderator,不改进数据库里的
  comments_notify / moderation_notify。改用户设置属于越权,而且用户下次进后台
  一勾又回来了;filter 是可逆的。只拦通知,不影响评论审核流程。
- 线上站点 m_smtp=1 但 m_host 为空时判定为「未就绪」并完全放手。只勾开关不填
  主机时若强行接管,所有邮件会静默失败,比回退到 mail() 更糟。

邮件模板(新增 inc/theme-mail-template.php)
- 表格布局 + 全内联样式:Outlook 2007-2019 用 Word 引擎,不认 flex / grid;
  Gmail 会剥掉 <head> 里的 style 块(含 media query)。
- 不用外部图片、不用 CSS 变量、不用 ::before。按钮的 padding 放在 td 上,
  因为 Outlook 忽略 a 的 padding。
- 宽度用混合写法:HTML 属性 width=600 给 Outlook,CSS 的
  width:100% + max-width:600px 让手机窄屏自动收窄。只写死 600px 会让手机横向滚动。
- badge 背景用预算的实色而非 rgba。Outlook 不支持 rgba,会当成非法值丢掉,
  半透明背景变成全透明,badge 直接消失。
- 主色下沿那条 3px 深色带必须 height 同时写成 HTML 属性和 CSS,
  否则 Outlook 把它撑成一行文字高,比例就毁了。
- 用 WCAG 相对亮度判断主色深浅来决定叠在上面的文字用白还是深色。
  简单取平均会把 #00ff00 判成中等亮度(人眼对绿最敏感、蓝最不敏感)。
- 次要文字统一调到 #5f6b78。原来的浅灰在白底上只有 1.84:1,看着"高级"但读不出来;
  现在最低 4.62:1,全部过 AA。

theme-smtp.php
- 删掉 comment_notify / comment_approved 两个钩子及其硬编码模板。这两个钩子与
  wpDiscuz 的 notifyCommentSubscribers 完全重复,是重复收信的直接来源。

纪念日自动哀悼(新增 inc/theme-mourning.php)
- 法定纪念日三个默认勾选:09-03 抗战胜利纪念日、09-30 烈士纪念日、
  12-13 南京大屠杀死难者国家公祭日。依据是 2014 年全国人大常委会两次决定,
  已在代码注释里记下来源。
- 民间通行做法(05-12、09-18 等)默认不勾,由用户自己决定。替用户做价值判断不合适。
- 一次性全国哀悼日(2020-04-04 等)支持 YYYY-MM-DD 写法,只在当年生效。
  不能当周年循环,否则明年 4 月 4 日又自动变黑白。
- 按站点时区判断(current_time),不按服务器时区。服务器在 UTC 时会晚 8 小时生效。
- 只挂 wp_head 而不限定 is_home():老代码限定首页,详情页不黑白,那是 bug。
- 打印时取消灰度,否则打印出来是灰的。
- 保留旧的 g_rip 手动常开开关。不能因为升级把原本黑白中的站点变回彩色。

options-framework
- 补 multicheck 的渲染分支与 of_sanitize_multicheck。框架原来只在保存侧处理了
  这个类型,没有渲染分支,所以这个类型此前根本不可用。
- 值存成 array(键 => 1|0) 而不是「勾选了的键组成的列表」:列表无法区分
  「用户明确全部取消」和「从未配置」,会导致取消勾选后静默回退到默认值。
- 读取侧用 array_key_exists 判断是否保存过,而不是判断值是否为空。
- 配套补上 of-multicheck 样式,没有样式时 9 个选项会挤成一行流水文字。

去掉广告配置
- 删除主题选项里的广告配置段,以及 single.php 里上下两处广告位输出。

验证
- 全主题 68 个 PHP 文件语法检查通过。
- 行为测试 82 条、multicheck 闭环 25 条、邮件几何 15 条、设计审计 23 条。
- 反向验证 16 个注入 bug 全部被断言抓住(去重失效、不写标记、to 未清空、
  From 未剥离、亮度算法退化、时区改用 date()、全不勾回退默认等)。
- 邮件最低文字对比度 4.62:1;320px 视口下卡片自动收窄到 296px 不溢出。
2026-10-23 13:55:23 +08:00

309 lines
13 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);
}