From f70de443380c0fbdfe3a4d6add755518c704b5ab Mon Sep 17 00:00:00 2001 From: xcs Date: Wed, 26 Aug 2026 04:07:08 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BB=88=E5=AE=A1=E6=AE=8B=E4=BD=99=E4=B8=A4?= =?UTF-8?q?=E5=A4=84:=E6=8D=A2=20address=20=E5=B0=B1=E6=98=AF=E6=8D=A2?= =?UTF-8?q?=E4=BF=A1=E7=AE=B1;=E9=94=99=E8=AF=AF=E8=AF=9D=E7=9A=84?= =?UTF-8?q?=E5=87=BA=E5=8F=A3=E4=B9=9F=E5=89=A5=20NUL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复审的两条范围外观察,都落在这一轮刚动过的那两段里,一并收掉: ① 收侧判据看不见 address —— `imapSettingsOf` 用的是 `login ?? address`,而 login **常常空着**, 这时候真正决定「登到哪个信箱」的就是 address。上一刀把发信三键排除掉之后, 换 address 判据为假:旧游标的 UID 说的是原来那个箱子,而 UID 是按信箱编号的。 判据改认**实际登录名**:login 填了就比 login(这时 address 只是发信落款,换它不动收信), 空着就比 address。wecom 不参与 —— 它的 address 由探测回填,比它会在回填那一刻误清游标。 ② 治毒信的那道门自己会被同一个字符毒死 —— 降级存档把解析失败的原因写进正文与 `last_poll_error`,而那串原因常常来自那封信自己的字节。`errorText` 出口一律剥 NUL: 所有落库的错误话都从这一个出口出去,分散到每个调用点去剥,总会漏掉下一个新调用点。 两条测试都验过 RED→GREEN(拿掉修复各自变红,旁边的既有用例不受影响)。 顺带:`mail/CONTRACT.md` 两处点名的 `createFakeMailProvider` 全仓不存在(未定义、未导出、无调用方)—— 契约文档摆一个不存在的出口,照它接线的人会去 import 一个没有的东西;改成实情。 debt 补 ⑳-㉔(IMAP 每轮重登录与腾讯频率限制 / wecom -1 无退避 / pollDue 每 tick 刷日志 / notes 不设上限 / claimForResend 推 created_at 使该列不再是创建时刻)。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TM4kUBvekALu9qqmp7D3wr --- apps/api/src/modules/mail/CONTRACT.md | 4 +-- .../mail/__tests__/account-view.test.ts | 25 +++++++++++++++++++ .../src/modules/mail/__tests__/redact.test.ts | 10 ++++++++ .../mail/internal/domain/account-view.ts | 24 +++++++++++++++--- .../modules/mail/internal/domain/redact.ts | 15 ++++++++++- docs/debt.md | 13 ++++++++++ 6 files changed, 84 insertions(+), 7 deletions(-) diff --git a/apps/api/src/modules/mail/CONTRACT.md b/apps/api/src/modules/mail/CONTRACT.md index 6fe320d..5b9ecdc 100644 --- a/apps/api/src/modules/mail/CONTRACT.md +++ b/apps/api/src/modules/mail/CONTRACT.md @@ -23,7 +23,7 @@ 脱敏(`redactSecrets` `secretVariants` `errorText`)、发信(`validateSend` `pickAccount` `messageIdFor`)、 账号(`viewOf` `mergeAccount` `secretOf` `cursorReset`)、分发(`canRedeliver` `stateOfOutcome`) - adapter:`createMailAccountStore` `createInboundStore` `createOutboundStore`(Drizzle)、 - `createPostalMimeParser`、`createImapSmtpProvider`、`createWecomProvider`、`createFakeMailProvider`; + `createPostalMimeParser`、`createImapSmtpProvider`、`createWecomProvider`; Drizzle 表:`mailAccountTable` `mailInboundTable` `mailInboundDeliveryTable` `mailOutboundTable` 层级: L1(编码规范 §4) @@ -67,7 +67,7 @@ wecom 的 `address` 探到之前是空串 - **两缝**(§7.3):`MailProvider` 不经 ModelGateway / ToolInvoker —— 入站是外界事实、持久化即记录 (回放读 `sys.mail_inbound`,永不重拉);活动侧出站经 `ToolInvoker`(`mail.send` 工具),系统侧留痕 - `sys.mail_outbound`;`createFakeMailProvider` 是测试替身 + `sys.mail_outbound`;测试替身是 `__tests__/fakes.ts` 里的内存件(**不从 index 导出** —— 外界替身只进测试) 影响半径: 新模块;channel / toolbox / event 的 index / contract / port 未变。 2026-08-25 终审修复轮改了 `port.ts`:`InboundStore` 新增 `getMeta`(list / getMeta 不再取 raw)、 diff --git a/apps/api/src/modules/mail/__tests__/account-view.test.ts b/apps/api/src/modules/mail/__tests__/account-view.test.ts index 8dc7a63..29ec51e 100644 --- a/apps/api/src/modules/mail/__tests__/account-view.test.ts +++ b/apps/api/src/modules/mail/__tests__/account-view.test.ts @@ -52,6 +52,31 @@ describe('mergeAccount / secretOf', () => { expect(mergeAccount(changed({ smtpPort: 587 }), { id: 'acc-1', existing, secretCipher: 's', now }).cursor).toEqual(imapCursor('7', 9)); }); + it('**登录名空着时,换 address 就是换信箱** —— 收侧判据认的是 `login ?? address`', () => { + /* + * `imapSettingsOf` 里写的是 `login: settings.login ?? account.address`:login 常常空着, + * 这时候真正决定「登到哪个信箱」的是 address。只比 settings 里的 login, + * 人把 address 换成另一个邮箱时判据为假 —— 旧游标的 UID 说的是原来那个箱子, + * 而 UID 是**按信箱**编号的。反过来,显式填了 login 时 address 只是发信落款,换它不动收信。 + */ + const base: MailAccountRecord = { + ...mergeAccount(input, { id: 'acc-1', existing: undefined, secretCipher: 's', now }), + cursor: imapCursor('7', 9), + }; + const withAddress = (address: string): MailAccountInput => mailAccountInputSchema.parse({ ...input, address }); + // login 空着:address 就是登录名,换了它要重锚 + expect(base.settings['login']).toBeUndefined(); + expect(cursorReset(base, withAddress('ops@we.com'))).toBe(true); + expect(cursorReset(base, withAddress('sales@we.com'))).toBe(false); + // 显式填了 login:address 只是落款,换它不重锚(而换 login 照旧重锚) + const pinned = mailAccountInputSchema.parse({ ...input, settings: { ...input.settings, login: 'box@we.com' } }); + const withPinnedLogin: MailAccountRecord = { ...mergeAccount(pinned, { id: 'acc-1', existing: base, secretCipher: 's', now }), cursor: imapCursor('7', 9) }; + expect(cursorReset(withPinnedLogin, mailAccountInputSchema.parse({ ...pinned, address: 'ops@we.com' }))).toBe(false); + expect(cursorReset(withPinnedLogin, mailAccountInputSchema.parse({ ...pinned, settings: { ...pinned.settings, login: 'other@we.com' } }))).toBe(true); + // 游标真的留下 / 真的被清(不只是判据) + expect(mergeAccount(withAddress('ops@we.com'), { id: 'acc-1', existing: base, secretCipher: 's', now }).cursor).toBeNull(); + }); + it('wecom 形按 corpId / apiBase 判重锚', () => { const wecom = mailAccountInputSchema.parse({ kind: 'wecom', name: '应用', settings: { corpId: 'ww1' } }); const existing: MailAccountRecord = { diff --git a/apps/api/src/modules/mail/__tests__/redact.test.ts b/apps/api/src/modules/mail/__tests__/redact.test.ts index f057547..78fd2df 100644 --- a/apps/api/src/modules/mail/__tests__/redact.test.ts +++ b/apps/api/src/modules/mail/__tests__/redact.test.ts @@ -27,4 +27,14 @@ describe('errorText', () => { expect(errorText('raw')).toBe('raw'); expect(errorText(undefined)).toBe('未知错误'); }); + it('**出口剥 NUL** —— 这句话的去向全是 PG 的 text 列,而它的来源常常是那封信自己的字节', () => { + /* + * 降级存档那条路把解析失败的原因写进正文与 `last_poll_error`,原因串里可能带着信里的 NUL。 + * 不剥的话「记下这次失败」这一笔自己也写不进去 —— 治毒信的那道门,自己被同一个字符毒死。 + */ + expect(errorText(new Error('bad\u0000byte'))).toBe('badbyte'); + expect(errorText(Object.assign(new Error('parse failed'), { response: 'at \u0000 offset 12' }))).toBe('parse failed —— at offset 12'); + expect(errorText('\u0000')).toBe(''); + expect(errorText(new Error('clean')).includes('\u0000')).toBe(false); + }); }); diff --git a/apps/api/src/modules/mail/internal/domain/account-view.ts b/apps/api/src/modules/mail/internal/domain/account-view.ts index fd99f22..9741b37 100644 --- a/apps/api/src/modules/mail/internal/domain/account-view.ts +++ b/apps/api/src/modules/mail/internal/domain/account-view.ts @@ -42,10 +42,10 @@ export function secretOf(input: MailAccountInput): string | undefined { * 从前比的是全部 settings 键,连 `smtpHost` / `smtpPort` / `smtpTls` 都算: * 人把 465 改成 587(纯发信的事)→ 游标清空 → IMAP 重锚 `lastUid = uidNext − 1` → * 上次游标到重锚之间到达的信**永远不拉**(设计即「不回填」)。**改发信的设置不该丢来信**。 - * `login` 两侧都用,算收侧(换了登录名就是换了一个信箱在读)。 + * 登录名不在这张表里 —— 它由 `loginOf` 单独判(见下)。 */ const RECEIVE_KEYS: Readonly> = { - 'imap-smtp': ['imapHost', 'imapPort', 'imapTls', 'login', 'folder'], + 'imap-smtp': ['imapHost', 'imapPort', 'imapTls', 'folder'], wecom: ['corpId', 'apiBase'], }; @@ -53,10 +53,26 @@ function sameReceiveSettings(kind: MailAccountKind, a: Record, return RECEIVE_KEYS[kind].every((k) => JSON.stringify(a[k] ?? null) === JSON.stringify(b[k] ?? null)); } -/** kind 或**收侧** settings 变了,旧游标指的就不是同一个信箱 / 同一个文件夹 —— 清空重锚 */ +/** + * imap-smtp **实际拿去登录的那个名字** = `login ?? address`(与 `imapSettingsOf` 同一句)。 + * + * 只比 `login` 不够:它**常常空着**,这时候真正决定「读哪个信箱」的是 `address` —— + * 人把 address 换成另一个邮箱,IMAP 就登到别的箱子上去了,而旧游标的 UID 说的是原来那个箱子。 + * 也不能改成比 `address`:显式填了 `login` 时,address 只是发信的落款,换它不动收信。 + * wecom 不参与 —— 它的 address 由探测回填(不是人填的),比它会在回填那一刻误清游标。 + */ +function loginOf(settings: Record, address: string): string { + const login = settings['login']; + return typeof login === 'string' && login !== '' ? login : address; +} + +/** kind 或**收侧** settings / 登录名变了,旧游标指的就不是同一个信箱 / 同一个文件夹 —— 清空重锚 */ export function cursorReset(existing: MailAccountRecord | undefined, input: MailAccountInput): boolean { if (existing === undefined) return false; - return existing.kind !== input.kind || !sameReceiveSettings(input.kind, existing.settings, input.settings); + if (existing.kind !== input.kind) return true; + if (!sameReceiveSettings(input.kind, existing.settings, input.settings)) return true; + if (input.kind !== 'imap-smtp') return false; + return loginOf(existing.settings, existing.address) !== loginOf(input.settings, input.address); } export function mergeAccount( diff --git a/apps/api/src/modules/mail/internal/domain/redact.ts b/apps/api/src/modules/mail/internal/domain/redact.ts index f5089de..3f8bf9d 100644 --- a/apps/api/src/modules/mail/internal/domain/redact.ts +++ b/apps/api/src/modules/mail/internal/domain/redact.ts @@ -2,6 +2,8 @@ * 脱敏 —— 凡要落库 / 上接口的厂商原话都先过这里(设计稿 §9.4)。 * 明文、base64(SMTP AUTH 的形)、URL 编码三形都换掉。 */ +import { stripNul } from './inbound-record.ts'; + const MIN_SECRET_CHARS = 4; export function secretVariants(secret: string): string[] { @@ -24,8 +26,19 @@ export function redactSecrets(text: string, secrets: readonly string[]): string return out; } -/** 错误 → 一句话;nodemailer / imapflow 把服务器回话挂在 `response` / `responseText` 上,一并带上 */ +/** + * 错误 → 一句话;nodemailer / imapflow 把服务器回话挂在 `response` / `responseText` 上,一并带上。 + * + * **出口一律剥 NUL**:这句话的去向全是 PG 的 `text` 列(`last_poll_error` / 投递的 `error` / + * 降级存档行的正文),而它的来源常常是**解析失败的那封信自己的字节** —— 一个 U+0000 就能让 + * 「记下这次失败」这一笔自己也写不进去。剥在这里,是因为所有落库的错误话都从这个出口出去; + * 分散到每个调用点去剥,总会漏掉下一个新调用点。 + */ export function errorText(error: unknown): string { + return stripNul(rawErrorText(error)); +} + +function rawErrorText(error: unknown): string { if (error instanceof Error) { const bag = error as Error & { response?: unknown; responseText?: unknown }; const response = typeof bag.response === 'string' ? bag.response : typeof bag.responseText === 'string' ? bag.responseText : ''; diff --git a/docs/debt.md b/docs/debt.md index d20efcd..09d53bf 100644 --- a/docs/debt.md +++ b/docs/debt.md @@ -1168,3 +1168,16 @@ pnpm 硬链到全局 store,真实占盘远小于这个数,但 `du` 看着吓人 下游会按一段「【降级存档】…」的正文起单。要么在重投前提醒,要么给这类行一个标(库里现在只能靠正文认)。 ⑲ **`SEND_STALE_MS` 与 `claimForResend` 的 5 分钟是两处各写一份**:用例算 `staleBefore = now − SEND_STALE_MS` 传进去, store 只按传进来的时刻判 —— 目前只有一个调用方所以对得上,但这个数没有一处共同的定义点。 +⑳ **IMAP 每一轮都新建 TLS 连接 + 登录一次**:`withImap` 用完即 `logout`,而轮询间隔默认 30s、下限 10s —— + 一个账号一天 2880-8640 次登录。腾讯企业邮箱对登录频率有限制,**上线前先拿真账号把 `pollIntervalSec` + 压到 10s 跑一小时**看会不会被拒;这比 IDLE 缺席更早会成为事故。根治是连接复用(或 IDLE), + 两者都要先解决「跨进程租约下谁持有那条连接」。 +㉑ **wecom 的 `-1`(系统繁忙)重试没有退避**:`call` 连打 `BUSY_RETRIES` 次,中间不等 —— + 对方正忙的时候我们打得更密。加指数退避即可。 +㉒ **`pollDue` 对「未配置凭证」的账号每 tick 写一行 stderr**:5 秒一次、一个账号一行, + 生产日志会被这条刷屏。应按**状态变化**打印(从能拉变成拉不动那一次说一句),而不是每轮说一遍。 +㉓ **一轮里的 `notes` 不设上限**:50 封毒信就把 50 句话拼进同一个 `last_poll_error`(几 KB)。 + 不是错,但运营面那一格会变得没法读 —— 截断并注明「另有 N 条」即可。 +㉔ **`claimForResend` 会把抢到的那一行的 `created_at` 推到当下**:这是 CAS 判据的一部分 + (抢到即离开可抢集合),代价是**这一列不再是「这一行何时建的」** —— 谁要拿它当来源凭据都会读错。 + 要么另立一列记真正的创建时刻,要么在读它的地方写明。目前无人拿它做凭据。