Repository navigation
fix(smtp): 防止 SQLite 锁冲突导致审计记录丢失 - #358
Merged
Jinnrry merged 1 commit intoAug 20, 2026
Merged
Conversation
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
问题
PMail 使用 SQLite 时,外部读事务或并发写事务可能使
INSERT、UPDATE或事务提交返回SQLITE_BUSY/SQLITE_LOCKED。这是 SQLite 的正常锁语义,但原有 SMTP 持久化逻辑只记录数据库错误,随后仍继续执行下游投递并向提交方报告成功。这会产生以下不一致:
email写入失败,但 PMail 仍将邮件交给下游 SMTP,导致邮件已送达而审计行完全缺失。email已插入但user_email插入失败,留下部分记录。email与user_email的最终状态更新失败或只更新一张表。问题的触发条件可以来自 SQLite 锁竞争,但静默吞掉持久化失败并继续报告成功是 PMail 的应用层 bug。
修改内容
1. 原子写入初始审计记录
email与对应的user_email初始写入放入同一个事务。451 4.3.0 Temporary local persistence failure,让提交方稍后重试。2. 原子写入最终投递结果
email.status/error与user_email.status的最终更新放入同一个事务。3. 正确处理 SQLite BUSY/LOCKED
*sqlite.Error.Code()识别 SQLite 主错误码,不依赖错误字符串。busy_timeout=1000ms。busy_timeoutpragma;过大的显式值会被限制到 1000ms 并记录警告。journal_mode(WAL)。modernc.org/sqlite升级到v1.46.1,确保 COMMIT 返回 BUSY 后连接可以安全复用并重试完整事务。本 PR 不会自动切换 WAL。WAL 会影响备份、checkpoint、
-wal/-shm文件、网络文件系统兼容和磁盘监控,应作为独立的可选运维变更进行验证。4. 区分投递前和投递后的持久化失败
出站审计初始状态改为:
如果初始审计事务失败,邮件尚未交给目标 MX,此时返回 451 是安全的,不会造成重复投递。
如果目标 MX 已经接受 DATA,但最终状态事务仍然失败,则不能因本地数据库错误向上游返回 451,否则上游重试会产生重复邮件。此时:
status=6,表示最终投递结果尚未可靠写入。CRITICAL audit_event=delivery_result_persist_failed。email_id、msg_id、目标状态以及下游是否已接受。status=6会出现在 Web 发件列表中,但不会被 IMAP 错误地归入 INBOX 或 Sent。5. 保持现有提交语义
SMTP 结果语义
已知限制
status=6 尚无跨重启自动恢复
当前实现没有 durable reconciler。以下崩溃窗口可能留下长期
status=6:数据库无法仅根据 stale pending 记录判断邮件究竟尚未发送、已经送达、明确失败,还是多域部分成功。因此不能按记录年龄自动改成 sent/fail,也不能自动重新投递。
后续应补充:
SMTP 无法天然提供端到端 exactly-once。即使增加 result journal,远端接受与本地结果持久化之间仍存在无法完全消除的未知窗口。
SendAfter hooks 的现有顺序
本 PR 保留了现有同步
SendAfterhook 顺序:hook 完成后才写入最终状态。这会扩大下游返回与最终持久化之间的窗口,建议在独立修改中评估将最终状态持久化提前到 hooks 之前。兼容性
journal_mode。测试
新增测试覆盖:
emailINSERT 永久 BUSY 时不调用下游,并向上游返回 451。user_emailINSERT 失败时整个初始事务回滚。已执行:
以上检查通过。另外,使用
go test -p 1逐包运行除根 package 外的全部 Go package,结果均通过。完整
make test的根 package 测试仍受外部 DNS/SPF 时序影响:TestMaster/testUser2EmailList使用固定 8 秒等待,本地一次 SPF/DNS 处理约 10 秒,邮件随后正常入库;另一次重试在外部 DNS/setup 阶段超时。未观察到与本修复相关的 BUSY、451 或审计写入失败。完整
go vet ./...仍会报告仓库既有的 unkeyed struct literal;修改包的 scoped vet 已通过。未运行 MySQL 集成测试,因为本地没有可用的 MySQL 服务或容器。