Files
wordpress-kratos-yang/inc/theme-mail-template.php
laoyang 6f50533e8e 3.2.10 修复邮件页脚显示字面量 ©
邮件正文会被 yang_mail_escape_for_wpdiscuz() 无差别地把 & 加倍,用来抵消
wpDiscuz 在过滤器之后又做的那一次 html_entity_decode —— 不加倍的话,访客
评论里的 <script> 会在解码时还原成活标签。

但这个替换是全局的,连模板骨架里手写的字符实体也一起被加倍。于是
&copy; 变成 &amp;copy;,能否正常显示完全取决于后面那次解码有没有执行:
走了解码 → 还原成实体 → 浏览器显示 ©;没走 → 只解析一次 → 收件人看到
字面量 &copy;。用户遇到的正是后者。

修法不是在转义函数上开口子(那会重新打开脚本注入口),而是让骨架不再
依赖实体:版权符号与 3px 分隔条的不换行空格一律输出 UTF-8 字符本身。
整封邮件本就声明 charset=UTF-8,这样无论被解码几次都不会变形。

用户数据(站名、标题、URL)仍走 esc_html/esc_url —— 那类 & 的转义是
必要的,加倍后能由那次解码还原,属于应当保留的一类。

assertions: run_tests 89 → 95 条(新增 1e 组 6 条)
- 骨架里零手写实体(0 次 decode 也不露原文)
- 经 0~5 次 html_entity_decode 始终是 ©、从不露实体原文
- URL/站名里的 & 仍被转义(不能把 esc_html 一起废掉)
- 分隔条是 U+00A0 裸字符,过过滤器后仍是裸字符

falsify 20 → 22 条,两处注入(页脚退回实体、分隔条退回实体)都能抓住。
全量流水线 14 组 / 465 条断言全绿。
2026-10-23 17:29:37 +08:00

