WordPress 表单与邮件:提交失败、送达率与反垃圾
表单提交失败和邮件发不出去,在 WordPress 里是两件完全不同的事,但用户看到的现象几乎一样:点了提交,页面转圈或者跳回空白,收件箱里什么都没有。先把链路拆成两段看——一段是「浏览器到数据库」,一段是「服务器到收件箱」——定位会快很多。判断方法很简单:表单插件后台有没有留下条目。有条目说明提交成功,问题在邮件;没条目说明请求根本没进来。
一、四种现象对应四个环节
| 现象 | 卡在哪一环 | 先查什么 |
|---|---|---|
| 点提交后页面刷新,字段全部清空 | 表单层,nonce 校验失败或被必填校验拦截 | 浏览器控制台 Network 里 admin-ajax.php 的返回内容 |
| 按钮一直转圈,停在 submitting | AJAX 请求被拦截、超时或 500 | Network 里状态码是 403、500 还是 0 |
| 提示提交成功,后台没有任何记录 | 数据没落库,插件未开启条目存储 | 表单插件后台的 Entries 列表是否为空 |
| 后台有条目,收件箱没收到 | 邮件层,MTA 或 SMTP 配置 | SMTP 插件的调试日志与收件方垃圾箱 |
二、nonce 过期:缓存插件最常制造的故障
WordPress 的 nonce 有生命周期,默认 12 小时,由 nonce_life 过滤器控制。它的校验依赖「用户会话 + 时间窗口 + 动作名」三者的哈希。整页缓存会把带 nonce 的 HTML 一起缓存下来,用户拿到的是几小时前生成的页面,提交时 nonce 已经作废,服务端返回 -1,前端只看到一句含糊的失败提示。
三个处理方向,按优先级排列:
- 把表单页从整页缓存里排除。多数缓存插件都支持按 URL 排除,写成
/contact/(.*)这样的正则。 - 关掉该页面的「浏览器缓存」,让 HTML 每次都回源。
- 实在要缓存,就把 nonce 生命周期延长,但这只是缓解,不解决根因。
// 子主题 functions.php:把表单页排除出页面缓存
add_filter( 'rocket_cache_reject_uri', function ( $uris ) {
$uris[] = '/contact/(.*)';
$uris[] = '/quote/(.*)';
return $uris;
} );
// 临时缓解:把 nonce 有效期放宽到 48 小时(不推荐长期使用)
add_filter( 'nonce_life', function () {
return 48 * HOUR_IN_SECONDS;
} );
还有一种情况容易被忽略:服务器时间不准。nonce 靠时间戳计算,机器时间偏了十几分钟就会出现「刚打开就提示过期」。用 timedatectl status 看 System clock synchronized 是否为 yes,不是就用 timedatectl set-ntp true 打开 NTP 同步。
三、邮件为什么发不出去:mail() 的真实处境
WordPress 默认用 PHP 的 mail() 发信,它把邮件交给本机的 sendmail 或 MTA,剩下的完全不管。共享主机上常见三种失败:根本没装 MTA、装了但没配 PTR 反向解析、以及 mail() 被 disable_functions 禁用。
先用一行命令判断本机通道通不通:
php -r "var_dump( mail( 'test@example.com', 'probe', 'body' ) );"
返回 bool(false) 说明本机 MTA 不可用,直接上 SMTP。返回 true 也不代表能送达,只代表邮件交给了 MTA 排队。云服务器还有一个高频原因:出方向 25 端口默认封禁,阿里云、腾讯云都需要单独申请解封,所以经常出现「本地测试能收到、上线一封都收不到」。
用 SMTP 插件接管 wp_mail()
WP Mail SMTP、Fluent SMTP、Post SMTP 三者的原理一样,都是在 phpmailer_init 钩子上把发送方式改成 SMTP。配置时几个必填项不要图省事:
- 加密方式选 SSL(465 端口)或 STARTTLS(587 端口),不要选「无」。
- 用户名填完整邮箱地址,不是邮箱前缀。
- 密码填授权码。QQ 邮箱、163、企业邮箱都需要在网页端单独开启 SMTP 并生成授权码,账号登录密码一定失败。
- From 地址必须与 SMTP 账号同域,否则收件方会按伪造处理。
配置可以写进 wp-config.php,避免插件升级或迁移时丢失:
define( 'WPMS_ON', true );
define( 'WPMS_SMTP_HOST', 'smtp.exmail.qq.com' );
define( 'WPMS_SMTP_PORT', 465 );
define( 'WPMS_SSL', 'ssl' );
define( 'WPMS_SMTP_AUTH', true );
define( 'WPMS_SMTP_USER', 'web@wordpressx.com' );
define( 'WPMS_SMTP_PASS', getenv( 'SMTP_PASS' ) );
define( 'WPMS_MAIL_FROM', 'web@wordpressx.com' );
define( 'WPMS_MAIL_FROM_NAME', '信科网络' );
密码不要写死在文件里,用环境变量或服务器层面的密钥管理,否则代码仓库一泄露,邮箱就被人拿去发垃圾邮件。
四、SPF、DKIM、DMARC:三条 DNS 记录
| 记录 | 主机名 | 作用 | 典型值 |
|---|---|---|---|
| TXT(SPF) | @ | 声明哪些服务器有权代表本域发信 | v=spf1 include:spf.exmail.qq.com -all |
| TXT(DKIM) | default._domainkey | 用私钥签名,收件方用公钥验签 | v=DKIM1; k=rsa; p=MIIBIjANBg… |
| TXT(DMARC) | _dmarc | 指明 SPF 与 DKIM 都不通过时的处理策略,并接收汇总报告 | v=DMARC1; p=quarantine; rua=mailto:dmarc@yourdomain.com |
DMARC 不要一上来就写 reject。正确顺序是先用 p=none 跑两周,把 rua 报告里所有代发 IP 找出来——网站通知、CRM、发票系统、建站平台都可能在自己的 SPF 之外——逐个补进 SPF 或者单独走子域,再切到 quarantine,稳定之后才上 reject。
几个会让记录直接失效的写法:同一域名存在两条 v=spf1 记录,收件方会判 permerror;SPF 的 DNS 查询超过 10 次同样判 permerror;DKIM 主机名复制时多带了域名后缀,导致查不到公钥。改完 DNS 用 dig TXT yourdomain.com +short 和 dig TXT default._domainkey.yourdomain.com +short 验证,别只看 DNS 服务商后台。
三条记录都过了,还有一层是内容本身的判据。通知邮件的主题里堆感叹号、正文只有一张图、正文里只放一个短链接,都会拉高垃圾分值。表单通知邮件的正确写法是纯文本为主、主题里带上表单名和提交时间、正文保留用户填写的原始字段。另外发件人显示名不要写成「admin」或「noreply」这类无信息量的名字,收件方的人工举报率会明显影响后续送达。
五、反垃圾:三道低成本防线
蜜罐字段
加一个正常用户看不见、也不会填的输入框,机器人会无差别填满所有字段。用绝对定位把它移出视口,比 display:none 更稳,因为部分爬虫会跳过隐藏元素。
<input type="text" name="your_website" value="" class="hp-field" tabindex="-1" autocomplete="off">
配一条 CSS 把 .hp-field 定位到 position:absolute; left:-9999px;,服务端收到请求时只要该字段非空就直接丢弃,连日志都不用记。
时间戳
记录表单渲染时间,提交间隔小于 2 秒的判为机器人。人填一个表单不可能这么快。
add_action( 'wp_footer', function () {
if ( ! is_page( 'contact' ) ) {
return;
}
echo '<input type="hidden" name="form_ts" value="' . esc_attr( time() ) . '">';
} );
服务端用 time() - (int) $_POST['form_ts'] < 2 判断即可,记得先做 absint() 处理。
验证码
每天垃圾提交不到 20 条时,蜜罐加时间戳已经够用。验证码会带来真实的转化损失:reCAPTCHA 在国内加载不稳,v3 还会拖慢首屏;Cloudflare Turnstile 免费且国内可加载,是目前比较平衡的选择。真要上验证码,放在最后一步,别放在第一个字段。
六、表单数据落库与导出
Contact Form 7 默认只发邮件、不存库,必须配 Flamingo 才有记录;WPForms、Fluent Forms、Gravity Forms 自带 Entries 表。存库之后有两个长期问题要管:数据库膨胀和隐私合规。
SELECT COUNT(*) FROM wp_fluentform_submissions
WHERE created_at >= DATE_SUB( NOW(), INTERVAL 7 DAY );
导出建议按时间范围导 CSV,超过 12 个月的询盘归档到离线文件后从库里删掉。同时检查 wp_options 里的 _transient_ 记录,表单插件常把统计结果写成 transient,不清理会越积越多。
CSV 导出最常见的两个问题:中文用 Excel 打开乱码,因为默认没有 BOM,导出的文件要用 UTF-8 with BOM,或者直接导 xlsx;另一个是字段里带换行和逗号,把 CSV 的列冲乱,专业做法是给所有字段统一加双引号并转义内部引号。这两点在自己写导出脚本时尤其要注意,插件自带的导出一般已经处理好了。
七、可执行的排查顺序
- 打开浏览器 Network,提交一次,看
admin-ajax.php的状态码和响应体。 - 看表单插件后台 Entries,判断是「没进来」还是「进来了但没发信」。
- 用 SMTP 插件的 Email Test 发一封测试邮件,看返回的具体报错。
- 打开
wp-content/debug.log,搜wp_mail相关的 PHP Notice。 - 查收件方垃圾箱和邮件网关隔离区,企业邮箱经常静默拦截。
- 用 dig 确认 SPF、DKIM、DMARC 三条记录已生效。
这套顺序能从外到内锁定问题,避免一上来就换插件。如果排查完发现是插件之间抢 wp_mail 钩子,参考插件冲突排查的方法做二分停用;表单被刷得厉害时,配合安全加固里的频率限制一起做,比单独加验证码有效。选型阶段可以看必备插件清单,需要把询盘直接写进自建表或对接 CRM 的,属于插件定制的范围。