Files
wordpress-kratos-yang/inc/theme-mail.php
T
laoyang cbe0f2dc59 3.2.18: 清明按节气换算、主题内 [TOC] 目录、重建评论区替代 wpDiscuz
三项功能:

1. 清明不再用固定 04-04
   清明是节气,日期在 4/4~4/6 之间浮动。硬编码 04-04 实测 1900-2100
   共 201 年里只有 76 年是对的(约 38%)。改为内嵌逐年表换算真实日期,
   超出 1900-2100 返回 null 明确放弃,不退化成近似值。
   兼容 3.2.17 及以前勾过的 '04-04' 键,升级后勾选不会被静默丢弃。

2. 主题内实现 [TOC]
   文章里写 [TOC],该位置输出本文目录,点击跳到对应标题。
   在 the_content 优先级 9 处理(早于 wpautop 10 / do_shortcode 11),
   保证"补 id"与"扫目录"用的是同一份文本,href 与 id 必然对得上。
   嵌套用深度归一化,h1→h3 跳级不会算错层级。
   scroll-margin-top 声明式让位,原生锚点/外部 #hash/后退全都生效。

3. 重建评论区
   旧实现的问题:列表渲染与 AJAX 渲染各写一份 HTML,已经分叉
   (AJAX 返回的新评论没有回复按钮);<li> 被 <div> 包着,嵌套回复错位。
   现在统一到 yang_comment_html() 单一渲染函数,列表与 AJAX 只差一个
   is-new 类。同时修掉三处真实缺陷:
     · 评论项用 flex 会把 <ol class="yang-children"> 当成第三个 flex 项,
       子评论跑到父评论右侧、正文被挤到 156px、页面横向溢出 75px —— 改 grid
     · 成功提示在 exitReplyMode() 之前设置,被其末尾的 setStatus('','')
       擦掉,用户提交成功却看不到反馈 —— 调整顺序
     · 窄屏媒体查询打不过深层选择器(媒体查询不增加特异性),
       嵌套头像反向变大 —— 补深层覆盖

   与 wpDiscuz 解耦:邮件模块不再无条件 return false(那等于永久关掉
   notify_postauthor 且不报错);插件在场时不加载主题评论资源,
   避免双份表单互抢。

验证:PHP 语法 73/73;_static_qingming 36、_test_toc 53、
_test_comments 62、_test_comments_falsify 17、
_toc_geometry 121、_toc_geometry_falsify 9、
_comments_geometry 98、_comments_geometry_falsify 23,全部 0 失败。
线上端到端 29/0(真实文章渲染目录 + 真实评论数据渲染)。
2026-10-24 01:03:36 +08:00

