终审残余两处:换 address 就是换信箱;错误话的出口也剥 NUL

复审的两条范围外观察,都落在这一轮刚动过的那两段里,一并收掉:

① 收侧判据看不见 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TM4kUBvekALu9qqmp7D3wr
This commit is contained in:
xcs
2026-08-26 04:07:08 +08:00
co-authored by Claude Fable 5
parent 58b1111e7e
commit f70de44338
6 changed files with 84 additions and 7 deletions
+2 -2
View File
@@ -23,7 +23,7 @@
脱敏(`redactSecrets` `secretVariants` `errorText`)、发信(`validateSend` `pickAccount` `messageIdFor`)、 脱敏(`redactSecrets` `secretVariants` `errorText`)、发信(`validateSend` `pickAccount` `messageIdFor`)、
账号(`viewOf` `mergeAccount` `secretOf` `cursorReset`)、分发(`canRedeliver` `stateOfOutcome`) 账号(`viewOf` `mergeAccount` `secretOf` `cursorReset`)、分发(`canRedeliver` `stateOfOutcome`)
- adapter:`createMailAccountStore` `createInboundStore` `createOutboundStore`(Drizzle)、 - adapter:`createMailAccountStore` `createInboundStore` `createOutboundStore`(Drizzle)、
`createPostalMimeParser`、`createImapSmtpProvider`、`createWecomProvider`、`createFakeMailProvider`; `createPostalMimeParser`、`createImapSmtpProvider`、`createWecomProvider`;
Drizzle 表:`mailAccountTable` `mailInboundTable` `mailInboundDeliveryTable` `mailOutboundTable` Drizzle 表:`mailAccountTable` `mailInboundTable` `mailInboundDeliveryTable` `mailOutboundTable`
层级: L1(编码规范 §4) 层级: L1(编码规范 §4)
@@ -67,7 +67,7 @@
wecom 的 `address` 探到之前是空串 wecom 的 `address` 探到之前是空串
- **两缝**(§7.3):`MailProvider` 不经 ModelGateway / ToolInvoker —— 入站是外界事实、持久化即记录 - **两缝**(§7.3):`MailProvider` 不经 ModelGateway / ToolInvoker —— 入站是外界事实、持久化即记录
(回放读 `sys.mail_inbound`,永不重拉);活动侧出站经 `ToolInvoker`(`mail.send` 工具),系统侧留痕 (回放读 `sys.mail_inbound`,永不重拉);活动侧出站经 `ToolInvoker`(`mail.send` 工具),系统侧留痕
`sys.mail_outbound`;`createFakeMailProvider` 是测试替身 `sys.mail_outbound`;测试替身是 `__tests__/fakes.ts` 里的内存件(**不从 index 导出** —— 外界替身只进测试)
影响半径: 新模块;channel / toolbox / event 的 index / contract / port 未变。 影响半径: 新模块;channel / toolbox / event 的 index / contract / port 未变。
2026-08-25 终审修复轮改了 `port.ts`:`InboundStore` 新增 `getMeta`(list / getMeta 不再取 raw)、 2026-08-25 终审修复轮改了 `port.ts`:`InboundStore` 新增 `getMeta`(list / getMeta 不再取 raw)、
@@ -52,6 +52,31 @@ describe('mergeAccount / secretOf', () => {
expect(mergeAccount(changed({ smtpPort: 587 }), { id: 'acc-1', existing, secretCipher: 's', now }).cursor).toEqual(imapCursor('7', 9)); 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 判重锚', () => { it('wecom 形按 corpId / apiBase 判重锚', () => {
const wecom = mailAccountInputSchema.parse({ kind: 'wecom', name: '应用', settings: { corpId: 'ww1' } }); const wecom = mailAccountInputSchema.parse({ kind: 'wecom', name: '应用', settings: { corpId: 'ww1' } });
const existing: MailAccountRecord = { const existing: MailAccountRecord = {
@@ -27,4 +27,14 @@ describe('errorText', () => {
expect(errorText('raw')).toBe('raw'); expect(errorText('raw')).toBe('raw');
expect(errorText(undefined)).toBe('未知错误'); 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);
});
}); });
@@ -42,10 +42,10 @@ export function secretOf(input: MailAccountInput): string | undefined {
* 从前比的是全部 settings 键,连 `smtpHost` / `smtpPort` / `smtpTls` 都算: * 从前比的是全部 settings 键,连 `smtpHost` / `smtpPort` / `smtpTls` 都算:
* 人把 465 改成 587(纯发信的事)→ 游标清空 → IMAP 重锚 `lastUid = uidNext − 1` → * 人把 465 改成 587(纯发信的事)→ 游标清空 → IMAP 重锚 `lastUid = uidNext − 1` →
* 上次游标到重锚之间到达的信**永远不拉**(设计即「不回填」)。**改发信的设置不该丢来信**。 * 上次游标到重锚之间到达的信**永远不拉**(设计即「不回填」)。**改发信的设置不该丢来信**。
* `login` 两侧都用,算收侧(换了登录名就是换了一个信箱在读)。 * 登录名不在这张表里 —— 它由 `loginOf` 单独判(见下)。
*/ */
const RECEIVE_KEYS: Readonly<Record<MailAccountKind, readonly string[]>> = { const RECEIVE_KEYS: Readonly<Record<MailAccountKind, readonly string[]>> = {
'imap-smtp': ['imapHost', 'imapPort', 'imapTls', 'login', 'folder'], 'imap-smtp': ['imapHost', 'imapPort', 'imapTls', 'folder'],
wecom: ['corpId', 'apiBase'], wecom: ['corpId', 'apiBase'],
}; };
@@ -53,10 +53,26 @@ function sameReceiveSettings(kind: MailAccountKind, a: Record<string, unknown>,
return RECEIVE_KEYS[kind].every((k) => JSON.stringify(a[k] ?? null) === JSON.stringify(b[k] ?? null)); 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<string, unknown>, 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 { export function cursorReset(existing: MailAccountRecord | undefined, input: MailAccountInput): boolean {
if (existing === undefined) return false; 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( export function mergeAccount(
@@ -2,6 +2,8 @@
* 脱敏 —— 凡要落库 / 上接口的厂商原话都先过这里(设计稿 §9.4)。 * 脱敏 —— 凡要落库 / 上接口的厂商原话都先过这里(设计稿 §9.4)。
* 明文、base64(SMTP AUTH 的形)、URL 编码三形都换掉。 * 明文、base64(SMTP AUTH 的形)、URL 编码三形都换掉。
*/ */
import { stripNul } from './inbound-record.ts';
const MIN_SECRET_CHARS = 4; const MIN_SECRET_CHARS = 4;
export function secretVariants(secret: string): string[] { export function secretVariants(secret: string): string[] {
@@ -24,8 +26,19 @@ export function redactSecrets(text: string, secrets: readonly string[]): string
return out; 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 { export function errorText(error: unknown): string {
return stripNul(rawErrorText(error));
}
function rawErrorText(error: unknown): string {
if (error instanceof Error) { if (error instanceof Error) {
const bag = error as Error & { response?: unknown; responseText?: unknown }; const bag = error as Error & { response?: unknown; responseText?: unknown };
const response = typeof bag.response === 'string' ? bag.response : typeof bag.responseText === 'string' ? bag.responseText : ''; const response = typeof bag.response === 'string' ? bag.response : typeof bag.responseText === 'string' ? bag.responseText : '';
+13
View File
@@ -1168,3 +1168,16 @@ pnpm 硬链到全局 store,真实占盘远小于这个数,但 `du` 看着吓人
下游会按一段「【降级存档】…」的正文起单。要么在重投前提醒,要么给这类行一个标(库里现在只能靠正文认)。 下游会按一段「【降级存档】…」的正文起单。要么在重投前提醒,要么给这类行一个标(库里现在只能靠正文认)。
⑲ **`SEND_STALE_MS` 与 `claimForResend` 的 5 分钟是两处各写一份**:用例算 `staleBefore = now − SEND_STALE_MS` 传进去, ⑲ **`SEND_STALE_MS` 与 `claimForResend` 的 5 分钟是两处各写一份**:用例算 `staleBefore = now − SEND_STALE_MS` 传进去,
store 只按传进来的时刻判 —— 目前只有一个调用方所以对得上,但这个数没有一处共同的定义点。 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 判据的一部分
(抢到即离开可抢集合),代价是**这一列不再是「这一行何时建的」** —— 谁要拿它当来源凭据都会读错。
要么另立一列记真正的创建时刻,要么在读它的地方写明。目前无人拿它做凭据。