活力39282
在线时间13640 小时
阅读权限200
管理员
自由的灵魂
- 积分
- 107434
- 主题
- 5630
- 回帖
- 26661
- 注册时间
- 2003-4-10
- 最后登录
- 2026-10-2
|
马上注册,结交更多好友,享用更多功能,让你轻松玩转社区。
您需要 登录 才可以下载或查看,没有账号?立即注册
×
一、问题缘起
论坛使用独立的 CLI 脚本(/api/mail_queue_cli.php)配合 Crontab 定时任务处理邮件队列。近期发现邮件发送日志中存在大量重复的发送失败记录,部分无效邮箱地址(如已注销的 QQ 邮箱)被持续重试,导致:
- 资源浪费:无效邮箱占用 SMTP 连接和队列处理时间
- 日志膨胀:大量重复失败记录撑爆日志文件
- 延迟正常邮件:无效邮件阻塞队列,影响其他邮件的发送
- 重试无上限:失败邮件被无限次重试,永不停歇
独立问题汇总:
| 序号 | 问题 | 结论 | | 1 | 失败邮件为何被无限重试? | mail_queue_cli.php 发送失败时不更新 status,下次扫描继续尝试 | | 2 | 是否有重试次数上限? | ❌ 无,导致同一封无效邮件被尝试数百次 | | 3 | SMTP 错误码是否被识别? | ❌ 原 sendmail 函数只返回 true/false,不返回错误码 | | 4 | 550 等永久性错误能否立即停止重试? | ❌ 无法区分永久错误和临时错误 | | 5 | 重试间隔是否合理? | 当前每次扫描间隔 1 分钟,过于频繁 |
二、解决方案
(1)简要技术分析
在 sendmail 函数中记录 SMTP 错误码到全局变量,在 mail_queue_cli.php 中根据错误码判断是否为永久性错误(如 550),如果是则直接标记失败不再重试;同时增加重试次数上限(3 次)。
(2)修改文件列表(完整路径)
| 文件 | 操作 | 说明 | | /source/function/function_mail.php | 修改 | 在 RCPT TO 失败时保存 SMTP 错误码到 $_G['smtp_error_code'] | | /api/mail_queue_cli.php | 修改 | 增加重试次数限制(3次),增加永久性错误判断(550/553/501/510/551) | | /www/wwwroot/www.dianbo.org/data/log/mail_send.log | 清理 | 已积累 4.6MB 重复日志,可选择性清理 |
(3)关键作用代码段
文件 1:/source/function/function_mail.php(第 232-242 行)
修改前:
- if(substr($lastmessage, 0, 3) != 250) {
- fputs($fp, "RCPT TO: <".preg_replace("/.*\<(.+?)\>.*/", "\\1", $toemail).">\r\n");
- $lastmessage = fgets($fp, 512);
- fputs($fp, "QUIT\r\n");
- runlog('SMTP', "({$_G['setting']['mail']['server']}:{$_G['setting']['mail']['port']}) RCPT TO - $lastmessage", 0);
- return false;
- }
复制代码
修改后:
- if(substr($lastmessage, 0, 3) != 250) {
- fputs($fp, "RCPT TO: <".preg_replace("/.*\<(.+?)\>.*/", "\\1", $toemail).">\r\n");
- $lastmessage = fgets($fp, 512);
- fputs($fp, "QUIT\r\n");
- // 保存 SMTP 错误码到全局变量,供调用者判断
- global $_G;
- $_G['smtp_error_code'] = intval(substr(trim($lastmessage), 0, 3));
- runlog('SMTP', "({$_G['setting']['mail']['server']}:{$_G['setting']['mail']['port']}) RCPT TO - $lastmessage", 0);
- return false;
- }
复制代码
文件 2:/api/mail_queue_cli.php(第 7-8 节,处理结果部分)
修改前:
- $result = sendmail($task['toemail'], $task['subject'], $task['message']);
- if ($result) {
- DB::update('common_mail_queue', array('status' => 1), array('id' => $task['id']));
- echo "✅ Sent to: {$task['toemail']}\n";
- } else {
- // 发送失败,仅记录日志,不改变状态
- echo "❌ Failed to send: {$task['toemail']}\n";
- }
复制代码
修改后:
- // 清除旧的错误码
- $_G['smtp_error_code'] = 0;
- $result = sendmail($task['toemail'], $task['subject'], $task['message']);
- if ($result) {
- // 发送成功
- DB::update('common_mail_queue', array('status' => 1), array('id' => $task['id']));
- file_put_contents('/www/wwwroot/www.dianbo.org/data/log/mail_send.log', date('Y-m-d H:i:s') . " 独立脚本cron已经发送给 {$task['toemail']}\n", FILE_APPEND);
- echo "✅ Sent to: {$task['toemail']}\n";
- } else {
- $error_code = isset($_G['smtp_error_code']) ? intval($_G['smtp_error_code']) : 0;
- // 如果是永久性错误(550, 553, 501, 510等),直接标记为失败,不重试
- $permanent_errors = array(550, 553, 501, 510, 551);
- if ($error_code > 0 && in_array($error_code, $permanent_errors)) {
- DB::update('common_mail_queue', array(
- 'status' => 2,
- 'retry' => intval($task['retry']) + 1
- ), array('id' => $task['id']));
- file_put_contents('/www/wwwroot/www.dianbo.org/data/log/mail_send.log', date('Y-m-d H:i:s') . " 发送失败(永久错误 {$error_code}): {$task['toemail']}\n", FILE_APPEND);
- echo "❌ Permanent error {$error_code}: {$task['toemail']}\n";
- } else {
- // 临时性错误,继续重试逻辑
- $retry_count = intval($task['retry']) + 1;
- if ($retry_count >= 3) {
- DB::update('common_mail_queue', array(
- 'status' => 2,
- 'retry' => $retry_count
- ), array('id' => $task['id']));
- file_put_contents('/www/wwwroot/www.dianbo.org/data/log/mail_send.log', date('Y-m-d H:i:s') . " 发送失败(已达最大重试次数): {$task['toemail']}\n", FILE_APPEND);
- echo "❌ Failed (max retry): {$task['toemail']}\n";
- } else {
- DB::update('common_mail_queue', array(
- 'retry' => $retry_count
- ), array('id' => $task['id']));
- file_put_contents('/www/wwwroot/www.dianbo.org/data/log/mail_send.log', date('Y-m-d H:i:s') . " 发送失败(第{$retry_count}次重试): {$task['toemail']}\n", FILE_APPEND);
- echo "❌ Failed (retry {$retry_count}/3): {$task['toemail']}\n";
- }
- }
- }
复制代码
三、最终实现效果
| 场景 | 修改前 | 修改后 | | 无效邮箱(550) | 无限重试,永不停止 | 1 次尝试 + 3 次重试后自动标记失败 | | 有效邮箱发送成功 | 正常 | 正常 | | 临时性错误(超时等) | 无限重试 | 最多重试 3 次 | | SMTP 错误码识别 | ❌ 不识别 | ✅ 识别 550/553/501/510/551 | | 日志记录清晰度 | 仅记录"发送失败" | 记录错误码和重试次数 | | 队列阻塞 | 无效邮件阻塞后续邮件 | 快速清理无效邮件 |
实际运行日志示例(修改后):
- 2026-08-18 12:00:04 发送失败(第1次重试): [email]22274653604@qq.com[/email]
- 2026-08-18 12:10:07 发送失败(第2次重试): [email]22274653604@qq.com[/email]
- 2026-08-18 12:19:06 发送失败(已达最大重试次数): [email]22274653604@qq.com[/email]
复制代码
四、重要技术沉淀
- 永久性 SMTP 错误分类:550(收件人不存在)、553(邮箱名称无效)、501(语法错误)、510(收件人地址被拒绝)、551(用户不在本地)等为永久性错误,应直接放弃重试
- 全局变量传递错误码:Discuz! 的 sendmail 函数只返回 true/false,可通过 $_G 全局变量额外传递错误码
- 重试策略:对于邮件发送,建议最多重试 3 次,且重试间隔应逐步增加(指数退避),当前每分钟 1 次间隔仍需优化
- 队列状态管理:status=0(待发送)、status=1(成功)、status=2(失败),失败邮件不会再次被处理
是否需要更新固化摘要:✅ 是。建议在 env-base 固化摘要的“邮件系统”章节补充邮件队列的重试机制和错误分类策略。
五、进一步优化的可能性
- 指数退避重试:当前重试间隔固定为 1 分钟,可改为 5 分钟 → 15 分钟 → 60 分钟逐步延长
- 后台管理界面:在 Discuz! 后台增加邮件队列管理页面,支持查看、删除、手动重试失败邮件
- SMTP 日志分级:目前 smtp_debug.log 和 smtp_debug_*.php 日志混用,可统一格式并定期清理
- 队列优先级:可为系统通知、用户注册验证等邮件设置更高优先级,优先于普通通知邮件(已有策略:注册邮件同时10封以上才入队,小于10则直接发送)
- 实时发送与队列结合:系统已支持评论/评分等场景的实时发送(已有独立通知机制),可考虑将队列发送作为兜底方案
|
|