Archived
终审残余两处:换 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:
@@ -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 : '';
|
||||
|
||||
@@ -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 判据的一部分
|
||||
(抢到即离开可抢集合),代价是**这一列不再是「这一行何时建的」** —— 谁要拿它当来源凭据都会读错。
|
||||
要么另立一列记真正的创建时刻,要么在读它的地方写明。目前无人拿它做凭据。
|
||||
|
||||
Reference in New Issue
Block a user