版本号 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 不溢出。
This commit is contained in:
2026-10-23 13:55:23 +08:00
parent 4f0d19fb91
commit 37c52948c4
14 changed files with 1157 additions and 195 deletions
@@ -308,3 +308,54 @@
#optionsframework .about-content .tips b {
margin-left: 3px;
}
/* ── 多选复选框组(multicheck)────────────────────────────────
框架原本没有这个类型的渲染分支,所以也没有配套样式。
不加样式的话,9 个纪念日会挤成一行流水文字,看不出是一个可勾选的清单。
这里做成两列表格状,并把说明文字压到第二行、颜色调淡,
让「名称」是视觉主体、「日期说明」是补充信息。 */
#optionsframework .of-multicheck {
margin: 0;
padding: 2px 0 0;
border: 0;
}
#optionsframework .of-multicheck-item {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 9px 12px;
margin: 0 0 2px;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.15s ease;
}
#optionsframework .of-multicheck-item:hover {
background-color: #f3f5f7;
}
#optionsframework .of-multicheck-item input[type="checkbox"] {
margin: 3px 0 0;
flex: 0 0 auto;
}
/* 名称与说明两行文字共用的竖排容器 */
#optionsframework .of-multicheck-text {
display: flex;
flex-direction: column;
flex: 1 1 auto;
}
#optionsframework .of-multicheck-label {
font-size: 14px;
color: #23282d;
line-height: 1.5;
}
#optionsframework .of-multicheck-note {
font-size: 12px;
color: #727777;
line-height: 1.5;
margin-top: 3px;
}
@@ -210,6 +210,46 @@ class Options_Framework_Interface
$output .= '<label class="explain" for="' . esc_attr($value['id']) . '">' . wp_kses($explain_value, $allowedtags) . '</label>';
break;
// 多选复选框组
//
// 框架原来只在保存侧处理了 multicheck(把未提交的项补 false),
// **没有渲染分支、也没有 of_sanitize_multicheck**,
// 所以这个类型此前是不可用的。这里补齐两处。
//
// 值统一存成 array( 键 => 1|0 ),而不是「勾选了的键组成的列表」:
// 用 map 才能区分「用户明确取消勾选」和「这个键是新增的」。
// 若存成列表,用户取消全部勾选后存进去是空数组,
// 读的时候无法与「从未配置」区分,会静默回退到默认值。
case "multicheck":
$name = $option_name . '[' . $value['id'] . ']';
$saved = is_array($val) ? $val : array();
if (!empty($value['options'])) {
$output .= '<fieldset class="of-multicheck">';
foreach ($value['options'] as $key => $opt) {
$id = $option_name . '-' . $value['id'] . '-' . $key;
$on = !empty($saved[$key]);
$label = is_array($opt) && isset($opt['name']) ? $opt['name'] : $opt;
$note = is_array($opt) && isset($opt['note']) ? $opt['note'] : '';
$output .= '<label class="of-multicheck-item" for="' . esc_attr($id) . '">';
$output .= '<input class="checkbox of-input" type="checkbox" '
. 'id="' . esc_attr($id) . '" '
. 'name="' . esc_attr($name . '[' . $key . ']') . '" '
. 'value="1" ' . checked($on, true, false) . ' />';
// 名称与说明必须包在同一个竖排容器里。
// 若让它们各自作为 label 的直接子项,flex 布局会把
// 说明挤到名称右边同一行(不是名称下方)。
$output .= '<span class="of-multicheck-text">';
$output .= '<span class="of-multicheck-label">' . esc_html($label) . '</span>';
if ($note !== '') {
$output .= '<span class="of-multicheck-note">' . esc_html($note) . '</span>';
}
$output .= '</span>';
$output .= '</label>';
}
$output .= '</fieldset>';
}
break;
// Color picker
case "color":
$default_color = '';
@@ -95,6 +95,36 @@ function of_sanitize_checkbox( $input ) {
}
add_filter( 'of_sanitize_checkbox', 'of_sanitize_checkbox' );
/**
* Sanitization for multicheck input
*
* 保存成 array( 键 => 1|0 ),键表来自选项定义里的 options。
*
* 关键点:**所有已知键都要出现在结果里**,未勾选的写 0。
* 如果只保留勾选的键,用户取消全部勾选后存进去是空数组,
* 读取时无法与「这个选项从未配置过」区分,会静默回退到默认值,
* 表现成「取消勾选不生效」。这是同类字段最常见的 bug。
*
* 输入的键先与选项定义里的白名单求交集,防止用户 POST 伪造任意键。
*
* @param array $input 提交上来的值
* @param array $option 选项定义(含 options 白名单)
* @return array
*/
function of_sanitize_multicheck( $input, $option ) {
$output = array();
if ( ! isset( $option['options'] ) || ! is_array( $option['options'] ) ) {
return $output;
}
$input = is_array( $input ) ? $input : array();
foreach ( $option['options'] as $key => $value ) {
// 只认白名单里的键;值为真即视为勾选
$output[ $key ] = ( isset( $input[ $key ] ) && $input[ $key ] ) ? 1 : 0;
}
return $output;
}
add_filter( 'of_sanitize_multicheck', 'of_sanitize_multicheck', 10, 2 );
/**
* File upload sanitization.
*