512 lines
22 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 存在时**)
*
* ── 为什么要关 ──
* 站点用了 wpDiscuz,评论通知由插件负责(它能带订阅、退订链接、
* 能按订阅范围精确投递)。核心那套 `wp_notify_postauthor` / `wp_notify_moderator`
* 是给原生评论系统设计的纯文本通知,在 wpDiscuz 场景下是**纯重复**:
* 管理员会同时收到「核心:新评论待审」和「插件:新评论」,内容重叠。
*
* ── 为什么不直接改后台选项 ──
* 改 `comments_notify` / `moderation_notify` 是「用户设置」,主题替用户改
* 数据库里的设置属于越权,而且用户下次进后台一勾又回来了。
* 用 filter 拦截是**可逆的**:把这段代码删掉立刻恢复原状,不动用户数据。
*
* ── 只关通知,不关审核 ──
* 注意这里只拦 `notify_*` 两个 filter,评论审核流程(comment_moderation)
* 完全不受影响,评论该进待审还是进待审。
*
* ── 🔴 3.2.18:插件不在时必须放行 ─────────────────────────────
* 这一条是卸载 wpDiscuz 时最容易出的事故:
* 原来无条件返回 false,等于永久关掉核心的「有新评论通知作者」。
* 插件在时无所谓(插件自己会发);插件一卸,就没有任何东西通知作者了。
* 而主题自带的 yang_notify_comment_reply() 只处理**回复**场景
* ($comment_parent > 0),顶层新评论仍然依赖核心的 notify_postauthor。
* 两者叠加的结果是:作者从此收不到任何新评论邮件,且没有任何报错,
* 只有"怎么一直没邮件"这种模糊现象。
*
* 所以这里的判据必须跟着插件走,而不是"只要开了 SMTP 就关"。
*/
function yang_mail_disable_core_notify($maybe, $comment_id)
{
if (!yang_mail_smtp_ready()) {
return $maybe;
}
// 没有插件接管 → 核心通知是唯一的顶层评论通知渠道,必须放行
if (!yang_mail_wpdiscuz_present()) {
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;")。
* 只处理 `&` 就够,因为其它实体的风险都源于 `&` 被还原。
*
* ── 3.2.18:这个函数只在插件存在时才能用 ─────────────────────
* 主题现在自带评论通知(inc/theme-comments.php),不再依赖插件。
* 如果这里无条件做 `&` 预编码,而没有插件去 decode 那一次,
* 收件人看到的邮件正文里就会出现字面的 `&amp;lt;`、`&amp;nbsp;`
* —— 比安全风险更常见的实际故障,而且极难联想到原因。
*
* 所以调用点必须先判断插件在不在。见下方两个 add_filter 的包裹。
*/
function yang_mail_escape_for_wpdiscuz($html)
{
return str_replace('&', '&amp;', $html);
}
/**
* 当前是否有 wpDiscuz 在接管评论邮件
*
* 单独抽出来,是因为 theme-mail.php 加载早于 theme-comments.php
* 时(子主题调整了 require 顺序)`yang_comments_plugin_active()`
* 可能还不存在 —— 这时退回自己探测,不能让后台白屏。
*/
function yang_mail_wpdiscuz_present()
{
if (function_exists('yang_comments_plugin_active')) {
return yang_comments_plugin_active();
}
return class_exists('WpdiscuzCore') || function_exists('wpdiscuz');
}
/**
* 把 wpDiscuz 的通知邮件接管成主题模板
*
* ── 为什么要接管 ──────────────────────────────────────────────
* wpDiscuz 自己的模板只印「新评论内容」,收件人看到一句陌生人说的话,
* 完全想不起自己在哪篇文章下说过什么。要理解上下文,必须**同时给出
* 被回复的那条评论**。
*
* 它的过滤器 `wpdiscuz_email_content` 能拿到 `$comment`(新评论对象)
* 和 `$emailData`(订阅记录),足以重建整封邮件,所以不需要改插件。
*
* ── 两个模板还是一个模板 ──────────────────────────────────────
* 用一个。评论通知和「审核通过」通知的差别只有标题、徽标、引言和
* 上面那两段引用,正文骨架完全一样。做成两套模板会立刻分叉,
* 以后改一次配色要改两处。
*
* ── 3.2.18:这整段只在装了 wpDiscuz 时才注册 ─────────────────
* 主题已经自带评论通知(inc/theme-comments.php)。若插件不存在,
* 这两个 `wpdiscuz_*` 过滤器永远不会被触发 —— 注册了也是死代码,
* 但它们会让人误以为"邮件通知依赖插件",日后维护时是个陷阱。
* 更实际的问题:这些函数内部调用 yang_mail_escape_for_wpdiscuz()
* 做 `&` 预编码,那是专门给插件多一次 decode 用的;没有插件时
* 一旦被别处误调,邮件里就会出现字面的 &amp;lt;。
*
* 所以整个注册包在 if 里。插件卸载后,通知自动由主题接管,
* 用户不需要改任何配置。
*
* @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);
}
/* 只在插件确实存在时挂这两个过滤器。
插件不在时(用户按 3.2.18 的建议卸载了),评论通知由
inc/theme-comments.php 的 yang_notify_comment_reply() /
yang_notify_comment_approved() 接管,发的是**不预编码**的正文。 */
if (yang_mail_wpdiscuz_present()) {
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'),
)));
}
if (yang_mail_wpdiscuz_present()) {
add_filter('wpdiscuz_comment_approved_email_content', 'yang_mail_render_approved_notify', 20, 3);
}