终审残余两处:换 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`)、
账号(`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)、
@@ -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 = {
@@ -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);
});
});
@@ -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<Record<MailAccountKind, readonly string[]>> = {
'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<string, unknown>,
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 {
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(
@@ -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 : '';