62 KiB
62 KiB
Smail 代码逐行审查阶段报告
审查范围与状态
- 请求:对全部代码逐行审查,不能用搜索命中、编译成功或测试通过代替逐行检查。
- 开始时间:2026-09-06 01:46:05,时区 Asia/Shanghai。
- 基线:
9849f8f11c9f49efd1bc877d7fedd8da265e32df,分支main。 - 审查前已建立中文提交检查点,远端
smail/main已核验一致。 - 当前结论:审查进行中,尚未完成全仓逐行覆盖;发现的问题尚未修复。
- 本轮只新增隔离审查测试、覆盖工具和报告,未修改业务源码,未清空真实数据库,未停止开发服务。
需求确认与任务清单
按用户明确要求,审查范围包括业务源码、测试、构建部署脚本、配置模板及前端页面。锁文件、第三方资源、二进制资源单独分类,不伪装成已逐行审查的业务代码。
- [✓] 核验工作区及审查前 Git 检查点。
- 运行原有前后端测试基线,结果通过,等待结合新增场景复审。
- [~] 建立逐文件、行范围和哈希覆盖账本。
- [~] 检查认证恢复、迁移、邮件列表、IMAP、配置与前端交互。
- [~] 用隔离 SQLite、真实本地协议和真实前端函数验证问题。
- 补齐全仓其余文件的逐行审查。
- 浏览器 MCP 验收完整业务流程。
- MySQL、PostgreSQL 实库及 Linux/Windows 部署验收。
- 全部发现项修复后的独立复审;本轮未执行修复。
已确认问题
优先级:P1 为安全授权、数据完整性或核心功能问题;P2 为特定场景故障及一致性问题。编号固定,便于后续复现和修复。
R01 / P1:迁移成功切库,但自定义唯一索引静默丢失
- 位置:
backend/services/dbmigration/portable_schema.go:9-28;portable_executor.go:177-223;portable_data.go:72-95,111-117。 - 前提:源库包含版本化表之外的自定义唯一索引。
- 原因:源对象校验未识别这些索引,目标库按内置结构重建,之后只验证数据,不验证自定义约束等价性。
- 实测:完整版本化 SQLite 源库增加唯一索引后执行迁移,结果为
switched=true custom_index_count=0 duplicate_insert_error=<nil>。 - 影响:迁移被报告成功,但源库禁止的重复值可以写入目标库。
- 建议:无法忠实迁移的结构在预览和执行时明确拒绝,或完整迁移并比较索引、约束。
- 边界:只实测 SQLite,不将结果泛化为三种数据库实库均已验证。
- 复现:
TestAuditCustomUniqueIndexMustNotDisappear。
R02 / P1:登录二次验证重发接口错误要求先登录
- 位置:
backend/listen/http_server/https_server.go:168-170,194-200;api_v1_compat.go:19;http_server.go:52;frontend/src/views/LoginVerification/LoginVerification.js:93-103。 - 前提:密码已验证,但尚未完成登录二次验证,用户需要重发验证码。
- 原因:登录前安全接口白名单遗漏
/api/v1/security/login/resend,请求未到验证码控制器就被登录拦截。 - 实测:真实路由、内存会话返回 HTTP 200,响应体业务码 403:
Login has expired.。 - 影响:未登录用户无法在二次验证页重发验证码。
- 建议:将该接口归入登录前安全路由,保留挑战票据、限频及 CSRF 等校验。
- 边界:未发送真实验证码;不能据此断言前端必然跳回登录页。
- 复现:
TestAuditAnonymousLoginResendMustReachController。
R03 / P2:迁移排空请求不响应取消
- 位置:
backend/services/dbmigration/maintenance.go:62-70;service.go:224-232。 - 原因:持有或升级请求租约时使用不可取消的
RWMutex.Lock(),传入的上下文未参与等待。 - 实测:30ms 上下文超时,200ms 后仍未退出,必须释放在途读锁才恢复。
- 影响:在途请求长期不结束时,迁移取消/超时不能及时结束排空,还可能让新 HTTP 请求排队等待。
- 建议:采用可取消的排空协调方式,确保请求租约、迁移状态和错误路径一致释放。
- 边界:证明的是超时不生效,不是已经复现永久死锁。
- 复现:
TestAuditRequestDrainMustRespectCancellation。
R04 / P2:邮件列表第二页起返回总数 0
- 位置:
backend/services/list/list.go:28-30,98-107;backend/controllers/email/list.go:120-129。 - 原因:聚合计数查询错误附带分页偏移和排序。
- 实测:31 封邮件,每页 15 封;偏移 0/15/30 分别返回行数 15/15/1,总数却为 31/0/0。
- 影响:接口分页元数据错误,依赖每次响应总数的调用方会出现错误页数。
- 建议:计数与列表查询分离,仅共享筛选条件。
- 边界:当前网页翻页函数未逐次刷新总页数,不能声称网页翻页后分页控件必然消失。
- 复现:
TestAuditPaginationMustPreserveTotal。
R05 / P1:撤销安全密钥后,旧恢复票据仍允许改密
- 位置:
backend/services/recovery/service.go:315-328,390-417;backend/services/webauthn/service.go:323-333。 - 前提:安全密钥验证后已签发恢复票据,随后该凭据被撤销,票据仍未过期。
- 原因:恢复完成阶段只对邮箱来源重新检查凭据;WebAuthn 来源没有同等撤销检查。
- 实测:真实撤销凭据后,旧票据完成改密返回成功,并用密码校验函数确认新密码生效。
- 影响:用户撤销凭据后,先前依赖该凭据产生的改密授权仍有效到票据过期。
- 建议:完成改密时重新校验票据所绑定的凭据归属、撤销状态及版本,或撤销时使关联票据失效。
- 边界:隔离测试从签发恢复票据阶段开始,不验证真实设备的 WebAuthn 签名链;不是未认证直接获取票据的证明。
- 复现:
TestAuditRevokedWebAuthnMustInvalidateRecoveryTicket。
R06 / P2:配置审计导出下载内容为 undefined
- 位置:
frontend/src/utils/axios.js:71-78;frontend/src/views/components/ConfigSettings/ConfigSettings.js:549-561。 - 原因:公共响应拦截器已返回
response.data,导出函数却再次读取结果的.data。 - 实测:执行真实响应拦截器和真实导出函数,CSV、JSON 两种格式下载内容均为字符串
undefined。 - 影响:导出请求可成功,但生成的文件没有审计记录。
- 建议:统一请求工具的返回类型,直接使用返回的 Blob,并覆盖两种导出格式。
- 边界:网络和下载 DOM 被隔离,未做真实浏览器文件下载验收。
- 复现:
frontend-findings.test.mjs中的审计导出测试。
R07 / P2:写信页附件按钮未绑定文件输入框
- 位置:
frontend/src/views/Editer/Editer.vue:12,116-134;Editer.js:197-199。 - 原因:
usePage()已返回fileRef,但 Vue 文件的解构遗漏该变量,模板引用没有连接到组合函数的引用。 - 实测:编译真实 SFC、挂载真实组合函数后,
template_file_ref_connected=false,调用附件按钮方法触发dispatchEvent空引用错误。 - 影响:通过附件按钮选择文件失败。
- 建议:在页面脚本中正确接收并绑定
fileRef,增加挂载后点击测试。 - 边界:使用 Vue 自定义渲染器,不是操作系统文件选择框的浏览器验收。
- 复现:
frontend-findings.test.mjs中的附件按钮测试。
R08 / P2:IMAP COPY 后,网页移动误判邮件不属于当前用户
- 位置:
backend/services/group/group.go:393-403,741-751;backend/controllers/email/move.go。 - 前提:IMAP COPY 为同一封邮件建立多个当前用户的文件夹关系。
- 原因:移动服务将关系行数和唯一邮件 ID 数量直接比较,没有正确处理合法副本。
- 实测:真实本地 TLS IMAP COPY 后,关系行数为 2;网页调用的服务返回
one or more messages are not owned by the user。 - 影响:用户自己的邮件因存在 IMAP 副本而不能通过网页正常移动。
- 建议:定义清晰的邮箱关系身份,区分邮件内容 ID 与文件夹中的关系 ID,所有权校验按实际目标关系执行。
- 边界:当前服务器没有声明 MOVE 扩展,客户端
Move()回退 COPY/STORE/EXPUNGE 且成功;不能报告成 IMAP 客户端 MOVE 必然失败。 - 复现:
TestAuditCopyThenMoveMustKeepMailboxIdentity。
R09 / P2:安全运行时发布和读取存在数据竞争
- 位置:
backend/controllers/security.go:89;backend/controllers/webauthn.go:63;backend/res_init/init.go:252-259,394-402。 - 原因:全局 WebAuthn 服务指针无同步读写,关闭流程在 HTTP 停止前清空服务,可能与在途控制器读取并发。
- 实测:
-race报告ConfigureWebAuthnRuntime与webAuthnRuntime的数据竞争。 - 影响:生命周期切换期间并发访问不安全,运行时可见性无保证。
- 建议:用互斥锁或原子发布一致的安全运行时快照,调整停止顺序并补并发生命周期测试。
- 边界:尚未复现真实重启崩溃;普通测试模式显示通过不能消除 race 证据。
- 复现:
TestAuditWebAuthnRuntimePublication,必须使用-race。
R10 / P2:IMAP EXAMINE 只读选择仍持久化修改邮件标记
- 位置:
backend/listen/imap_server/session_select.go:10-44;session_store.go:18-20,81-91;server.go:50-58。 - 原因:会话未保存
SelectOptions.ReadOnly,STORE 没有只读状态检查。 - 实测:真实 TLS IMAP 使用 EXAMINE 打开 INBOX 后执行 STORE,返回成功,临时数据库的
is_read从 0 变为 1。 - 影响:客户端要求的只读邮箱状态没有生效。
- 建议:保存只读选中状态,对 STORE、EXPUNGE 及非 PEEK FETCH 等写入路径统一检查。
- 复现:
TestAuditExamineMustNotPersistStore。
R11 / P1:IMAP 同文件夹副本复用可变对象,覆盖 UID 并遗漏 FETCH 响应
- 位置:
backend/services/list/list.go:307-322;backend/listen/imap_server/session_fetch.go:147-157,253-300。 - 前提:同一邮件经 IMAP COPY 在同一文件夹生成两个不同 UID 的关系,并在一次范围查询中读取。
- 原因:内容缓存按
email_id保存同一个响应指针,在遍历关系时直接覆盖该指针的 UID、序号和标记;两个结果最终指向同一对象。 - 实测:数据库为 UID 17、18,均在 INBOX;真实列表函数返回
hydrated_uids=18,18 same_pointer=true;TLS IMAP FETCH 两个 UID 只收集到 1 封响应且没有错误。 - 影响:邮箱副本身份和状态被混淆,客户端同步可能漏信;使用相同构造函数的批量操作也需逐项复查。
- 建议:为每个邮箱关系构造独立响应对象,允许共享不可变内容但不能共享 UID、序号和状态;增加 COPY 后范围 FETCH/STORE 的回归测试。
- 边界:协议层实测为漏响应,不将其表述成客户端已收到两条重复 UID 响应。
- 复现:
TestAuditCopiedMessagesMustKeepDistinctUIDs。
R12 / P2:IMAP 顶层文件夹枚举遗漏默认文件夹
- 位置:
backend/listen/imap_server/session_list.go:14-16,62-66。 - 原因:仅
LIST "" "*"分支生成默认文件夹;标准顶层通配符%分支只查询自定义文件夹表,且通配规则直接复用 SQL LIKE。 - 实测:真实协议
LIST "" "%"返回空列表,尽管同一账户有 INBOX 和真实邮件。 - 影响:按层级枚举邮箱的客户端无法发现默认收件箱等文件夹。
- 建议:先组合默认和自定义文件夹,再统一按 IMAP 的 reference、
%和*规则匹配,不能直接等同 SQL 通配符。 - 复现:
TestAuditListPercentMustIncludeInbox。
R13 / P2:移走最大 UID 邮件后,UIDNEXT 倒退
- 位置:
backend/services/group/group.go:573-575,631-651。 - 原因:用当前仍在文件夹内的最大关系 ID 加一计算 UIDNEXT,没有保存已分配 UID 的高水位。
- 实测:将最新邮件从 INBOX 移到 Junk,STATUS 返回 UIDNEXT 从 20 降为 19,但 UIDVALIDITY 始终为 2000000000。
- 影响:违反同一 UIDVALIDITY 下 UIDNEXT 不应倒退的同步约定,客户端缓存与增量检测可能不一致。
- 建议:使用单调分配的邮箱 UID 状态,不能根据剩余邮件反推下一个 UID。
- 边界:实测是 UIDNEXT 倒退,不是已经证明数据库复用了旧 UID。
- 复现:
TestAuditUIDNextMustNotDecrease。
R14 / P2:UID FETCH * 无法读取最后一封邮件
- 位置:
backend/listen/imap_server/session_fetch.go:147-151;backend/services/list/list.go:250-252。 - 原因:协议动态 UID
*用 0 表示,未解析为当前最大 UID 就传入查询函数,被判定为空条件返回空列表。 - 实测:非空 INBOX 中最大 UID 为 20,真实协议
UID FETCH *返回 0 封且没有错误。 - 影响:依赖标准动态 UID 查询的客户端拿不到最后一封邮件。
- 建议:在协议入口统一解析动态 UID、反向范围及空邮箱边界,再交给数据库查询;同步检查 COPY/STORE。
- 复现:
TestAuditFetchWildcardUIDMustReturnLastMessage。
R15 / P2:登录邮箱绑定被计入收件箱邮件数量
- 位置:
backend/services/group/group.go:654-662;backend/models/user_email.go。 - 原因:
user_email同时保存邮件关系和登录邮箱绑定,绑定记录的email_id=0;邮箱计数未排除这类记录,但邮件内容查询会排除它们。 - 实测:真实本地 TLS IMAP STATUS 返回
login_bindings=1 real_messages=15 status_messages=16,与临时数据库中有效邮件数量不一致。 - 影响:客户端看到的收件箱数量大于实际可读取数量。
- 建议:统一有效邮件关系的筛选条件,或把账户绑定与邮件关系分表;同步检查 SELECT、STATUS、IDLE、Poll 的计数定义。
- 边界:已复现 STATUS 数量错误;其他命令的实际客户端影响仍需分别验证。
- 复现:
TestAuditInboxCountMustExcludeLoginBindings。
R16 / P2:符合权限条件的用户角色修改仍被拒绝
- 位置:
backend/controllers/authorization.go:13-24;backend/controllers/user.go:218-240;backend/services/access/authorize.go:89-95。 - 前提:超级管理员近期认证有效,目标普通用户已配置恢复码,拟将其调整为管理员。
- 原因:控制器调用通用授权适配器时没有传递
NewRole,而role.manage策略明确要求NewRole.Valid(),因此总是拒绝。 - 实测:直接构造完整授权请求可通过;同样前提下执行真实 EditUser 控制器返回业务码 405,数据库角色仍为
user。 - 影响:已有用户的角色提升和降低流程不可用。
- 建议:明确传递旧角色和新角色;修复后同时复查角色变化时会话版本递增逻辑,避免启用当前不可达分支时引入新问题。
- 边界:已验证控制器及临时数据库,不是浏览器用户管理界面的操作验收。
- 复现:
TestAuditAuthorizedRoleChangeMustSucceed。
R17 / P1:密码恢复 Cookie 路径不覆盖前端使用的版本化接口
- 位置:
backend/controllers/security.go:583-586,595-598;backend/listen/http_server/api_v1_compat.go:27-31;frontend/src/views/PasswordRecovery/PasswordRecovery.js:65,130,143。 - 原因:恢复上下文 Cookie 的 Path 固定为
/api/security/recovery,前端接口已改为/api/v1/security/recovery/...;服务端内部路由重写不会改变浏览器的 Cookie 路径匹配。 - 实测:真实恢复申请控制器签发 Cookie;TLS HTTP 客户端配合标准 CookieJar,旧接口后续请求携带 1 个 Cookie、上下文可读取;版本化接口携带 0 个、上下文不可读取。
- 影响:邮箱恢复在验证阶段被拒绝;离线恢复即使得到票据,也会在完成阶段因缺少上下文被拒绝。安全密钥恢复使用同一 Cookie,需纳入修复回归。
- 建议:为新旧接口制定一致且足够窄的 Cookie Path,并同步更新签发、读取、清除与历史 Cookie 兼容测试;不能删除上下文校验来绕过故障。
- 边界:已验证真实 TLS 传输、控制器和 Cookie 路径;未操作浏览器、未投递真实邮件、未完成真实安全密钥恢复。
- 复现:
TestAuditRecoveryCookieMustReachVersionedAPI,旧路径为通过对照组。
R18 / P2:POP3 消息序号错误使用全局邮件 ID
- 位置:
backend/listen/pop3_server/action.go:145-176,195-208,215-235,243-245。 - 原因:没有会话级邮箱快照和从 1 开始的消息序号映射,列表和读取参数直接使用全局邮件 ID。
- 实测:临时邮箱只有两封邮件,LIST 返回编号 305、101,查询
LIST 1返回Not authorized。 - 影响:按 STAT 得到的数量依次读取 1..N 的标准 POP3 客户端不能正确下载邮件,连接期间新增邮件也缺乏稳定序号视图。
- 建议:认证成功后建立稳定的有效邮箱关系快照,分离消息序号和 UIDL 唯一标识,读取、删除和统计共用该映射。
- 边界:已验证真实 Action 和临时数据库;未操作外部 POP3 客户端。
- 复现:
TestAuditPOP3MessageNumbersMustStartAtOne。
R19 / P2:POP3 删除标记没有影响当前会话的邮箱视图
- 位置:
backend/listen/pop3_server/action.go:130-138,142-180,190-239,267-272。 - 原因:DELE 只追加
session.DeleteIds,没有校验消息存在性;STAT、LIST、UIDL 没有排除已标记删除项。 - 实测:DELE 后 LIST 和 STAT 仍返回 1 封、UIDL 仍可查询;重复 DELE 和不存在消息的 DELE 都返回成功。
- 影响:客户端看到错误的删除状态,后续同步和退出提交不符合 POP3 事务约定。
- 建议:统一检查会话内有效消息及删除标记,直到 RSET 撤销或 QUIT 提交;不能直接在 DELE 时物理删除邮件。
- 边界:本项验证临时库的真实 Action,不证明跨用户越权删除。
- 复现:
TestAuditPOP3MarkedDeletionMustHideMessage。
R20 / P2:POP3 STAT 与 LIST 查询不同的邮箱集合
- 位置:
backend/services/list/list.go:63-64,87-89,117-123;backend/listen/pop3_server/action.go:133,215。 - 原因:STAT 统计全部未删除的接收邮件,LIST 只查询收件箱状态和分组。
- 实测:收件箱 1 封、垃圾箱 1 封时,STAT 返回 2,LIST 只列出 1 封。
- 影响:客户端得到的邮箱数量与实际列表不一致;这与 R19 的会话删除标记遗漏是不同原因。
- 建议:明确 POP3 邮箱可见范围,并让统计、列表、UIDL 和读取共享同一快照。
- 复现:
TestAuditPOP3STATMustMatchVisibleMailbox。
R21 / P2:POP3 将标准 RSET 拼成 REST,无法撤销待提交删除
- 位置:
backend/internal/gopop/pop3.go:309-326;backend/listen/pop3_server/action.go:61,275-278,327-334。 - 原因:解析器只识别
REST,标准命令RSET落入不支持的自定义命令分支。 - 实测:通过
net.Pipe执行真实解析器,DELE 后发送 RSET 返回-ERR unsupported command,随后 QUIT 仍把[1]传给删除提交操作。 - 影响:标准客户端不能用 RSET 撤销删除;若忽略错误继续 QUIT,原有删除集合仍会提交。
- 建议:支持标准 RSET 并测试事务回滚;如保留旧拼写兼容,应明确其非标准身份。
- 边界:数据提供层使用测试 Action,真实解析器和协议响应未替换;没有删除真实或临时数据库邮件。
- 复现:
TestAuditPOP3RSETMustCancelPendingDeletion。
R22 / P2:单封 LIST 和 UIDL 被错误编码成多行响应
- 位置:
backend/internal/gopop/pop3.go:232-258。 - 原因:没有按命令是否携带消息序号区分单行与多行响应,两种情况都输出列表和终止点。
- 实测:真实解析器对
LIST 1和UIDL 1的首行都只返回+OK,其后还残留 10 和 20 字节的额外列表响应。 - 影响:客户端不能从成功首行解析消息大小或唯一标识,后续命令可能读取到前一命令残留响应。
- 建议:携带消息序号时使用单行成功响应;无参数列表才使用多行与终止点。
- 边界:通过内存连接验证真实响应编码,数据提供层为确定性夹具。
- 复现:
TestAuditPOP3SingleMessageRepliesMustBeSingleLine。
R23 / P1:POP3 TOP 负数行参数触发未恢复的切片越界 panic
- 位置:
backend/internal/gopop/pop3.go:77,111,260-267;backend/listen/pop3_server/action.go:299-311。 - 前提:已通过 POP3 认证,拥有一封可正常读取的邮件。
- 原因:解析器只校验整数格式、不拒绝负数;Top 用该值直接计算切片上界,连接处理 goroutine 未设置 panic 恢复。
- 实测:真实解析器把
-100000传入 Top;另一个隔离测试先确认正常 Top 成功,再调用真实 Action,捕获slice bounds out of range [:-99992]。 - 影响:持有有效协议授权码的客户端可以触发未恢复 panic,按 Go 的运行规则会终止后端进程,不只关闭这一条连接。
- 建议:协议入口和处理层双重校验非负行数及有效消息序号,使用安全边界计算;panic 防护只能作为兜底,不能代替参数修复。
- 边界:解析器参数传递与真实 Action panic 分段验证;测试捕获 panic,未向正在运行的服务发送恶意命令,也未实际终止后端进程。
- 复现:
TestAuditPOP3ParserMustRejectNegativeTOPLines、TestAuditPOP3NegativeTOPMustNotPanic。
R24 / P1:普通收件人的邮件详情响应泄露密送收件地址
- 位置:
backend/dto/parsemail/email.go:381-397;backend/listen/smtp_server/read_content.go:275-288;backend/models/email.go:19,67-86;backend/controllers/email/detail.go:29-36。 - 前提:同一 SMTP 事务同时投递给可见收件人和密送收件人,原始邮件头不含密送地址。
- 原因:解析器把信封中未出现在 To 的地址推导为 Bcc,完整存入多用户共享的邮件内容记录;收件人详情响应直接序列化 Bcc,没有按收件身份隐藏。
- 实测:原始 MIME 不含测试密送地址,真实解析和入库生成两个用户关系;以普通可见收件人身份执行真实详情服务和 HTTP 响应序列化,响应包含该密送地址。
- 影响:普通收件人可以从 API 获知应保密的其他收件地址,无需拥有管理员权限,也不取决于页面是否展示 Bcc。
- 建议:区分投递信封元数据与用户可见邮件内容;发送者可见的密送清单不能共享给收件人,检查详情、列表、导出等所有序列化入口。
- 边界:隔离 SQLite 验证解析、真实入库、授权详情查询及响应编码;未运行网络 SMTP 投递或浏览器界面。
- 复现:
TestAuditInboundBCCMustNotLeakToVisibleRecipient。
R25 / P2:标准 Reply-To 在解析和邮件重建时丢失
- 位置:
backend/dto/parsemail/email.go:402,824-840。 - 原因:读取的头名是
ReplyTo而非标准Reply-To,BuildBytes 也没有输出已有的 ReplyTo 字段。 - 实测:含标准 Reply-To 的原始 MIME 解析后回复地址数量为 0;单独补入合法 ReplyTo 后重建,结果头仍为空。
- 影响:SMTP 接收、POP3/IMAP 重建读取及发送流程会丢失指定回复地址,客户端可能回复到错误的 From 地址。
- 建议:按标准头名解析并保留回复地址列表,补充解析、持久化、重建的完整往返测试。
- 边界:真实解析和 MIME 重建验证,未实测外部客户端发送回复。
- 复现:
TestAuditReplyToMustSurviveParseAndBuild。
R26 / P2:带逗号的合法收件人姓名被拆成无效地址
- 位置:
backend/dto/parsemail/email.go:579-591;backend/utils/validation/email.go:40-44。 - 原因:地址列表直接使用
strings.Split(line, ","),忽略带引号的姓名、注释和标准地址列表语法。 - 实测:
"Doe, Jane" <jane@example.com>被解析为两个收件项,第一个是无效片段,第二个姓名也不完整。 - 影响:正常客户端提交这类常见显示姓名时,后续验证可拒绝原本合法的邮件;接收邮件的地址元数据也被污染。
- 建议:使用标准地址列表解析器,错误时明确拒绝,不能按逗号切割后把无效片段当邮箱地址。
- 边界:已验证真实 MIME 解析输出;验证拒绝的调用链来自源码,未操作外部 SMTP 客户端。
- 复现:
TestAuditQuotedRecipientNameMustNotSplitAddress。
R27 / P2:邮件重建丢失内嵌图片的 Content-ID
- 位置:
backend/dto/parsemail/email.go:689-700,890-902。 - 原因:附件重建只写 Content-Type 和文件名,没有输出存储的 Content-ID,也没有保留内嵌附件语义。
- 实测:构造含
cid:audit-inline的 HTML 和同名 Content-ID 的图片附件,真实 BuildBytes 后再次解析,HTML 引用仍在,但附件 Content-ID 变为空。 - 影响:POP3/IMAP 下载、重建转发或发送后,客户端无法把 HTML 内嵌图片引用匹配到附件。
- 建议:保留 Content-ID 和内嵌/下载附件类型,覆盖原始 MIME 到存储再到协议读取的往返流程。
- 边界:本项直接实测 BuildBytes;ForwardBuildBytes 有同样字段遗漏,实际转发客户端显示仍需回归。
- 复现:
TestAuditInlineAttachmentMustPreserveContentID。
R28 / P1:数据库迁移丢失自增历史高水位,复用已删除邮件的 UID
- 位置:
backend/services/dbmigration/portable_data.go:23-24,107-155;portable_executor.go:208-223;backend/services/group/group.go:576-577,600-601。 - 前提:源库正常分配过邮箱关系 ID,随后删除最大 ID 对应的关系,再执行数据库迁移。
- 原因:迁移只复制现存业务行,不保存源库的自增序列高水位;SQLite 系统序列表被排除,目标序列由剩余行重新生成,文件夹 UIDVALIDITY 又没有重置。
- 实测:正常分配关系 ID 1、2,再删除 2,源序列仍为 2;真实 PortableExecutor 成功迁移后,目标序列为 1,新关系再次获得 ID 2。日志为
switched=true removed_uid=2 source_sequence=2 target_sequence=1 newly_allocated_uid=2。 - 影响:迁移成功后复用历史邮件 UID,可能让客户端把新邮件与已缓存或已删除邮件混淆;其他依赖历史唯一身份的自增表也需逐一核验。
- 建议:迁移并校验源库序列及自增历史高水位,目标下一值必须超过历史已分配范围,而不能只使用现存行的 MAX(id)。
- 边界:只实测 SQLite→SQLite,没有执行迁移前后真实 IMAP 客户端缓存验证;与 R13 的 UIDNEXT 倒退不同,本项已经实测数据库 ID 重用。PostgreSQL 的 MAX(id) 序列重建仅有源码证据,未做实库验证。
- 复现:
TestAuditMigrationMustPreserveAllocatedMailboxUIDs。
R29 / P1:配置提交失败后的成功重试,会在重启时被旧事务回滚
- 位置:
backend/config/manager_v2.go:163-166,395-397;backend/config/transaction.go:101-106,146-161,229-239;backend/controllers/config.go:130-150。 - 前提:一次配置应用在分区提交阶段中断,管理员在同一进程中重新应用,收到成功结果,之后重启服务。
- 原因:失败事务留在事务目录;后续重试新建事务并成功提交,但启动恢复仍对旧的未完成事务无条件恢复原始文件,没有识别它已被更新的成功提交取代。
- 实测:真实 manager 和 loader 配合项目已有故障注入钩子,第一次应用失败、重试成功;重启前磁盘代次为 2、邮件大小策略为 8 MiB,新建 manager 触发启动恢复后变为代次 1、旧值 0。
- 影响:用户已经确认保存成功的配置在下次启动时丢失,当前代次和安全、邮件等配置可能退回旧状态。
- 建议:允许重试或新提交前处理未决事务;恢复时核对代次、内容摘要与事务关系,不能让过期失败事务覆盖后续已确认提交。
- 边界:只操作临时配置目录,使用真实提交、加载和恢复实现;没有重启用户后端,也没有模拟物理磁盘故障。
- 复现:
TestAuditSuccessfulConfigRetryMustSurviveRestart。
R30 / P2:保存候选配置后编辑或刷新,页面失去继续操作入口
- 位置:
frontend/src/views/components/ConfigSettings/ConfigSettings.js:131-149,188-200,468-496,587-588;backend/config/manager_v2.go:299-300,328-332,368-369,608-609。 - 前提:保存候选配置成功,但尚未应用;用户继续编辑字段,或确认刷新配置页面。
- 原因:前端清空候选代次及对应输入,后端却继续保留唯一候选并拒绝后续保存;应用方法又在没有本地候选代次时直接返回。重新登录还会因候选绑定旧 SessionID 而不能应用。
- 实测:两种操作均得到
server_pending=true saved_generation=0 apply_requested=false,再次保存返回代次冲突;真实后端 manager 另行验证原会话再次保存冲突、新会话保存冲突及应用拒绝。保留代次的原会话可以成功应用,作为对照组。 - 影响:正常编辑或刷新即可让在线配置管理卡住,影响所有共享该候选状态的分区,不是单次按钮提示错误。
- 建议:设计有明确授权的候选查询、取消和重新保存流程,并在刷新后恢复状态;不能简单允许其他会话无条件应用敏感配置。
- 边界:执行真实前端组合函数和表单工具,网络按真实 manager 已验证的约束隔离;未做浏览器与后端连通验收,不宣称重启是唯一恢复方式。
- 复现:前端
edit、refresh两项候选测试;后端TestAuditPendingConfigBlocksFurtherUpdates是约束对照测试,当前通过不代表业务问题已修复。
R31 / P2:外部邮箱验证码输错一次后,页面不能继续验证原绑定
- 位置:
frontend/src/views/components/SecuritySettings/SecuritySettings.js:212-240;SecuritySettings.vue:60-69;backend/services/externalemail/repository.go:60-77;backend/controllers/security.go:631-673。 - 前提:第一次绑定已成功发码,用户输错验证码,随后再次点击“绑定并验证”。
- 原因:前端虽然保存了挑战和绑定 ID,但每次点击都重新请求绑定;后端拒绝同地址重复绑定,因而第二次不会弹出验证码输入框。待验证列表也没有继续验证或重发入口。
- 实测:真实前端函数输出
binding_pending=true prompt_count=1 verify_count=1,提示先为验证失败、再为绑定失败。真实控制器、仓储及验证码服务对照输出after_wrong_status=pending retry_bind_error=100 continued_verify_error=0,证明服务端允许原挑战正确重试,问题出在页面无法进入该路径。 - 影响:一次普通输入错误即可中断该地址的安全邮箱配置;待验证记录继续占用相应用途名额。取消、刷新及验证码过期场景也需要在修复时补齐恢复策略。
- 建议:显式区分创建绑定、输入验证码、重发和取消状态,失败后保留原挑战并允许再次输入;为已有待验证记录提供受授权的恢复和取消流程。
- 边界:前端网络隔离,后端使用临时 SQLite 和测试发送器,未连通真实浏览器或投递真实邮件;不把停用后重绑定策略直接列为另一项已确认缺陷。
- 复现:前端“外部邮箱验证码输入错误后必须能够再次输入”失败测试;后端
TestAuditPendingExternalBindingRequiresExistingChallenge为当前通过的约束对照测试,不代表页面问题已修复。
R32 / P1:外部安全邮箱绑定没有执行近期重新认证
- 位置:
backend/controllers/security.go:631-641,655-665;backend/listen/http_server/https_server.go:157-165,180。 - 要求:
docs/超级管理员重置与邮箱验证码需求文档.md:418-437明确要求绑定及更换外部邮箱先完成近期重新认证。 - 原因:中间件正确计算
RecentAuth,但绑定和验证控制器只检查登录用户与服务是否存在,没有执行近期认证授权。 - 实测:真实 TLS HTTP、版本化路由、CookieJar、会话和 CSRF 配合临时 SQLite。1 分钟会话为成功对照;11 分钟会话已经超过 10 分钟近期认证时限,仍返回
bind_error=0 verify_error=0 verified_binding=true。同一会话不携带 CSRF 时返回 100,证明不是绕过 CSRF 得出的结果。 - 影响:持有仍有效但未近期认证会话的人,可以将自己控制的邮箱注册为账户恢复凭据,绕过设计要求的再次确认身份边界。
- 建议:绑定前统一执行近期认证要求,并明确挑战期间认证过期的处理方式;不能把前端弹窗或 CSRF 当成重新认证。
- 边界:测试入口只签发确定年龄的真实会话,被审查接口及安全中间件没有替换;测试发送器捕获验证码,不投递邮件,未实测浏览器或完整账户接管。
- 复现:
TestAuditExternalBindingMustRequireRecentAuthentication,近期会话及缺失 CSRF 为对照。
R33 / P2:用户的 Smail 主邮箱可被绑定为外部安全邮箱
- 位置:
backend/controllers/security.go:641-646;backend/services/externalemail/repository.go:40-60;backend/controllers/login_verification.go:90-103。 - 要求:
docs/超级管理员重置与邮箱验证码需求文档.md:428,481-487禁止外部邮箱与当前用户主邮箱相同,也禁止将登录验证码发回正在登录的 Smail 邮箱。 - 原因:绑定只验证地址格式、指纹和名额,没有与用户主邮箱及认证地址映射比较;后续登录验证直接使用该主要外部邮箱。
- 实测:临时库建立真实主邮箱关系,
auth.ResolveIdentifier确认其对应当前用户,再经真实 TLS 版本化接口绑定并验证,结果primary_identity_resolved=true sent_to_own_mailbox=true bind_error=0 verify_error=0 verified_primary_external=true。 - 影响:系统接受不能独立于 Smail 登录访问的恢复渠道;之后登录或忘记密码时可能需要先进入同一个邮箱取码,形成无法完成的流程,并违反外部验证渠道要求。
- 建议:规范化后与用户当前主邮箱及相关认证地址核验,绑定及使用阶段都应保证目标符合外部渠道要求。
- 边界:实测完整绑定及验证,发送目标由测试发送器捕获;登录验证目标选择已核对源码,未执行真实邮件投递和完整登录恢复流程。
- 复现:
TestAuditExternalBindingMustRejectOwnPrimaryMailbox。
R34 / P2:邮件详情页的正常附件下载链接被后端拒绝
- 位置:
backend/controllers/attachments.go:37-44;backend/listen/http_server/http_server.go:93-94;frontend/src/views/EmailDetail/EmailDetail.vue:59。 - 原因:前端生成
/attachments/download/{邮件ID}/{索引},去掉两端斜杠后实际只有四段;控制器却要求五段并读取索引 3、4,尚未访问附件服务即返回参数错误。 - 实测:临时 SQLite 存入真实附件和用户邮件关系;服务层正确返回内容,其他用户访问被拒绝。相同已登录用户经真实 TLS、Cookie 和完整路由请求
/attachments/download/1/0,得到 HTTP 200 和errorNo=100,没有下载响应头或附件内容。 - 影响:从普通邮件详情页点击附件无法下载,不是缺少附件数据、服务未运行或所有权检查导致。
- 建议:统一生成与解析下载路由,严格解析正整数邮件 ID 和非负索引;补充正常链接、越界和非所有者请求的端到端测试。
- 边界:真实 HTTP 层验证,无浏览器点击及真实用户数据操作;未将所有权校验误报为缺失。
- 复现:
TestAuditAttachmentPageDownloadMustReturnStoredContent。
R35 / P2:邮件列表表格引用断连,选择和批量操作失效
- 位置:
frontend/src/views/List/List.vue:43,100-117;frontend/src/views/List/List.js:26,91-105,120-123,142-145,195。 - 原因:组合函数返回了
taskTableDataRef,但页面没有解构;模板同名字符串引用没有写入组合函数持有的 ref。 - 实测:编译并挂载真实 SFC 和组合函数,模拟表格正常发出选中事件,
selectedRows已有一行;点击全选、反选以及三种批量操作,结果connected=false cleared=0 toggled=0 requests=[],连续三次提示“请先选择邮件”。仅测试内手工连接该引用后,全选及已读、移动、删除请求均正常,作为对照。 - 影响:全选和反选无效果,即使用户已在表格勾选邮件,批量已读、移动、删除仍无法执行。
- 建议:在页面显式绑定组合函数返回的 ref,补真实页面挂载后的操作测试,而非只测试孤立函数。
- 边界:使用真实 Vue 编译器和渲染器,Element Plus 表格与网络为隔离桩;尚未在浏览器用实际表格验收。与 R07 写信页引用遗漏为不同页面的问题。
- 复现:前端“邮件列表挂载后,表格选中项必须用于全选和批量操作”。
R36 / P1:Windows 升级在备份前失败,回滚会删除旧程序
- 位置:
deploy/install-windows.ps1:131-138,171-183。 - 前提:已安装实例执行安装或升级,停止服务、复制候选程序或备份旧程序时发生异常。
- 原因:异常处理没有记录哪些目标已备份或替换,直接删除两个现有目标;只有备份存在才恢复。提前失败时,删除的就是尚未替换的旧程序。
- 实测:从当前脚本 AST 抽取真实事务语句,所有文件及服务命令改由内存函数桩执行。停止服务、两次复制、第一次备份失败均让两份旧程序丢失;第二次备份失败只恢复主程序,旧管理程序丢失。两份备份都完成后再失败则能恢复,作为对照。
- 影响:本应保持原安装的普通升级失败会破坏可用安装,导致服务下次无法启动或本地管理程序丢失。
- 建议:按目标记录备份和替换状态,仅清理本次真正安装的新文件;成功备份后才能覆盖,回滚失败时必须保留恢复材料并明确报告。
- 边界:没有执行真实安装脚本主体、删除磁盘文件、停止服务、调用系统安装工具或改变 ACL;此处确认的是实际控制流在指定异常下的结果,双平台部署验收仍未完成。
- 复现:
node test/review/run-windows.mjs,六个失败阶段内存验证。
R37 / P2:规则编辑取消后点击新建,提交的仍是对旧规则的修改
- 位置:
frontend/src/views/components/RuleSettings/RuleSettings.js:76-83,93-98;RuleSettings.vue:35,101;backend/controllers/rule.go:122-128。 - 原因:编辑表单直接共享原列表的条件数组,取消只隐藏弹窗;新建按钮又只显示弹窗,没有重置旧规则 ID 和草稿。
- 实测:编译并挂载真实 SFC 和组合函数,修改条件后通过真实模板点击取消、再点击新建和提交。原列表已变成未保存条件
unsaved,新建表单 ID 仍为 7,实际请求为/api/v1/rule/update且携带旧 ID。后端已核对会按该 ID 更新当前用户的原规则。 - 影响:用户取消的修改没有被丢弃,之后原本想新增的规则会覆盖已有规则,而非创建新记录。
- 建议:编辑时深拷贝条件;新建、取消和关闭均显式管理草稿生命周期,保证新建 ID 为 0;覆盖取消、关闭图标、遮罩关闭后重新新建的测试。
- 边界:真实 Vue 渲染器和模板事件,表格、弹窗容器和网络为桩;没有操作真实用户规则,尚未进行浏览器验收。早期渲染器桩错误不作为业务证据。
- 复现:
node --test test/review/rule-frontend.test.mjs。
R38 / P1:ACME 正常返回的完整证书链被重复拼接,自动签发和续期无法保存
- 位置:
backend/services/setup/ssl/ssl.go:366-376,430-446;backend/config/ssl_storage.go:221-230,282-290。 - 原因:签发请求设置
Bundle: true,lego 返回的Certificate已包含中间证书,IssuerCertificate又提供相同链。导入函数把 issuer 无条件追加到已完整的链尾,随后按相邻证书校验签名,最终尝试让非自签中间证书验证自身签名。 - 实测:生成根、中间及叶证书,完整链独立通过私钥匹配及域名、签名链校验;将与 lego
Bundle=true一致的完整链及独立 issuer 一起传入真实writeCertificateFiles,返回invalid SSL certificate material。当前依赖 lego v4.34.0 的acme/api/certificate.go:83-94与certificate/certificates.go:409-411已核对这种响应结构。 - 影响:常见的叶证书加非自签中间证书响应会被拒绝,初次自动签发不能进入完成阶段,自动续期也不能安装新证书。
- 建议:明确叶证书、完整链与 issuer 的接口语义,按证书 DER 去重或只使用已完整的链;保留真正的不匹配、过期和非法链拒绝测试。
- 边界:使用本地生成的证书复现实际存储入口,没有请求真实 CA、提交 DNS 挑战或消耗签发配额;不声称所有可能的证书链均会失败。
- 复现:
TestAuditACMEBundledChainMustBeImportable。
R39 / P2:有效泛域名证书被判定名称不匹配,每次检查都触发续期
- 位置:
backend/services/setup/ssl/ssl.go:298-315,512-518,530-548;backend/listen/cron_server/ssl_update.go:69-76。 - 原因:DNS 模式主动申请
*.example.test,但健康检查只判断 SAN 文本是否包含imap,而不是按 X.509 域名匹配规则判断服务名称。 - 实测:通过真实受控存储写入 90 天有效的
*.example.test完整链,真实VerifyHostname("imap.example.test")成功;CheckSSLCrtInfo却返回days_remaining=89 name_match_fail=true,续期条件为 true。 - 影响:DNS 泛域名模式在证书足够有效时仍反复申请证书,需要重复人工 DNS 挑战,并可能消耗签发配额;成功更新时还触发不必要的重启。
- 建议:根据实际服务域名调用标准域名校验,支持泛域名,不使用名称子串推断证书有效性。
- 边界:为隔离 R38,本测试不重复附加 issuer;只验证真实检查结果与续期条件,没有向 CA 发请求或实际重启服务。
- 复现:
TestAuditFreshWildcardCertificateMustNotRequireRenewal。
R40 / P1:分区配置下自动续期调用旧写入器,报错后证书目录却已切换
- 位置:
backend/services/setup/ssl/ssl.go:256-267,376-377,541-548;backend/config/config.go:281-288;backend/config/ssl_storage.go:137-160。 - 前提:已经迁移为分区配置,使用系统管理的自动证书;证书签发结果本身有效。
- 原因:续期先调用
ImportManagedSSL切换受控current目录,再调用明确禁止覆盖分区配置的旧版WriteConfig。后者失败后没有回滚证书,调用方按失败退出,不发送成功更新后的重启信号。 - 实测:临时根目录中生成有效证书、真实迁移并加载 ready 状态分区配置,再调用真实续期存储入口。结果为
root_unchanged=true current_changed=true current_is_new=true loaded_tls_is_old=true,错误为“分区配置不能通过旧版写入接口覆盖,请使用配置管理”。 - 影响:续期无法完成正常提交和重启路径,磁盘证书已经换新,但已加载的 TLS 配置仍持有旧证书;外部看到失败状态,实际受控存储却已变化。
- 建议:接入分区配置管理器的提交及运行时应用流程,协调证书切换、配置提交和失败回滚;不能简单移除旧写入器对分区根的保护。
- 边界:为独立于 R38,本测试不重复附加 issuer;未请求 CA、启动真实 TLS 监听或重启用户服务,已加载证书对象作为运行时持有旧材料的对照。
- 复现:
TestAuditPartitionedRenewalMustCommitBeforeSwitch。
R41 / P2:旧配置迁移静默将自动 DNS 证书改成 HTTP 挑战
- 位置:
backend/config/migrate.go:717-733;backend/config/partitioned_compatibility.go:75-78;backend/services/setup/ssl/ssl.go:337-347,400-409。 - 原因:迁移仅区分系统管理和用户上传,不保存原来的自动 HTTP / DNS 签发方式;兼容加载将所有
managed_default固定映射成SSLTypeAutoHTTP。 - 实测:真实迁移输入
SSLTypeAutoDNS并重读分区配置,得到before_ssl_type=2 after_ssl_type=0 before_dns_challenge=true after_http_challenge=true;迁移没有拒绝或提示这一配置丢失。 - 影响:原本依赖 DNS 挑战的部署后续走 HTTP 挑战,在没有公网 HTTP 可达条件时不能续期,泛域名申请策略也改变。
- 建议:把 ACME 挑战方式作为独立配置保留和校验;暂不支持时明确阻止迁移并提示人工处理,不能静默换成另一种方式。
- 边界:验证配置迁移、兼容加载和挑战分支选择;未进行真实网络挑战。与 R40 保存阶段失败属于不同根因。
- 复现:
TestAuditPartitionedMigrationMustPreserveDNSChallengeMode。
R42 / P1:旧配置迁移丢弃 SQLite 目录,现有数据库被改指向空库
- 位置:
backend/config/migrate.go:581-592;backend/config/partitioned_compatibility.go:89-92;backend/config/config.go:370-372,403-428。 - 前提:旧版 SQLite 数据库使用带子目录的有效路径,例如
./config/archive/review.db。 - 原因:迁移通过
filepath.Base只保留文件名,兼容加载再将文件名固定拼到根目录的config下,没有搬迁原库或验证路径等价。 - 实测:临时原库保存真实 SQLite 标记表,旧配置能够正确解析其位置;执行真实配置迁移后重读得到另一个路径。结果
source_exists=true target_missing_before_open=true same_path=false marker="",查询报no such table: audit_marker。 - 影响:配置格式迁移成功,但后续数据库连接指向新建空库,原有账号和邮件不再从原路径读取。原数据库仍存在,不是文件被删除。
- 建议:完整保留并验证已有数据库位置;若新格式只允许固定位置,应明确拒绝迁移或在备份及校验后受控搬迁,不能静默改变连接目标。
- 边界:使用临时标记表验证配置迁移和真实 SQLite 打开行为,不是三种数据库搬迁服务测试,也未启动正式用户实例。
- 复现:
TestAuditLegacyMigrationMustPreserveSQLiteLocation。
R43 / P1:生产 DKIM 校验保留特殊域名放行分支,无效签名被判为通过
- 位置:
backend/dto/parsemail/dkim.go:134-142;backend/utils/consts/consts.go:4;backend/listen/smtp_server/read_content.go:121,131-148,295。 - 原因:检查签名错误之前,只要验证结果中的域名等于测试常量就立即返回 true。该函数由正常匿名 SMTP 收信路径调用,不受测试构建或开发模式限制。
- 实测:构造缺少必要签名标签的邮件,真实 DKIM 库返回
signature missing required tag;普通保留示例域名的应用结果为 false,相同无效结构改用特殊常量域名则为application_passed=true。 - 影响:外部发件人可以使无效 DKIM 被记录为通过,污染认证结果及依赖该结果的插件判断;不能把该结果当作可信邮件认证依据。
- 建议:删除运行时代码中的测试域名放行,只依据实际校验结果判断;测试通过注入验证器或真实隔离密钥实现,不能保留生产后门分支。
- 边界:直接执行真实 DKIM 校验入口,验证器在 DNS 查询前失败,不请求外网。SMTP 调用、认证对象和落库字段已核对源码,未做真实外部 SMTP 投递;不声称绕过所有其他反垃圾检查。
- 复现:
TestAuditInvalidDKIMMustNotPassForSpecialDomain,包含普通域名拒绝对照。
R44 / P2:IMAP SEARCH 的多个独立搜索键被错误取并集
- 位置:
backend/services/list/imap_search.go:172-187,191-205,465-512;backend/listen/imap_server/session_search.go:20。 - 原因:UID、序号过滤命中任意一个集合就保留;多个 OR 键也共享同一个累计结果集合。同一 SEARCH 中独立键应互相 AND,只有单个 OR 键内部两项取并集。
- 实测:真实本地 TLS IMAP 创建三封邮件,对
{A,B}和{B,C}两个 UID 键搜索,返回 A/B/C;两个独立 OR 键的相同组合也返回 A/B/C,均应只返回 B。 - 影响:客户端组合筛选返回不符合全部条件的邮件,搜索结果集合错误。
- 建议:按搜索语法树逐键求交,OR 节点内部求并,并覆盖多个 UID、序号、OR 及其嵌套组合。
- 边界:真实 TLS 协议验证 UID 和 OR 组合;序号分支具有相同静态逻辑,但未在本用例单独实测。不涉及跨用户访问结论。
- 复现:
TestAuditSearchKeysMustIntersect。
R45 / P2:IMAP SEARCH DELETED 与 STORE/FETCH 使用不同状态字段
- 位置:
backend/services/list/imap_search.go:239-245;backend/listen/imap_server/session_store.go:154-170。 - 原因:STORE 持久化
is_deleted,FETCH 据此返回删除标记;SEARCH 却检查用于网页文件夹分类的status == 3。 - 实测:真实 TLS IMAP 对收件箱邮件执行
STORE +FLAGS (\Deleted)后,FETCH 明确返回该标记,但同 UID 的SEARCH DELETED返回空集合。 - 影响:客户端不能正确搜索已标记删除的邮件,
UNDELETED的判断也与实际标记不一致。 - 建议:协议层统一使用独立的
is_deleted标记,不混用网页垃圾箱状态;补充设置、搜索、撤销、再次搜索的协议测试。 - 边界:使用隔离 SQLite 和本地 TLS,验证 STORE、FETCH、SEARCH,未对真实用户邮箱执行删除或 EXPUNGE。
- 复现:
TestAuditSearchDeletedMustMatchStoredFlag。
R46 / P1:关闭 Telegram 附件推送仍上传邮件附件
- 位置:
plugins/pmail_telegram_push/hook/hook.go:83-94;hook/send.go:172-177,181-218。 - 原因:用户设置正确传入
SendAttachments=false,但通知发送和附件批次发送均不检查该字段。 - 实测:通过真实
ConfigureUserSettings、ReceiveSaveAfter和 Telegram SDK,在内存 HTTP 客户端捕获到enabled=false messages=1 uploaded_files=2;启用开关的对照同样发送两个附件。 - 影响:用户明确关闭附件外发后,附件内容仍进入第三方上传请求,违反隐私设置。
- 建议:在附件发送入口检查用户开关,并覆盖直接调用和收信钩子两条路径。
- 边界:使用虚构测试 token 和内存客户端,未向 Telegram 发送真实请求;生产插件的启动与隔离仍需平台验收。
- 复现:
TestAuditDisabledAttachmentsMustNotBeSent。
R47 / P2:Telegram 普通主题和正文未转义却按 HTML 发送
- 位置:
plugins/pmail_telegram_push/hook/send.go:24-28,87-95,157-160。 - 原因:邮件主题和
Text原样插入通知,sendText固定指定ParseModeHTML。 - 实测:真实 SDK multipart 请求中
parse_mode="HTML" raw_subject=true raw_body=true;正常文本里的尖括号和&没有转义。 - 影响:普通文本可能被解释为格式或链接,含不支持标签时会导致 Telegram 拒绝通知;附件依赖文本发送成功,也会受影响。
- 建议:普通文本统一 HTML 转义,只对经过白名单处理的邮件 HTML 保留受支持标记。
- 边界:已验证真实请求构造,未请求真实 Telegram API,不能将模拟响应当成 Telegram 接收结果。
- 复现:
TestAuditTelegramPlainTextMustBeEscaped。
R48 / P2:Telegram 长中文通知截断破坏 UTF-8
- 位置:
plugins/pmail_telegram_push/hook/send.go:120-128。 - 原因:用字节长度和
text[:4076]截断 Unicode 文本,没有字符边界检查。 - 实测:合法中文输入在三种前缀长度中有两种生成
output_bytes=4079 output_valid=false,另一种恰好对齐字节边界的对照通过。 - 影响:正常中文通知末尾损坏;还需独立检查截断后的 HTML 标签与实体完整性。
- 建议:按 Telegram 文本限制与 Unicode 边界处理长度,再保证 HTML 标签、实体和 spoiler 闭合。
- 边界:证明函数输出无效 UTF-8,不声称真实 API 必然返回某个错误码。
- 复现:
TestAuditTelegramTruncationMustKeepUTF8。
R49 / P2:第三方协议关闭失败后仍显示已关闭
- 位置:
frontend/src/views/components/ThirdPartyAuthorization/ThirdPartyAuthorization.js:41-56;ThirdPartyAuthorization.vue:34-39。 - 原因:
v-model在change前更新item.enabled,处理函数保存的previous已是新值,失败回滚不能恢复原状态。 - 实测:编译并挂载真实 Vue 模板,按已核对的 Element Plus 事件顺序触发开关;模拟请求失败后
serverEnabled=true uiEnabled=false。成功请求的对照通过。 - 影响:用户以为 IMAP 等客户端访问已关闭,但服务端没有关闭;虽出现失败通知,页面持续状态仍然误导。
- 建议:改用受控状态或
before-change,请求成功才提交新值;失败时回读服务端状态。 - 边界:组件事件与请求失败为隔离模拟,未做浏览器或真实协议关闭验收。
- 复现:
test/review/protocol-frontend.test.mjs。
R50 / P2:Telegram 设置页与 iframe 隔离策略不兼容,初始化和 API 均被阻断
- 位置:
frontend/src/views/components/PluginSettings/PluginSettings.vue:13-16;plugins/pmail_telegram_push/src/App.vue:241-249;backend/listen/http_server/https_server.go:244-246。 - 原因:插件 iframe 只有
allow-scripts,页面挂载时却先读取localStorage,之后才获取设置;同时插件响应的 CSP 明确connect-src 'none',页面却直接通过 Axios 调用主程序 API。 - 实测:在 Smail 专属 MCP 浏览器中运行源码提取的真实挂载函数、sandbox 和 CSP。
localStorage产生SecurityError,后面的获取设置函数未执行;独立请求探针触发connect-src违规,服务端未收到 API 请求。 - 影响:Telegram 设置页无法正常初始化、读取或保存设置;不是简单重启或清浏览器缓存能解决的问题。
- 建议:保留插件隔离,采用主程序原生配置页面或严格限定来源、动作和字段的消息桥接。不能简单同时给不可信插件添加
allow-same-origin和allow-scripts来绕过安全边界。 - 边界:这是使用源码策略和挂载函数的浏览器隔离夹具,不是当前生产插件二进制或完整页面部署验收;未修改真实配置和数据库。
- 复现:
node test/review/plugin-browser.mjs;MCP 内联结果转录在test-log/review-plugin-browser-1788639873115/browser-evidence.json。MCP 自带文件输出因工具工作区限制失败,随后读取内联结果并明确标记转录来源。
R51 / P2:投递失败信息不能通过插件 RPC,发送后钩子被跳过
- 位置:
backend/hooks/framework/framework.go:40-46,162-170,195-202;backend/hooks/base.go:67,131-132。 - 原因:通信对象直接使用
map[string]error。非空 Go 错误被 JSON 编码为对象,接收端不能将该对象反序列化到error接口,因此尚未执行钩子就返回 400。 - 实测:使用真实 DTO、JSON 编解码和
handleHookDTO,成功投递对照为status=200 callback=true;投递失败为status=400 callback=false,错误消息没有到达插件。 - 影响:插件不能可靠接收发送失败通知或执行其失败处理逻辑,恰好在需要错误信息时丢失回调。
- 建议:为 RPC 定义明确的可序列化错误对象或字符串映射;接口适配层负责与 Go
error转换,并保留成功和失败对照测试。 - 边界:验证的是实际处理函数和序列化链,不是外部邮件投递或生产插件进程验收;不声称现有插件都实现了发送失败通知。
- 复现:
node test/review/run.mjs --framework-only;日志test-log/run-2026-09-05T20-29-56-638Z-30704/backend.log。
已排除线索与通过的对照
- 首次管理员角色:使用全新临时 SQLite、真实 15 次版本化迁移、真实
SetAdminPassword并关闭重开数据库。新建和重启后均为is_admin=1 role="" effective_role="super_admin" config_read=true role_manage=true。xorm 实际写入空角色,兼容回退生效,不能仅凭 schema 默认值推断管理员降为普通用户。不登记为缺陷,也不泛化为 MySQL/PostgreSQL 实库已验证。 - TOTP 登记密钥并发线索:16 个 goroutine 各执行 150 次真实
BeginEnrollment,-race通过,没有复现数据竞争;当前全仓 Go 调用搜索也未找到该服务被业务包引用。不列为确认问题,不能据此声称完整 TOTP 用户流程已经接入或通过验收。
待诊断项,不列为已确认问题
- 同文件夹 COPY 的身份覆盖已验证为 R11,其他批量副本操作仍需分别验证。
- Windows 正式模式插件隔离存在明确不可用分支,仍需核对功能开关、实际调用链和文档承诺。
- SMTP MX 回退、IMAP 动态序列其他命令、HTML 入库与插件路径等尚需继续检查;UIDNEXT 和 UID FETCH 通配符的已验证问题见 R13、R14。
复现与验证记录
# 原有测试基线
node test/run.mjs all -count=1 ./...
# 独立审查测试:当前预期会失败,失败断言用于暴露尚未修复的业务问题
node test/review/run.mjs
node test/review/run.mjs -race
node test/review/run.mjs --pop3-only
node test/review/run.mjs --message-only
node test/review/run.mjs --migration-only
node test/review/run.mjs --config-only
node test/review/run.mjs --controllers-only -run=^TestAuditPendingExternalBindingRequiresExistingChallenge$
node test/review/run.mjs --http-only -run=^TestAuditExternalBinding
node test/review/run.mjs --http-only -run=^TestAuditAttachment
node test/review/run-windows.mjs
node test/review/run.mjs --setup-only -run=^TestAuditFirstAdministrator
node test/review/run.mjs --ssl-only
node --test test/review/rule-frontend.test.mjs
node --test test/review/frontend-findings.test.mjs
node test/review/run-telegram.mjs
node --test test/review/protocol-frontend.test.mjs
node test/review/run.mjs --framework-only
# 逐行覆盖账本,不代表测试覆盖率或功能验收率
node test/review/coverage.mjs
- 原有前端 50/50 及后端全量通过:
test-log/run-2026-09-05T17-49-53-054Z-47380/。 - 重点包原有 race 基线:
test-log/run-2026-09-05T17-51-55-556Z-29640/backend.log,通过不代表新增并发路径已覆盖。 - 已确认后端复现:
test-log/run-2026-09-05T18-13-47-945Z-448/backend.log。 - 运行时 race 证据:
test-log/run-2026-09-05T18-09-57-603Z-29380/backend.log。 - 新增副本身份复现:
test-log/run-2026-09-05T18-20-32-820Z-49756/backend.log。 - 三项标准 IMAP 行为及 race 重跑:
test-log/run-2026-09-05T18-23-59-068Z-51124/backend.log。 - 包含 R15 的后端复现:
test-log/run-2026-09-05T18-25-37-339Z-30964/backend.log。 - 包含 R16、R17 的后端复现:
test-log/run-2026-09-05T18-32-47-015Z-27832/backend.log。 - POP3 的 R18–R23 隔离复现:
test-log/run-2026-09-05T18-44-16-845Z-41276/backend.log。 - MIME 与密送隐私的 R24–R27 隔离复现:
test-log/run-2026-09-05T18-48-38-700Z-47076/backend.log。 - 迁移的 R01、R03、R28 隔离复现:
test-log/run-2026-09-05T18-55-32-900Z-28932/backend.log。 - 配置事务的 R29 隔离复现:
test-log/run-2026-09-05T19-02-28-851Z-43688/backend.log。 - 配置事务重跑与 R30 后端约束:
test-log/run-2026-09-05T19-09-01-546Z-43540/backend.log。 - R31 后端真实约束及已知问题重跑:
test-log/run-2026-09-05T19-17-39-345Z-44436/backend.log。 - R31 修正测试筛选参数后精确重跑通过:
test-log/run-2026-09-05T19-19-04-347Z-42100/backend.log;仅对照测试通过,不表示页面问题修复。 - R32、R33 真实 TLS 和 CSRF 隔离复现:
test-log/run-2026-09-05T19-26-18-382Z-44472/backend.log;近期会话对照通过,两项安全边界断言预期失败。 - R34 附件下载及 R32、R33 完整路由重跑:
test-log/run-2026-09-05T19-34-58-471Z-48248/backend.log。 - R35 列表引用挂载复现及手工连接对照:
test-log/review-list-1788636898383.log。 - R36 Windows 安装事务内存隔离验证:
test-log/review-windows-1788637055598/windows.log;前五种阶段暴露缺陷,备份完成后的失败能恢复。 - R37 规则编辑取消再新建:
test-log/review-rules-1788637601791.log;旧 ID 与未保存条件一起提交到更新接口。 - 首次管理员角色排除对照:
test-log/run-2026-09-05T19-44-36-177Z-37720/backend.log,新建及重启均通过。 - R38 完整链重复与 R39 泛域名误续期:
test-log/run-2026-09-05T19-51-15-151Z-40896/backend.log,两项缺陷断言失败。 - TOTP 并发线索隔离对照:
test-log/run-2026-09-05T19-53-03-838Z-34048/backend.log,-race通过,未确认缺陷。 - R40 分区续期保存与 R41 DNS 挑战方式丢失:
test-log/run-2026-09-05T19-58-24-637Z-33360/backend.log,两项缺陷断言失败。 - R42 SQLite 配置迁移路径丢失:
test-log/run-2026-09-05T20-02-12-179Z-39988/backend.log,真实原库仍存在,新连接目标缺少标记表。 - R43 无效 DKIM 放行、R44 搜索键并集、R45 删除标记搜索:
test-log/run-2026-09-05T20-07-31-623Z-3388/backend.log,三个问题的安全或语义断言均失败;普通域名 DKIM 拒绝对照通过。 - R46–R48 Telegram 附件开关、HTML 与中文截断:
test-log/review-telegram-2026-09-05T20-16-00-530Z/telegram.log,三个缺陷断言失败,附件开启与对齐截断对照通过。 - R49 协议开关失败回滚:
test-log/review-protocol-20260906-041737.log,失败请求状态一致性断言失败,成功请求对照通过。 - 包含候选配置与外部邮箱重试的五项前端失败复现:
test-log/review-frontend-2026-09-05T19-15-56-197Z/frontend.log。 - 前端两项复现日志:
test-log/frontend-review-20260906-022032.log。 - 测试源码:
test/review/;Go overlay 将测试注入对应包,不改业务源码;运行产物放在test-log/。 ^TestAudit还会匹配项目原有审计接口测试,因此测试用例总数不等于新增问题数。- 未运行带
--fix的 lint,不用自动格式化或文本替换悄悄改动业务源码。
覆盖说明
- 覆盖记录源:
test/review/reviewed-ranges.json。 - 生成账本包含每个受版本控制文件的路径、分类、行数、SHA-256、阅读行范围及发现编号。
read-not-accepted只表示全文已读,不表示无问题,也不表示复审通过。partial-read表示只读取部分行;not-recorded表示没有登记,接续摘要中模糊描述的已读内容不冒充全文覆盖。- 新增且尚未提交的审查文件不属于基线受控文件,单独验证;日志和运行数据库不纳入业务源码逐行统计。
- 当前不能声称“每一行都审查完成”或“全部功能已实现并验收”。