* @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() 做的转义会被整体还原: * 用户评论里的 `` 会变回可执行标签, * 未闭合的 `