Files
wordpress-kratos-yang/inc/theme-mail-template.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

374 lines
15 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. **必须是表格布局**。Outlook 2007-2019 用 Word 的排版引擎,完全不支持
* flex / grid,连 `max-width` 都不稳。只有 `<table>` + `cellpadding` 在老
* Outlook 里可靠。现在的 web 布局写法在邮件里必崩。
*
* 2. **样式必须内联**。Gmail 会把 `<head>` 里的 `<style>` 全部剥掉(含 media query),
* QQ 邮箱、网易邮箱同样。所以每个 `td` 都要带 `style="..."`,不能依赖 class。
*
* 3. **宽度 600px**。这是邮件客户端预览窗格的通用安全宽度。用
* `width="600"` 属性 + `style="width:600px;max-width:600px"` 双保险。
*
* 4. **不用外部图片做装饰**。邮件里的外链图默认被 Gmail / QQ 邮箱屏蔽,
* 用户看到的是一堆灰框。所有视觉元素用纯色块 + 圆角 + 文字实现。
*
* 5. **不用 CSS 变量**。邮件客户端不支持,写了就是丢失样式。
*
* 6. 深色模式:Android Gmail / Apple Mail 会反色。这里显式声明
* `color-scheme` 与 `supported-color-schemes`,并避免纯黑纯白。
*
* @author Yongquan Yang <yangyq.net>
* @license MIT License
*/
if (!defined('ABSPATH')) {
exit;
}
/**
* 站点主色(可被选项覆盖)
*
* 取主题的主色选项;取不到就用一个稳妥的深蓝灰。
* 不用亮色,因为邮件背景是浅灰,亮色正文对比度不够。
*/
function yang_mail_brand_color()
{
$c = kratos_option('top_color', '#24292e');
if (!preg_match('/^#[0-9a-fA-F]{6}$/', (string) $c)) {
$c = '#24292e';
}
return $c;
}
/**
* 把主色调暗一档,用作渐变末端 / 描边
*
* 邮件里不能用 `filter: brightness()`(不支持),只能算。
*/
function yang_mail_shade($hex, $ratio = 0.82)
{
$hex = ltrim($hex, '#');
if (strlen($hex) !== 6) {
return '#24292e';
}
$out = '#';
foreach (str_split($hex, 2) as $pair) {
$v = max(0, min(255, (int) round(hexdec($pair) * $ratio)));
$out .= str_pad(dechex($v), 2, '0', STR_PAD_LEFT);
}
return $out;
}
/**
* 判断一个颜色是深是浅,决定叠在上面的文字用白还是深色
*
* 用相对亮度公式(WCAG),不是简单取平均 —— 人眼对绿最敏感、蓝最不敏感,
* 直接平均会把 #00ff00 判成中等亮度。
*/
function yang_mail_is_dark($hex)
{
$hex = ltrim((string) $hex, '#');
if (strlen($hex) !== 6) {
return true;
}
$r = hexdec(substr($hex, 0, 2)) / 255;
$g = hexdec(substr($hex, 2, 2)) / 255;
$b = hexdec(substr($hex, 4, 2)) / 255;
$lin = function ($c) {
return $c <= 0.03928 ? $c / 12.92 : pow(($c + 0.055) / 1.055, 2.4);
};
$L = 0.2126 * $lin($r) + 0.7152 * $lin($g) + 0.0722 * $lin($b);
return $L < 0.5;
}
/**
* 把可能含 HTML 的片段转成邮件安全的纯文本
*
* 邮件正文里塞用户提交的评论内容,必须防注入:邮件客户端也会执行
* `<script>`(部分 webmail 会),而且一旦渲染出畸形 HTML,整封邮件布局就崩。
*/
function yang_mail_text($html, $limit = 0)
{
$t = wp_strip_all_tags((string) $html, true);
$t = html_entity_decode($t, ENT_QUOTES, 'UTF-8');
$t = preg_replace('/\s+/u', ' ', $t);
$t = trim($t);
if ($limit > 0 && mb_strlen($t, 'UTF-8') > $limit) {
$t = mb_substr($t, 0, $limit, 'UTF-8') . '…';
}
return $t;
}
/**
* 把主色与白色按比例混合,得到一档浅色
*
* 为什么不用 rgba:Outlook(Word 引擎)完全不支持 rgba(),
* 写了会当成非法值丢弃 ⇒ 半透明背景变成完全透明,badge 直接"消失"。
* 所以这里预先算出实色。$ratio 是白色的占比(0=纯主色,1=纯白)。
*/
function yang_mail_tint($hex, $ratio = 0.18, $onto = '#ffffff')
{
$hex = ltrim((string) $hex, '#');
$onto = ltrim((string) $onto, '#');
if (strlen($hex) !== 6 || strlen($onto) !== 6) {
return '#ffffff';
}
$out = '#';
for ($i = 0; $i < 3; $i++) {
$a = hexdec(substr($hex, $i * 2, 2));
$b = hexdec(substr($onto, $i * 2, 2));
$v = (int) round($a + ($b - $a) * $ratio);
$out .= str_pad(dechex(max(0, min(255, $v))), 2, '0', STR_PAD_LEFT);
}
return $out;
}
/**
* 生成一个按钮(邮件里的按钮只能用表格单元格做,不能用 <button>)
*
* Outlook 不认 `<a>` 的 `padding`,必须把 padding 放在 `<td>` 上。
*/
function yang_mail_button($text, $url, $bg = null)
{
if ($bg === null) {
$bg = yang_mail_brand_color();
}
$fg = yang_mail_is_dark($bg) ? '#ffffff' : '#1a1a1a';
$url = esc_url($url);
$text = esc_html($text);
return '<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="margin:0 auto;">'
. '<tr><td align="center" bgcolor="' . esc_attr($bg) . '" style="border-radius:6px;padding:13px 34px;">'
. '<a href="' . $url . '" target="_blank" rel="noopener" '
. 'style="display:inline-block;font-family:-apple-system,BlinkMacSystemFont,\'Segoe UI\',Roboto,\'Helvetica Neue\',Arial,\'PingFang SC\',\'Microsoft YaHei\',sans-serif;'
. 'font-size:15px;font-weight:600;line-height:1;color:' . $fg . ';text-decoration:none;letter-spacing:0.3px;">'
. $text . '</a></td></tr></table>';
}
/**
* 渲染整封邮件
*
* @param array $args {
* @type string $title 邮件主标题(收件箱预览也会看到)
* @type string $preheader 预读文字,显示在收件箱标题后面,不影响正文
* @type string $heading 正文第一行大字
* @type string $intro 正文引导段落(纯文本)
* @type array $rows 表格数据,每项 array('label'=>, 'value'=>, 'strong'=>bool)
* @type array $quote 引用块 array('label'=>, 'content'=>, 'meta'=>)
* @type string $cta_text 按钮文字
* @type string $cta_url 按钮链接
* @type string $note 底部提示(纯文本)
* @type string $badge 顶部小标签,如「新评论」
* }
* @return string 完整 HTML
*/
function yang_mail_render($args)
{
$d = wp_parse_args($args, array(
'title' => get_bloginfo('name'),
'preheader' => '',
'heading' => '',
'intro' => '',
'rows' => array(),
'quote' => array(),
'cta_text' => '',
'cta_url' => '',
'note' => '',
'badge' => '',
));
$brand = yang_mail_brand_color();
$brandD = yang_mail_shade($brand, 0.78);
$onBrand = yang_mail_is_dark($brand) ? '#ffffff' : '#1a1a1a';
// badge 的背景:把主色往白色方向提亮一档,得到比主色浅、但同色相的实色。
// 主色本身是深色时提亮 22%,浅色时压暗 —— 否则浅主色上叠浅 badge 会看不见。
$badgeBg = yang_mail_is_dark($brand)
? yang_mail_tint($brand, 0.22)
: yang_mail_shade($brand, 0.88);
$badgeBd = yang_mail_is_dark($brand)
? yang_mail_tint($brand, 0.34)
: yang_mail_shade($brand, 0.72);
$site = get_bloginfo('name');
$home = home_url('/');
$year = date('Y');
// 字体栈:中文优先 PingFang / 微软雅黑,英文走系统默认。
// 邮件里不能用 @font-face(Gmail 剥掉),只能靠系统字体。
$sans = "-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,'PingFang SC','Hiragino Sans GB','Microsoft YaHei',sans-serif";
ob_start();
?>
<!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">
<meta name="color-scheme" content="light">
<meta name="supported-color-schemes" content="light">
<title><?php echo esc_html($d['title']); ?></title>
<!--[if mso]>
<style type="text/css">
body,table,td,p,a{font-family:Arial,'Microsoft YaHei',sans-serif !important;}
</style>
<![endif]-->
</head>
<body style="margin:0;padding:0;width:100%;background-color:#eef1f4;">
<?php /* 预读文字:显示在收件箱列表里标题的后面。藏起来但必须真实存在,且要足够长,
否则客户端会把正文第一句抓上去当摘要。 */ ?>
<div style="display:none;font-size:1px;color:#eef1f4;line-height:1px;max-height:0;max-width:0;opacity:0;overflow:hidden;">
<?php echo esc_html($d['preheader'] ? $d['preheader'] : $d['heading']); ?>
</div>
<table border="0" cellpadding="0" cellspacing="0" width="100%" role="presentation" style="background-color:#eef1f4;">
<tr><td align="center" style="padding:32px 12px;">
<?php /* 宽度写法是「混合式」:
· Outlook(Word 引擎)只认 HTML 属性 width="600",CSS 的 max-width 它当没看见;
· 其它客户端认 CSS,于是 width:100% + max-width:600px 让手机窄屏自动收窄。
两套并存,各取所需。只写死 600px 会让手机横向滚动——实测过。 */ ?>
<table border="0" cellpadding="0" cellspacing="0" width="600" role="presentation" style="width:100%;max-width:600px;background-color:#ffffff;border-radius:12px;overflow:hidden;border:1px solid #e3e7ec;">
<?php /* ── 顶部品牌条 ── */ ?>
<tr>
<td bgcolor="<?php echo esc_attr($brand); ?>" style="background-color:<?php echo esc_attr($brand); ?>;padding:0;">
<table border="0" cellpadding="0" cellspacing="0" width="100%" role="presentation">
<tr>
<td style="padding:26px 32px;">
<div style="font-family:<?php echo $sans; ?>;font-size:19px;font-weight:600;color:<?php echo esc_attr($onBrand); ?>;letter-spacing:0.2px;line-height:1.3;">
<?php echo esc_html($site); ?>
</div>
<?php if ($d['badge'] !== ''): ?>
<?php /* badge 用预算好的实色,不用 rgba —— Outlook 不认 rgba,会直接丢掉背景 */ ?>
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="margin-top:10px;">
<tr>
<td bgcolor="<?php echo esc_attr($badgeBg); ?>" style="background-color:<?php echo esc_attr($badgeBg); ?>;border:1px solid <?php echo esc_attr($badgeBd); ?>;border-radius:20px;padding:4px 12px;font-family:<?php echo $sans; ?>;font-size:12px;font-weight:500;line-height:1.4;">
<span style="color:<?php echo esc_attr($onBrand); ?>;"><?php echo esc_html($d['badge']); ?></span>
</td>
</tr>
</table>
<?php endif; ?>
</td>
</tr>
</table>
</td>
</tr>
<?php /* 主色条下沿压一条深色,给扁平色块一点层次(渐变在 Outlook 里不生效,用纯色)。
height 必须**同时**写成 HTML 属性和 CSS:Outlook 只认属性,
只写 CSS 高度时这条 3px 带会撑成一行文字高(约 18px),比例就毁了。 */ ?>
<tr>
<td bgcolor="<?php echo esc_attr($brandD); ?>" height="3" style="background-color:<?php echo esc_attr($brandD); ?>;height:3px;line-height:3px;font-size:0;mso-line-height-rule:exactly;">&nbsp;</td>
</tr>
<?php /* ── 正文 ── */ ?>
<tr>
<td style="padding:34px 32px 8px 32px;">
<?php if ($d['heading'] !== ''): ?>
<div style="font-family:<?php echo $sans; ?>;font-size:21px;font-weight:600;color:#1a2028;line-height:1.45;margin:0 0 12px 0;">
<?php echo esc_html($d['heading']); ?>
</div>
<?php endif; ?>
<?php if ($d['intro'] !== ''): ?>
<div style="font-family:<?php echo $sans; ?>;font-size:15px;color:#4a5560;line-height:1.7;margin:0 0 22px 0;">
<?php echo esc_html($d['intro']); ?>
</div>
<?php endif; ?>
<?php if (!empty($d['quote'])): ?>
<?php /* 引用块:左边一条主色竖线,用 border-left 而不是伪元素(邮件不支持 ::before) */ ?>
<table border="0" cellpadding="0" cellspacing="0" width="100%" role="presentation" style="margin:0 0 22px 0;">
<tr>
<td style="border-left:3px solid <?php echo esc_attr($brand); ?>;background-color:#f6f8fa;padding:16px 18px;border-radius:0 8px 8px 0;">
<?php if (!empty($d['quote']['label'])): ?>
<div style="font-family:<?php echo $sans; ?>;font-size:12px;font-weight:600;color:#5f6b78;letter-spacing:0.6px;text-transform:uppercase;margin:0 0 8px 0;">
<?php echo esc_html($d['quote']['label']); ?>
</div>
<?php endif; ?>
<div style="font-family:<?php echo $sans; ?>;font-size:15px;color:#2d3740;line-height:1.7;word-break:break-word;">
<?php echo nl2br(esc_html($d['quote']['content'])); ?>
</div>
<?php if (!empty($d['quote']['meta'])): ?>
<div style="font-family:<?php echo $sans; ?>;font-size:13px;color:#5f6b78;line-height:1.6;margin:9px 0 0 0;">
<?php echo esc_html($d['quote']['meta']); ?>
</div>
<?php endif; ?>
</td>
</tr>
</table>
<?php endif; ?>
<?php if (!empty($d['rows'])): ?>
<?php /* 信息表:两列。label 列定宽 88px 并右对齐,value 左对齐,视觉上最稳。
不用 table-layout:fixed 之外的花样,Outlook 只认这个。 */ ?>
<table border="0" cellpadding="0" cellspacing="0" width="100%" role="presentation" style="margin:0 0 24px 0;border-top:1px solid #edf0f3;">
<?php foreach ($d['rows'] as $row): ?>
<tr>
<td width="88" valign="top" style="width:88px;padding:11px 0;border-bottom:1px solid #edf0f3;font-family:<?php echo $sans; ?>;font-size:13px;color:#5f6b78;line-height:1.6;white-space:nowrap;">
<?php echo esc_html($row['label']); ?>
</td>
<td valign="top" style="padding:11px 0 11px 16px;border-bottom:1px solid #edf0f3;font-family:<?php echo $sans; ?>;font-size:14px;color:#2d3740;line-height:1.6;word-break:break-word;">
<?php
$v = isset($row['value']) ? $row['value'] : '';
if (!empty($row['strong'])) {
echo '<strong style="font-weight:600;">' . esc_html($v) . '</strong>';
} else {
echo esc_html($v);
}
?>
</td>
</tr>
<?php endforeach; ?>
</table>
<?php endif; ?>
<?php if ($d['cta_text'] !== '' && $d['cta_url'] !== ''): ?>
<div style="margin:0 0 28px 0;">
<?php echo yang_mail_button($d['cta_text'], $d['cta_url']); ?>
</div>
<?php endif; ?>
<?php if ($d['note'] !== ''): ?>
<div style="font-family:<?php echo $sans; ?>;font-size:13px;color:#5f6b78;line-height:1.7;padding:16px 0 0 0;border-top:1px solid #edf0f3;">
<?php echo esc_html($d['note']); ?>
</div>
<?php endif; ?>
</td>
</tr>
<?php /* ── 页脚 ── */ ?>
<tr>
<td style="padding:24px 32px 30px 32px;">
<div style="border-top:1px solid #edf0f3;padding-top:20px;font-family:<?php echo $sans; ?>;font-size:12px;color:#5f6b78;line-height:1.9;">
<div style="margin:0 0 6px 0;">
<a href="<?php echo esc_url($home); ?>" target="_blank" rel="noopener" style="color:#3d4752;text-decoration:none;font-weight:600;"><?php echo esc_html($site); ?></a>
</div>
<div style="margin:0;">此邮件由系统自动发出,请勿直接回复。</div>
<div style="margin:6px 0 0 0;color:#6b7684;">&copy; <?php echo esc_html($year); ?> <?php echo esc_html($site); ?></div>
</div>
</td>
</tr>
</table>
<?php /* 底部说明:为什么收到这封邮件。放在卡片外面,视觉上属于系统信息 */ ?>
<div style="font-family:<?php echo $sans; ?>;font-size:12px;color:#5f6b78;line-height:1.7;padding:18px 8px 0 8px;max-width:600px;">
您收到这封邮件,是因为本站记录了与您邮箱相关的订阅或互动。
</div>
</td></tr>
</table>
</body>
</html>
<?php
return ob_get_clean();
}