From b6ec37a231ff36db9f70b251d8bbda8d07090045 Mon Sep 17 00:00:00 2001 From: xcs Date: Wed, 26 Aug 2026 02:20:08 +0800 Subject: [PATCH] =?UTF-8?q?mail=20=E6=8E=A5=E7=BA=BF=E4=B8=A4=E5=A4=84?= =?UTF-8?q?=E6=94=B6=E7=B4=A7:=E5=85=A5=E7=AB=99=E7=BA=BF=E7=A8=8B?= =?UTF-8?q?=E5=8F=96=E6=A0=B9=E4=B8=8D=E5=8F=96=E6=9C=80=E8=BF=91=E4=B8=80?= =?UTF-8?q?=E8=B7=B3(=E5=90=A6=E5=88=99=E8=B7=91=E4=B8=80=E4=B8=AA?= =?UTF-8?q?=E6=9D=A5=E5=9B=9E=E2=91=A0=E7=BA=A7=E5=BD=92=E5=B1=9E=E5=B0=B1?= =?UTF-8?q?=E8=90=BD=E7=A9=BA);=E9=99=84=E4=BB=B6=E6=B2=A1=E7=BB=99=20uplo?= =?UTF-8?q?adRef=20=E5=BD=93=E5=9C=BA=E5=88=A4=20invalid,=E4=B8=8D?= =?UTF-8?q?=E9=9D=99=E9=BB=98=E4=B8=A2=E6=8E=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TM4kUBvekALu9qqmp7D3wr --- .../api/src/app/__tests__/mail-wiring.test.ts | 19 ++++++++++++++++--- apps/api/src/app/mail-wiring.ts | 16 +++++++++++++--- 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/apps/api/src/app/__tests__/mail-wiring.test.ts b/apps/api/src/app/__tests__/mail-wiring.test.ts index 1444d3f..9459d66 100644 --- a/apps/api/src/app/__tests__/mail-wiring.test.ts +++ b/apps/api/src/app/__tests__/mail-wiring.test.ts @@ -41,9 +41,11 @@ function fakeSend(result: Partial = {}): { send: (input: MailSen } describe('toInboundMail', () => { - it('线程取 References 末项(没 In-Reply-To);只带存下来的附件;纯 html 剥成正文', () => { + it('线程取 References 首项(即这一串的根);只带存下来的附件;纯 html 剥成正文', () => { const mail: InboundMail = toInboundMail(detail); - expect(mail.threadId).toBe(''); + // 不是末项 :我方出站登记的恒是首封,①级归属是精确等值比对 —— + // 取最近一跳的话,跑一个来回(我方再回一封)线程就漂到库里没记过的 Message-ID 上,①级落空退③级 + expect(mail.threadId).toBe(''); expect(mail.from).toBe('buyer@acme.com'); expect(mail.to).toEqual(['sales@we.com']); expect(mail.cc).toEqual(['ops@we.com']); @@ -87,12 +89,23 @@ describe('mailSendTool', () => { const base = { connection: { account: 'default' }, operation: undefined, idempotencyKey: 'k-1' }; it('数组 / 逗号串都认作收件人;body → text;幂等键与连接配置的账号带过去;出参保留 queued', async () => { const mail = fakeSend(); - const result = await mailSendTool(mail)({ ...base, connection: { account: 'acc-2' }, input: { to: 'a@x.com; b@x.com', subject: 's', body: '正文', inReplyTo: '', attachments: [{ uploadRef: 'up-1', name: 'q.pdf' }, { name: 'no-ref' }] } }); + const result = await mailSendTool(mail)({ ...base, connection: { account: 'acc-2' }, input: { to: 'a@x.com; b@x.com', subject: 's', body: '正文', inReplyTo: '', attachments: [{ uploadRef: 'up-1', name: 'q.pdf' }] } }); expect(result).toEqual({ ok: true, output: { queued: true, outboundId: 'out-1', messageId: '', state: 'sent' } }); expect(mail.calls[0]).toMatchObject({ accountId: 'acc-2', to: ['a@x.com', 'b@x.com'], text: '正文', inReplyTo: '', references: [''], idempotencyKey: 'k-1', attachments: [{ uploadRef: 'up-1', name: 'q.pdf' }], requestedBy: { kind: 'tool', tool: 'mail.send' } }); await mailSendTool(mail)({ ...base, input: { to: ['a@x.com'], subject: 's', body: 'b' } }); expect(mail.calls[1]?.accountId).toBeUndefined(); }); + it('没给 uploadRef 的附件不静默丢掉:整次调用判 invalid,一封都不发', async () => { + // 模块那一层本就是硬的(既没字节也没 ref 就抛 MailSendInvalidError);app 层不许比它松 —— + // 悄悄丢掉附件再回 queued:true,等于让界面撒谎 + const mail = fakeSend(); + const result = await mailSendTool(mail)({ ...base, input: { to: ['a@x.com'], subject: 's', body: 'b', attachments: [{ uploadRef: 'up-1', name: 'q.pdf' }, { name: 'no-ref' }] } }); + expect(result.ok).toBe(false); + expect(result.error?.code).toBe('invalid'); + expect(result.error?.retryable).toBe(false); + expect(result.error?.message).toContain('no-ref'); + expect(mail.calls).toHaveLength(0); + }); it('dryRun 只校验不发', async () => { const mail = fakeSend(); expect(await mailSendTool(mail)({ ...base, input: { to: ['x@y.com'], subject: '询价', body: 'b', dryRun: true } })).toEqual({ ok: true, output: { queued: false, dryRun: true } }); diff --git a/apps/api/src/app/mail-wiring.ts b/apps/api/src/app/mail-wiring.ts index c135221..8dc3772 100644 --- a/apps/api/src/app/mail-wiring.ts +++ b/apps/api/src/app/mail-wiring.ts @@ -18,9 +18,18 @@ import { } from '@modules/mail'; import type { ToolHandler } from '@modules/toolbox'; -/** mail 的入站详情 → channel 的 InboundMail:只带存下来的附件;线程 = In-Reply-To ⊃ References 末项 */ +/** + * mail 的入站详情 → channel 的 InboundMail:只带存下来的附件。 + * + * **线程取的是根,不是最近一跳**:我方出站登记的 threadId 恒是这一串的**首封** + * (`channelTransport` 回的是 `input.threadId ?? messageId`,channel 据此 `recordOutbound`), + * 而归属解析①级是精确等值比对。若这里取 `inReplyTo`(最近一跳),跑一个来回就漂: + * 我方 M1 → 客户回 M1(命中)→ 我方再回(实际 M2、仍记在 M1 名下)→ 客户回 M2 → + * 取到 M2,而库里从没记过 M2 ⇒ ①级落空,退到③地址+时间窗。 + * `references[0]` 才稳定:客户端续链时首项恒是首封;客户端只给 In-Reply-To 时退回它。 + */ export function toInboundMail(mail: MailInboundDetail): InboundMail { - const threadId = mail.inReplyTo ?? mail.references.at(-1); + const threadId = mail.references[0] ?? mail.inReplyTo ?? undefined; return { messageId: mail.messageId, ...(threadId === undefined ? {} : { threadId }), @@ -80,7 +89,8 @@ function attachmentsOf(value: unknown): MailSendInput['attachments'] { if (typeof v !== 'object' || v === null) return []; const a = v as Record; const uploadRef = typeof a['uploadRef'] === 'string' && a['uploadRef'] !== '' ? a['uploadRef'] : undefined; - if (uploadRef === undefined) return []; + // 没给 uploadRef 的附件**不静默丢掉**:活动说「带上 q.pdf」,信发了附件没了还回 queued:true,是界面撒谎 + if (uploadRef === undefined) throw new MailSendInvalidError([`附件 ${String(a['name'] ?? '(无名)')} 没给 uploadRef`]); const name = typeof a['name'] === 'string' && a['name'] !== '' ? a['name'] : uploadRef; return [{ name, contentType: 'application/octet-stream', uploadRef }]; });