1011 lines
47 KiB
PHP
Raw Permalink 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 判成中等亮度。
*
* ── 阈值为什么是 0.179 而不是 0.5 ────────────────────────────
* 「白字对比度 = 黑字对比度」的那个点,可以解出来:
* 白字对比度 = 1.05 / (L + 0.05)
* 黑字对比度 = (L + 0.05) / 0.05
* 两者相等 ⇒ (L + 0.05)² = 0.0525 ⇒ L ≈ 0.179
* 所以在 L = 0.179 处,白字与黑字一样清楚;低于它白字更好,高于它黑字更好。
* 之前写 0.5,把一整段"其实该用深色字"的中间亮度全划成了"深色",
* 典型受害者就是亮蓝 #00a2ff(L=0.331):被判成深色 ⇒ 压白字 ⇒ 只有 2.76:1,
* 远低于 AA 的 4.5,品牌条上的站名其实很难看清。按 0.179 判定后走深色字,6.31:1。
*
* ⚠️ 这个函数只回答"深还是浅",**不负责保证对比度**。中灰 #808080 落在
* 交叉点附近,白字 3.95、深字 4.41 —— 两个都不到 4.5,但必须选一个。
* 所以配字时不要直接用它,用 yang_mail_fg_on():那里是"黑白里取更优",
* 顺带在都不达标时退回纯黑/纯白,能多榨出一两档。
*
* ⚠️ 本函数目前**没有生产调用点**(配字一律走 fg_on,配色档位一律走
* deepen_to)。保留它是为了给调用方一个便宜的"这是深色吗"判断,
* 以及 run_tests 里那三条亮度分类断言。别在配字场景重新启用它 ——
* 那正是 2.76:1 那个 bug 的来源。
*/
function yang_mail_is_dark($hex)
{
return yang_mail_lum($hex) < 0.179;
}
/**
* 相对亮度(WCAG,0=纯黑 1=纯白)
*/
function yang_mail_lum($hex)
{
$hex = ltrim((string) $hex, '#');
if (!preg_match('/^[0-9a-fA-F]{6}$/', $hex)) {
return 0.0;
}
$lin = function ($c) {
$c /= 255;
return $c <= 0.03928 ? $c / 12.92 : pow(($c + 0.055) / 1.055, 2.4);
};
return 0.2126 * $lin(hexdec(substr($hex, 0, 2)))
+ 0.7152 * $lin(hexdec(substr($hex, 2, 2)))
+ 0.0722 * $lin(hexdec(substr($hex, 4, 2)));
}
/**
* 给某个底色配文字色:在白与深之间取**对比度更高**的那个
*
* 为什么不是"深色就配白、浅色就配深":中灰区(如 #808080)两边都不达标
* (白字 3.95、深字 4.41),二元判定的答案取决于阈值落在哪一侧,
* 而阈值本身对两个都不达标的情况毫无意义。取更优的那个至少是当前最好的选择,
* 而且这个判据跟"阈值该定多少"完全解耦 —— 换阈值也不会选错。
*/
function yang_mail_fg_on($bg)
{
$white = yang_mail_contrast('#ffffff', $bg);
$dark = yang_mail_contrast('#1a1a1a', $bg);
if ($white >= $dark) {
return '#ffffff';
}
// 深字够不着 AA 时,纯黑还能再挤一点(#808080 上 4.41 → 5.32)
return yang_mail_contrast('#000000', $bg) > $dark ? '#000000' : '#1a1a1a';
}
/**
* 取一个颜色的「色度」(chroma)—— 三个通道的最大差
*
* ── 为什么不用 HSL 饱和度判断"有没有颜色" ────────────────────
* HSL 的 S 在高亮度区会虚高。实测派生底 #f0f9ff(几乎纯白带一点点蓝)
* 的 HSL 饱和度是 **1.00**,而 #e5e5e6(纯灰)是 0.005 —— 看起来判据很灵。
* 但 #e9eaec 这种"肉眼就是灰"的颜色,HSL 饱和度也有 0.12,
* 会被判成"有颜色"。真正决定人眼能否看出颜色的,是通道极差(色度):
* #f0f9ff → 0.059(看得出是极浅蓝)
* #e9eaec → 0.012(看不出,就是灰)
* 两者 HSL 饱和度一个是 1.00、一个 0.12,顺序跟人的感受**正好相反**。
* 所以这里一律用色度,不用 S。
*/
function yang_mail_chroma($hex)
{
$hex = ltrim((string) $hex, '#');
if (!preg_match('/^[0-9a-fA-F]{6}$/', $hex)) {
return 0.0;
}
$v = array(
hexdec(substr($hex, 0, 2)),
hexdec(substr($hex, 2, 2)),
hexdec(substr($hex, 4, 2)),
);
return (max($v) - min($v)) / 255;
}
/**
* 把「有色相但太淡」的颜色沿**它自己的色相**加饱和,直到色度达到下限
*
* ── 为什么需要这一步 ────────────────────────────────────────
* 主题默认主色是 #24292e,色度只有 0.039(几乎是中性深灰)。
* 往白里兑出浅底后色度只剩 0.002 —— 物理上不可见,整封邮件仍旧是黑白的,
* 用户看到的还是「模板没做好」。但「从灰里调出彩色」在数学上不可能,
* 而主题默认值就是它,所以必须有一个补饱和的环节。
*
* 只动饱和度,**色相与亮度一律不动** —— 派生色仍属同一色系,品牌识别没丢。
*/
function yang_mail_ensure_chroma($hex, $minChroma)
{
if (yang_mail_chroma($hex) >= $minChroma) {
return $hex;
}
list($hue, $l) = yang_mail_hsl($hex);
$den = 1 - abs(2 * $l - 1);
if ($den < 1e-6) {
// 纯白或纯黑:没有色相可言
return $hex;
}
$s0 = yang_mail_chroma($hex) / $den;
/* 逐档搜索而不是解析求解:色度是 max-min,跟 S 的关系在 l>0.5 与
* l<0.5 两侧公式不同,硬推容易在边界写错;这里步数只有几十。 */
for ($s = $s0; $s <= 0.95; $s += 0.02) {
$out = yang_mail_hsl_hex($hue, $s, $l);
if (yang_mail_chroma($out) >= $minChroma) {
return $out;
}
}
return yang_mail_hsl_hex($hue, 0.95, $l);
}
/**
* 这个颜色有没有「真实色相」(可以放心拿它的色相去染色)
*
* ── 为什么判据放在源头,而不是放在每个派生色上 ──────────────────
* 与白色混合是**同比压缩**色度:0.94 那一档压 16 倍。
* 所以"派生出的浅底色度很低"是**必然现象**,不是"这个颜色没有色相"。
* 早先的实现在 yang_mail_ensure_chroma() 里对入参做 0.02 死区判断,
* 于是极浅底(混合后 0.008)被当成"没有色相"直接跳过,
* 兜底等于没做 —— 恰恰是最需要兜底的那一档失效了。
*
* 真正该判断的是**种子**:种子的色度 ≥ 0.02 说明它有一个真实可辨的色相
* (#24292e 是 0.039,属"有一点点真实的蓝",可以放大);
* 低于 0.02 的(#808080 这种纯中性灰)色相是浮点噪声,按噪声染色
* 等于随机配色,还会让「主色本来就没有色相」变得不可观测 —— 那就不染。
*/
function yang_mail_has_hue($hex)
{
return yang_mail_chroma($hex) >= 0.02;
}
/**
* #rrggbb → array(色相 0~1, 亮度 0~1)
*/
function yang_mail_hsl($hex)
{
$hex = ltrim((string) $hex, '#');
if (!preg_match('/^[0-9a-fA-F]{6}$/', $hex)) {
return array(0.0, 0.0);
}
$r = hexdec(substr($hex, 0, 2)) / 255;
$g = hexdec(substr($hex, 2, 2)) / 255;
$b = hexdec(substr($hex, 4, 2)) / 255;
$mx = max($r, $g, $b);
$mn = min($r, $g, $b);
$l = ($mx + $mn) / 2;
$d = $mx - $mn;
if ($d < 1e-9) {
return array(0.0, $l);
}
if ($mx === $r) {
$h = (($g - $b) / $d + ($g < $b ? 6 : 0)) / 6;
} elseif ($mx === $g) {
$h = (($b - $r) / $d + 2) / 6;
} else {
$h = (($r - $g) / $d + 4) / 6;
}
return array($h, $l);
}
/**
* HSL → #rrggbb(h 为 0~1)
*/
function yang_mail_hsl_hex($h, $s, $l)
{
$hue2rgb = function ($p, $q, $t) {
if ($t < 0) {
$t += 1;
}
if ($t > 1) {
$t -= 1;
}
if ($t < 1 / 6) {
return $p + ($q - $p) * 6 * $t;
}
if ($t < 1 / 2) {
return $q;
}
if ($t < 2 / 3) {
return $p + ($q - $p) * (2 / 3 - $t) * 6;
}
return $p;
};
if ($s <= 0) {
$v = (int) round($l * 255);
return sprintf('#%02x%02x%02x', $v, $v, $v);
}
$q = $l < 0.5 ? $l * (1 + $s) : $l + $s - $l * $s;
$p = 2 * $l - $q;
return sprintf(
'#%02x%02x%02x',
(int) round(255 * $hue2rgb($p, $q, $h + 1 / 3)),
(int) round(255 * $hue2rgb($p, $q, $h)),
(int) round(255 * $hue2rgb($p, $q, $h - 1 / 3))
);
}
/**
* 取颜色的 HSL 饱和度
*
* 色度(通道极差)= S × (1 - |2L-1|),这里就是它的反解。
* 单独拿出来是因为「按亮度重设颜色」时必须保住原来的饱和度 ——
* 否则一个鲜艳的蓝压暗后会变成中性灰,头部就成了「深灰条」而不是「深蓝」。
*/
function yang_mail_hsl_s($hex)
{
list(, $l) = yang_mail_hsl($hex);
$den = 1 - abs(2 * $l - 1);
if ($den < 1e-6) {
return 0.0;
}
return min(1.0, yang_mail_chroma($hex) / $den);
}
/**
* 沿自身色相与饱和度,把颜色放到指定的 HSL 亮度上
*
* 用途:头部品牌条要「足够深、但不至于近黑」。只改亮度,
* 色相和饱和度是主色的原班底子,所以它仍认得出是哪个品牌色。
*/
function yang_mail_at_lightness($hex, $l)
{
list($h, ) = yang_mail_hsl($hex);
return yang_mail_hsl_hex($h, yang_mail_hsl_s($hex), max(0.0, min(1.0, (float) $l)));
}
/**
* 把颜色压深,直到叠在上面的文字达到目标对比度
*
* ── 为什么不是「取一个固定深色」────────────────────────────────
* 主色是用户填的,可能是亮蓝、亮绿、浅黄。同一个固定深色放在这些主色上
* 都不「属于」它们,品牌识别就没了。这里只沿它**自己的色相**往下压亮度,
* 压到「刚好够文字清楚」就停 —— 保住色相,也不会一路压成近黑。
*/
function yang_mail_deepen_to($hex, $target = 4.5, $fg = '#ffffff')
{
list($h, $l) = yang_mail_hsl($hex);
$s = yang_mail_hsl_s($hex);
for ($t = $l; $t >= 0.04; $t -= 0.01) {
$c = yang_mail_hsl_hex($h, $s, $t);
if (yang_mail_contrast($fg, $c) >= $target) {
return $c;
}
}
return yang_mail_hsl_hex($h, $s, 0.04);
}
/**
* 两个颜色的色相距离(0 = 同色相,0.5 = 正互补)
*
* 判「撞色够不够」必须用它,不能比较具体色值 —— 色值是用户主色的函数,
* 写死任何一个都会在主色变化时假红。
*/
function yang_mail_hue_dist($a, $b)
{
list($ha, ) = yang_mail_hsl($a);
list($hb, ) = yang_mail_hsl($b);
$d = abs($ha - $hb);
return $d > 0.5 ? 1 - $d : $d;
}
/**
* 暖色判定(红 / 橙 / 黄)
*
* 用户对「新回复」标签的要求是「不能用蓝色了,用黄色或者红色」。
* 判据直接写成语义:色相落在 -30°~75° 之外就是偏绿/偏蓝了,
* 换算成圈是 [0.917, 1] ∪ [0, 0.208]。绿色 #27ae60(h≈0.42)、
* 蓝色 #00a2ff(h≈0.56)都不在区间内。
*/
function yang_mail_is_warm($hex)
{
list($h, ) = yang_mail_hsl($hex);
return $h >= 0.917 || $h <= 0.208;
}
/**
* 从暖色候选里挑「与头部拉开足够距离」的那个
*
* 用户的要求有两句:「标签不能再用蓝色,用黄或红」「要和背景有区分度」。
* 第一句决定了只能是暖色族,第二句决定了**不能固定一个色号**:
* 品牌色本身偏红时(#e74c3c 压深成 #a82315),一个红按钮与它只差 6°,
* 贴在深红头部下方等于"看不见的撞色"。
*
* ── 判据 ────────────────────────────────────────────────────
* 按候选顺序取**第一个色相距离 ≥ 0.10(36°)**的。
* 头部本身就是暖色、暖色族里挑不出更远的那个时,取距离最大的 ——
* 此时靠**亮度差**拉开层次(深色头部 L≈0.10 上的金黄 L 很高)。
* 候选顺序把金黄放第一:与冷色头部撞得最开,压黑字对比度也最高(14.5:1),
* 最符合用户说的「更醒目」。
*
* @param string $headerBg 头部底色
* @param array $cands 暖色候选,顺序即优先级
*/
function yang_mail_pick_warm($headerBg, $cands)
{
$best = $cands[0];
$bestD = -1;
foreach ($cands as $c) {
$d = yang_mail_hue_dist($c, $headerBg);
if ($d >= 0.10) {
return $c;
}
if ($d > $bestD) {
$bestD = $d;
$best = $c;
}
}
return $best;
}
/**
* 生成整封邮件用的配色表
/**
* 生成整封邮件用的配色表
*
* ── 版式骨架(用户指定的审美,别再自己发挥)──────────────────
* · 外层背景**白色**。不是整片浅色底 —— 那样一眼看上去是「一张彩色纸」,
* 而不是「纸上一张卡片」。
* · 顶部品牌条**深色底 + 白字**。深色从主色压下来,色相仍是主色。
* · 「新回复」标签**暖色(黄/红)**,与冷色头部撞开。
* 早先它的底色是主色提亮一档 —— 与头部同色系,远看糊成一片,
* 用户的原话是「和背景蓝色没区分度」。
* · CTA 按钮**红底白字**,同样与头部撞开。
* 结论:这封邮件的色彩逻辑是「**白底 + 深头 + 暖色点缀**」,
* 而不是上一版那样「整封同色系」。
*
* ── 为什么整封邮件不能是灰的 ──────────────────────────────────
* 早先模板里只有品牌条、徽标、按钮、最后一块引用块的竖线用主色,
* 其余 90% 面积(标题、正文、引用块底色、信息表分隔线、页脚)全是中性灰
* (#1a2028 / #5f6b78 / #c2cbd4 / #edf0f3)。主色若本身不够艳
* (比如主题默认的 #24292e 是接近中性的深灰蓝),整封信看起来就是黑白的。
* 「彩色」不是靠一个大色块,而是让**底色、描边、强调文字**都在同一色系里。
*
* ── 为什么要算而不是写死 ──────────────────────────────────────
* 主色是用户在主题选项里改的,写死一套蓝就没有意义了。
* 这里从主色派生出:极浅底(区块背景)、浅底(页脚背景)、
* 中档描边、深档文字(保证对浅底 ≥4.5:1)。
*
* ── 派生为什么不能只是「与白混合」────────────────────────────
* 混合是同比压缩色度:0.94 那一档把色度压 16 倍。主色是 #24292e 这种
* 近中性深灰(色度 0.039)时,兑完只剩 0.002,肉眼与纯灰无异 ——
* 这正是「换成默认主色后邮件又变黑白」的机制。
* 所以浅底在混合之后还要过一道 yang_mail_ensure_chroma(),按**它自己所在
* 亮度**补一个可见的色度下限(浅底用 MIN_CHROMA_TINT,中档用 MIN_CHROMA_MID)。
* 这一步只动饱和度、不动色相和亮度,颜色仍是主色的同一色系。
*
* ── 品牌条为什么仍用原色 ────────────────────────────────────
* 用户直接看到主色的地方(品牌条、按钮、徽标)一律用他填的原色。
* 他选了深灰蓝,就不该在标题栏上看到它被换成亮蓝。补饱和只用于
* 「主色的稀释/加深」,那是他自己没指定过的颜色。
*
* 全部输出**实色**,不用 rgba:Outlook(Word 引擎)不认 rgba,会整条丢掉背景。
*/
function yang_mail_palette($brand = null)
{
if ($brand === null) {
$brand = yang_mail_brand_color();
}
$brand = ltrim((string) $brand, '#');
if (!preg_match('/^[0-9a-fA-F]{6}$/', $brand)) {
$brand = '24292e';
}
$brand = '#' . $brand;
/* 派生用的种子:中性一点的先提饱和,否则这一整套派生色都将是灰的。
* 只在主色有**真实色相**时才放大(见 yang_mail_has_hue);
* 零色相的纯灰宁可保持灰,也不要凭空编一个色相。 */
$seed = yang_mail_has_hue($brand) ? yang_mail_ensure_chroma($brand, 0.30) : $brand;
/* ── 头部:深色底 + 白字 ────────────────────────────────────
* 直接拿主色当头部底色在两种情况会翻:主色本身很亮(亮蓝/亮黄)时
* 白字看不清;主色本身很深时与正文的深色文字层次不分。
*
* ── 为什么压到 7:1 而不是只压到 AA 4.5 ────────────────────
* 只压到刚好 4.5 会得到一个「界限色」:亮蓝 #00a2ff 压出来是
* #007bc2,白字 4.55:1 —— 合规,但它是个中蓝,肉眼既不"深"也不"亮",
* 用户说的「深蓝色的背景」根本看不出来。压到 7:1 才真的落进深色区
* (#007bc2 的 L=0.181 恰好卡在 0.179 的深浅分界线上,这也是它
* 看着不深的原因)。白字 7:1 同时是 AAA 级正文标准,留有余量。
* 色相与饱和度仍是主色的原班底子,所以它认得出是哪个品牌色的深版。 */
$headerBg = yang_mail_deepen_to($seed, 7.0, '#ffffff');
$headerFg = yang_mail_fg_on($headerBg);
$headerDark = yang_mail_shade($headerBg, 0.80); // 下沿 3px 深色带
/* ── 强调色:暖色,与头部撞开 ───────────────────────────────
* 用户要求「标签不能用蓝色,要黄或红」。但从主色反推一个暖色是编造:
* 主色是 #27ae60(绿)时,把色相掰到红就等于替用户改了配色。
* 所以强调色用**语义固定**的暖色族,具体哪一个由「与头部的色相距离」
* 决定(见 yang_mail_pick_warm)—— 主色偏红时第一个候选贴上了,
* 就往暖色族里更远的那个退。
*
* 文字色走 fg_on():金黄 #ffd21f(L 高)压黑字 14.5:1,压白字只有 1.5:1。
* 这类"中亮度暖色"正是二元阈值最容易判错的地方。 */
$accent = yang_mail_pick_warm($headerBg, array('#ffd21f', '#f2a00b', '#e53935'));
$accentFg = yang_mail_fg_on($accent);
$accentBd = yang_mail_shade($accent, 0.86);
/* 按钮:用户明确要「红底白字」,就用红。
* 为什么不也按"与头部撞色"去挑:按钮在卡片最下方,中间隔着整段正文,
* 视觉上不与头部相邻 —— 用户说的「区分度」针对的是**贴在头部里面**的
* 标签。为它再引入一套撞色规则,只会在红主色站点上把按钮挑成橙色,
* 与「红白色的文字」这个明确指令冲突。
* 白字走 fg_on():#c62828 上 5.62:1,达 AA。 */
$ctaBg = '#c62828';
$ctaFg = yang_mail_fg_on($ctaBg);
/* ── 浅底色 ────────────────────────────────────────────────
* 外层背景必须是白(用户明确要求),卡片本身也是白;
* 浅底只留给「引用块」和「页脚」——没有它们,白底上全是散落的字,
* 分不出哪段是别人说的话。 */
$keep = yang_mail_has_hue($brand);
$tintXX = $keep ? yang_mail_ensure_chroma(yang_mail_tint($seed, 0.94), 0.045)
: yang_mail_tint($seed, 0.94);
$tintX = $keep ? yang_mail_ensure_chroma(yang_mail_tint($seed, 0.88), 0.075)
: yang_mail_tint($seed, 0.88);
$tintLine = $keep ? yang_mail_ensure_chroma(yang_mail_tint($seed, 0.80), 0.110)
: yang_mail_tint($seed, 0.80);
$tintMid = $keep ? yang_mail_ensure_chroma(yang_mail_tint($seed, 0.55), 0.220)
: yang_mail_tint($seed, 0.55);
/* 强调文字色:要「主色的深版」,不能直接拿主色当文字色 ——
* 亮蓝 #00a2ff 在白底只有 2.76:1。
*
* ── 循环方向 ──────────────────────────────────────────────
* shade() 是**按比例乘通道值**,所以 ratio 越小越暗。
* 从 0.90(最亮)往下扫到 0.20(最暗),取**第一个**达标的,
* 得到的是「最亮的达标档」:仍看得出属于主色,又刚够清楚。
* 反过来从 0.20 往上扫会取到最暗档 —— 亮蓝压成 #002033,标题看着是黑的。
*
* ── 为什么还要卡色度 ──────────────────────────────────────
* 只卡对比度时,主色很浅(如 #ffd21f 亮黄)会一路压到几乎发黑才达标,
* 那就不是"主色的深版"而是"黑色"了。要求色度不低于种子的 70%,
* 保证它还能被认出是同色系(浅主色达不到时退到最接近的那档)。 */
$seedC = yang_mail_chroma($seed);
$textOnTint = yang_mail_shade($seed, 0.90);
for ($s = 0.90; $s >= 0.20; $s -= 0.02) {
$c = yang_mail_shade($seed, $s);
if (yang_mail_contrast($c, '#ffffff') >= 4.5
&& yang_mail_contrast($c, $tintXX) >= 4.5
&& yang_mail_chroma($c) >= $seedC * 0.70) {
$textOnTint = $c;
break;
}
}
/* 主色本身没有色相(纯灰)时,上面那条色度条件退化成恒真,
* 于是会选到 #717171 这种"最亮的中灰"当 21px 标题 —— 合规但看着发虚。
* 中性主色本来就没有"深版"可言,直接给一个真正的深墨色,
* 层次反而清楚(与正文 #243040 同为深色,靠字号/字重区分)。 */
if (!$keep) {
$textOnTint = '#1a1a1a';
}
/* 次要文字色:原来是固定的 #5c6b7a,但页脚底色改成了主色浅底之后,
* 这个固定值在浅蓝上只有 4.26:1 —— 12px 小字不达 AA。
* 「文字色写死、背景色随主色变」这个组合本身就是错的:背景一变浅,
* 固定灰就压不住。所以改成从基准灰往下压,压到在**它实际会遇到的
* 三种背景**(页脚浅底、引用块浅底、白卡片)上都达标为止。 */
$muted = '#5c6b7a';
for ($t = 1.0; $t >= 0.55; $t -= 0.02) {
$cand = $t >= 0.999 ? $muted : yang_mail_shade($muted, $t);
if (yang_mail_contrast($cand, $tintX) >= 4.5
&& yang_mail_contrast($cand, $tintXX) >= 4.5
&& yang_mail_contrast($cand, '#ffffff') >= 4.5) {
$muted = $cand;
break;
}
}
return array(
'brand' => $brand, // 主色原件(引用块高亮竖线等)
'brand_dark' => yang_mail_shade($brand, 0.78), // 主色深版(备用)
'on_brand' => $headerFg, // 压在头部上的字
'header_bg' => $headerBg, // 顶部品牌条底色(深色)
'header_fg' => $headerFg, // 头部白字
'header_dark'=> $headerDark, // 头部下沿深色带
'accent' => $accent, // 强调色(badge 底,暖色)
'accent_fg' => $accentFg, // badge 上的字
'accent_bd' => $accentBd, // badge 描边
'cta_bg' => $ctaBg, // 按钮底(红)
'cta_fg' => $ctaFg, // 按钮字(白)
'text' => $textOnTint, // 同色系的强调文字(标题/标签)
'tint_xx' => $tintXX, // 极浅底:引用块、信息表
'tint_x' => $tintX, // 稍深一点的浅底:页脚
'tint_line' => $tintLine, // 浅底上的描边/分隔线
'tint_mid' => $tintMid, // 中档:弱化竖线、次要描边
'page_bg' => '#ffffff', // 邮件最外层背景(纯白,用户指定)
'body' => '#243040', // 正文(保持深灰蓝,长文好读)
'muted' => $muted, // 次要文字(时间、页脚),按背景调过
);
}
/**
* 计算两色的 WCAG 对比度
*
* 给配色表自检用:派生颜色是否达标,靠算而不是靠眼睛看。
*/
function yang_mail_contrast($fg, $bg)
{
$lumOf = function ($hex) {
$hex = ltrim((string) $hex, '#');
if (strlen($hex) !== 6) {
return 0;
}
$lin = function ($c) {
$c /= 255;
return $c <= 0.03928 ? $c / 12.92 : pow(($c + 0.055) / 1.055, 2.4);
};
return 0.2126 * $lin(hexdec(substr($hex, 0, 2)))
+ 0.7152 * $lin(hexdec(substr($hex, 2, 2)))
+ 0.0722 * $lin(hexdec(substr($hex, 4, 2)));
};
$a = $lumOf($fg);
$b = $lumOf($bg);
return (max($a, $b) + 0.05) / (min($a, $b) + 0.05);
}
/**
* 把可能含 HTML 的片段转成邮件安全的纯文本(保留换行)
*
* 邮件正文里塞用户提交的评论内容,必须防注入:邮件客户端也会执行
* `<script>`(部分 webmail 会),而且一旦渲染出畸形 HTML,整封邮件布局就崩。
*
* ── 换行为什么要专门处理 ─────────────────────────────────────
* 两个坑都会把整条评论拍成一行:
* 1. `wp_strip_all_tags($x, true)` 的第二个参数是 remove_breaks,
* 真实实现里会 `preg_replace('/[\r\n\t ]+/', ' ', $x)` —— 换行直接没了。
* 所以这里必须传 false。
* 2. `strip_tags()` 遇到 `<br>` 是**直接删掉、不补换行**的,`</p>` 同理。
* 评论里手写的换行和段落因此在去标签阶段就无痕消失。
* 结论:先把块级边界换成 `\n`,再去标签,最后才收拾空白。
* 这样模板里的 `nl2br()` 才有换行可转,不是一段死代码。
*/
function yang_mail_text($html, $limit = 0)
{
$t = (string) $html;
// 1. 先把"视觉上的换行"降级成真正的 \n
$t = preg_replace('@<br\s*/?>@i', "\n", $t);
$t = preg_replace('@</(p|div|blockquote|pre|h[1-6])\s*>@i', "\n", $t);
// <li> 自己带前缀,就不要</li>再加换行,否则列表每项之间会多一个空行
$t = preg_replace('@<li[^>]*>@i', "\n- ", $t);
// 2. 去标签,但不要 remove_breaks(那是换行消失的真正原因)
$t = wp_strip_all_tags($t, false);
// 3. 收拾空白:行内空白收成一个空格,换行保留,最多一个空行
$t = html_entity_decode($t, ENT_QUOTES, 'UTF-8');
$t = str_replace(array("\r\n", "\r"), "\n", $t);
/* 用 [^\S\n]("非空白"取反 + 排除换行 = 除换行外的空白)而不是
[ \t\f\v]:PCRE 里 \v 是**垂直空白类**,把 \n 也算进去,
写成 [ \t\f\v] 会连换行一起替换成空格 —— 而且只在带 /u 时表现如此,
极易漏看。语义直接写出来,别依赖转义字母的记忆。 */
$t = preg_replace('/[^\S\n]+/u', ' ', $t);
$t = preg_replace('/ *\n */u', "\n", $t);
$t = preg_replace('/\n{3,}/u', "\n\n", $t);
$t = trim($t);
if ($limit > 0 && mb_strlen($t, 'UTF-8') > $limit) {
$t = mb_substr($t, 0, $limit, 'UTF-8') . '…';
}
return $t;
}
/**
* 页脚版权行的文字
*
* ── 为什么不用字符实体 ───────────────────────────────────────
* 邮件的 HTML 会被 yang_mail_escape_for_wpdiscuz() 统一把 `&` 换成 `&amp;`
* (那是为了防 wpDiscuz 在过滤器之后又 decode 一次时把评论里的
* `<script>` 还原成活标签)。这个替换是**无差别**的,连我们自己写在
* 骨架里的字符实体也会被一起加倍。
*
* 正常路径下 wpDiscuz 会再 decode 一次,加倍过的实体恰好还原,
* 浏览器解析成 `©` —— 看起来没问题。但**只要有任何一条路径没有走那次
* decode**(插件版本差异、别的过滤器、后台预览、或是我们自己直接调
* wp_mail),加倍过的实体就只会被解析一次,收件人看到就是那串实体原文。
*
* 实体是"靠对家配合才好使"的东西,裸 UTF-8 字符不是。整封邮件都声明了
* `Content-Type: text/html; charset=UTF-8`,直接写 `©` 在多解码几次的
* 情况下也不会变形,所以这里一律输出字符本身,不输出实体。
*
* 站点名走 esc_html():站名可能带 `&`(如「A & B」),这个 `&` 是**用户数据**
* 的转义,被加倍后能由那次 decode 还原,是应当保留的那一类。
*/
function yang_mail_footer_copy($year, $site)
{
return '© ' . esc_html($year) . ' ' . esc_html($site);
}
/**
* 不换行空格
*
* 用途只有一个:给 3px 高的分隔条撑出行高(Outlook 里 td 不能是空的,
* 否则高度会被忽略)。同理不用那个常见的空格字符实体 —— 换成 U+00A0
* 字符本身,免得被 `&` 加倍后在某些路径下变成字面量文本,反而撑高那一行。
*/
function yang_mail_nbsp()
{
return "\xC2\xA0";
}
/**
* 把主色与白色按比例混合,得到一档浅色
*
* 为什么不用 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>` 上。
*
* ── 文字色为什么不写死 #ffffff ────────────────────────────────
* 按钮底色是可传入的(主题里默认给红 #c62828)。写死白字在浅底按钮上
* 会掉到 2:1 以下,而且「深还是浅」这个问题本身有结构性盲区(中灰两边
* 都不达标)。统一走 yang_mail_fg_on():在白与深之间取对比度更高的,
* 判据与任何阈值都解耦。
*/
function yang_mail_button($text, $url, $bg = null, $fg = null)
{
if ($bg === null) {
$pal = yang_mail_palette();
$bg = $pal['cta_bg'];
}
if ($fg === null) {
$fg = yang_mail_fg_on($bg);
}
$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(), // 单个引用块(旧接口,保留兼容)
'quotes' => array(), // 多个引用块,按顺序渲染
'cta_text' => '',
'cta_url' => '',
'note' => '',
'badge' => '',
));
/* 统一成 $quoteList:
* 调用方可能只给一个 quote,也可能给 quotes 数组(评论回复场景要同时放
* 「被回复的那条」和「新的回复」,两段各自有作者和时间,必须分开渲染)。
* 两者都给时以 quotes 为准,避免重复出现。 */
$quoteList = array();
if (!empty($d['quotes']) && is_array($d['quotes'])) {
$quoteList = $d['quotes'];
} elseif (!empty($d['quote'])) {
$quoteList = array($d['quote']);
}
/* 整套配色从主色派生一次,后面所有颜色都从这里取。
* 不在这里散落写死 #xxxxxx —— 那样主色一改,配色就断了。 */
$pal = yang_mail_palette();
$brand = $pal['brand'];
$cHeader = $pal['header_bg'];
$cHeaderFg = $pal['header_fg'];
$cHeaderD = $pal['header_dark'];
$cAccent = $pal['accent'];
$cAccentFg = $pal['accent_fg'];
$cAccentBd = $pal['accent_bd'];
$cCtaBg = $pal['cta_bg'];
$cText = $pal['text'];
$cBody = $pal['body'];
$cMuted = $pal['muted'];
$cTintXX = $pal['tint_xx'];
$cTintX = $pal['tint_x'];
$cTintLine = $pal['tint_line'];
$cTintMid = $pal['tint_mid'];
$cPageBg = $pal['page_bg'];
/* badge 不再复用头部配色。
* 早先 $badgeBg 是主色提亮一档、文字色复用 $onBrand ⇒ 与头部同色系,
* 远看就是头部里的一块「差不多颜色」,用户原话「和背景蓝色没区分度」。
* 现在用暖色强调色,与深色头部是撞色关系,红/黄一眼能挑出来。 */
$badgeBg = $cAccent;
$badgeBd = $cAccentBd;
/* 引用块高亮竖线:用与按钮同一个红(比 badge 的琥珀更沉),
避免 12px 琥珀色小字旁边出现一块同样亮的竖线而互相抢眼。 */
$ctaBarColor = $cCtaBg;
$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:<?php echo esc_attr($cPageBg); ?>;">
<?php /* 预读文字:显示在收件箱列表里标题的后面。藏起来但必须真实存在,且要足够长,
否则客户端会把正文第一句抓上去当摘要。 */ ?>
<div style="display:none;font-size:1px;color:<?php echo esc_attr($cPageBg); ?>;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:<?php echo esc_attr($cPageBg); ?>;">
<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 <?php echo esc_attr($cTintLine); ?>;">
<?php /* ── 顶部品牌条 ──
* 深色底 + 白字。深色是从主色压下来的,色相仍是主色;
* 白字保证在深底上过 AA(详见 yang_mail_deepen_to 的推导)。 */ ?>
<tr>
<td bgcolor="<?php echo esc_attr($cHeader); ?>" style="background-color:<?php echo esc_attr($cHeader); ?>;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($cHeaderFg); ?>;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:600;line-height:1.4;">
<span style="color:<?php echo esc_attr($cAccentFg); ?>;"><?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($cHeaderD); ?>" height="3" style="background-color:<?php echo esc_attr($cHeaderD); ?>;height:3px;line-height:3px;font-size:0;mso-line-height-rule:exactly;"><?php echo yang_mail_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:<?php echo esc_attr($cText); ?>;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:<?php echo esc_attr($cBody); ?>;line-height:1.7;margin:0 0 22px 0;">
<?php echo esc_html($d['intro']); ?>
</div>
<?php endif; ?>
<?php foreach ($quoteList as $qi => $q): ?>
<?php
/* 引用块:左边一条竖线,用 border-left 而不是伪元素(邮件不支持 ::before)。
*
* 配色规则:**最后一块用强调暖色高亮,前面的用主色系弱化档**。
* 为什么是「最后」而不是「第一」:评论回复场景里顺序是
* 「被回复的原评论」→「新的回复」,语义重点在新的回复上,所以让它高亮。
* 只有一个块时它既是第一也是最后 ⇒ 拿到强调色,与旧版行为一致。
*
* 高亮色从主色换成强调暖色,是为了与深色头部、红按钮形成同一套
* 「暖色点缀」语言;同时竖线与 badge 同色,收件人一眼把
* 「新回复」标签和「新回复那段字」连起来。 */
$isLast = ($qi === count($quoteList) - 1);
$barColor = $isLast ? $ctaBarColor : $cTintMid;
$barBg = $isLast ? $cTintXX : $cTintX;
$barMargin = $isLast ? '22px' : '14px';
?>
<table border="0" cellpadding="0" cellspacing="0" width="100%" role="presentation" style="margin:0 0 <?php echo $barMargin; ?> 0;">
<tr>
<td style="border-left:3px solid <?php echo esc_attr($barColor); ?>;background-color:<?php echo esc_attr($barBg); ?>;padding:16px 18px;border-radius:0 8px 8px 0;">
<?php if (!empty($q['label'])): ?>
<div style="font-family:<?php echo $sans; ?>;font-size:12px;font-weight:600;color:<?php echo esc_attr($cText); ?>;letter-spacing:0.6px;text-transform:uppercase;margin:0 0 8px 0;">
<?php echo esc_html($q['label']); ?>
</div>
<?php endif; ?>
<div style="font-family:<?php echo $sans; ?>;font-size:15px;color:<?php echo esc_attr($cBody); ?>;line-height:1.7;word-break:break-word;">
<?php echo nl2br(esc_html(isset($q['content']) ? $q['content'] : '')); ?>
</div>
<?php if (!empty($q['meta'])): ?>
<div style="font-family:<?php echo $sans; ?>;font-size:13px;color:<?php echo esc_attr($cMuted); ?>;line-height:1.6;margin:9px 0 0 0;">
<?php echo esc_html($q['meta']); ?>
</div>
<?php endif; ?>
</td>
</tr>
</table>
<?php endforeach; ?>
<?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 <?php echo esc_attr($cTintLine); ?>;">
<?php foreach ($d['rows'] as $row): ?>
<tr>
<td width="88" valign="top" style="width:88px;padding:11px 0;border-bottom:1px solid <?php echo esc_attr($cTintLine); ?>;font-family:<?php echo $sans; ?>;font-size:13px;color:<?php echo esc_attr($cMuted); ?>;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 <?php echo esc_attr($cTintLine); ?>;font-family:<?php echo $sans; ?>;font-size:14px;color:<?php echo esc_attr($cBody); ?>;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 /* 按钮用红底白字:与深色头部是撞色,位置又在正文末尾,
是全信第二个视觉落点。用户明确要求「红白色的文字」。 */ ?>
<?php echo yang_mail_button($d['cta_text'], $d['cta_url'], $cCtaBg); ?>
</div>
<?php endif; ?>
<?php if ($d['note'] !== ''): ?>
<div style="font-family:<?php echo $sans; ?>;font-size:13px;color:<?php echo esc_attr($cMuted); ?>;line-height:1.7;padding:16px 0 0 0;border-top:1px solid <?php echo esc_attr($cTintLine); ?>;">
<?php echo esc_html($d['note']); ?>
</div>
<?php endif; ?>
</td>
</tr>
<?php /* ── 页脚 ── */ ?>
<tr>
<td bgcolor="<?php echo esc_attr($cTintX); ?>" style="background-color:<?php echo esc_attr($cTintX); ?>;padding:24px 32px 30px 32px;">
<div style="border-top:1px solid <?php echo esc_attr($cTintLine); ?>;padding-top:20px;font-family:<?php echo $sans; ?>;font-size:12px;color:<?php echo esc_attr($cMuted); ?>;line-height:1.9;">
<div style="margin:0 0 6px 0;">
<a href="<?php echo esc_url($home); ?>" target="_blank" rel="noopener" style="color:<?php echo esc_attr($cText); ?>;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:<?php echo esc_attr($cMuted); ?>;"><?php echo yang_mail_footer_copy($year, $site); ?></div>
</div>
</td>
</tr>
</table>
<?php /* 底部说明:为什么收到这封邮件。放在卡片外面,视觉上属于系统信息 */ ?>
<div style="font-family:<?php echo $sans; ?>;font-size:12px;color:<?php echo esc_attr($cMuted); ?>;line-height:1.7;padding:18px 8px 0 8px;max-width:600px;">
您收到这封邮件,是因为本站记录了与您邮箱相关的订阅或互动。
</div>
</td></tr>
</table>
</body>
</html>
<?php
return ob_get_clean();
}