From 6748ef01b781483e90be808f7a2e8749195ac56f Mon Sep 17 00:00:00 2001 From: xcs Date: Wed, 26 Aug 2026 00:26:08 +0800 Subject: [PATCH] =?UTF-8?q?mail=20domain:=E8=A7=A3=E6=9E=90=E7=BB=93?= =?UTF-8?q?=E6=9E=9C=20=E2=86=92=20=E5=85=A5=E7=AB=99=E8=AE=B0=E5=BD=95=20?= =?UTF-8?q?/=20=E8=A7=86=E5=9B=BE,=E9=99=84=E4=BB=B6=E4=B8=8E=E5=8E=9F?= =?UTF-8?q?=E4=BB=B6=E6=8C=89=E4=B8=8A=E9=99=90=E5=88=86=E5=AD=98=E7=95=99?= =?UTF-8?q?=E6=B3=A8,=E5=8E=82=E5=95=86=E5=8E=9F=E8=AF=9D=E8=84=B1?= =?UTF-8?q?=E6=95=8F=E4=B8=89=E5=BD=A2?= 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 --- .../mail/__tests__/inbound-record.test.ts | 126 ++++++++++++++ .../src/modules/mail/__tests__/redact.test.ts | 30 ++++ apps/api/src/modules/mail/index.ts | 12 ++ .../mail/internal/domain/inbound-record.ts | 162 ++++++++++++++++++ .../modules/mail/internal/domain/redact.ts | 36 ++++ 5 files changed, 366 insertions(+) create mode 100644 apps/api/src/modules/mail/__tests__/inbound-record.test.ts create mode 100644 apps/api/src/modules/mail/__tests__/redact.test.ts create mode 100644 apps/api/src/modules/mail/internal/domain/inbound-record.ts create mode 100644 apps/api/src/modules/mail/internal/domain/redact.ts diff --git a/apps/api/src/modules/mail/__tests__/inbound-record.test.ts b/apps/api/src/modules/mail/__tests__/inbound-record.test.ts new file mode 100644 index 0000000..3c347e8 --- /dev/null +++ b/apps/api/src/modules/mail/__tests__/inbound-record.test.ts @@ -0,0 +1,126 @@ +/** 解析结果 → 入站记录:缺 Message-ID 顶上、线程取 In-Reply-To、纯 html 剥标签、附件按上限分「存」与「留注」 */ +import { describe, expect, it } from 'vitest'; +import type { ParsedMail } from '../internal/domain/records.ts'; +import { + ATTACHMENT_CAP, + bodyTextOf, + buildInboundRecord, + detailOf, + fallbackMessageId, + planAttachments, + rawOf, + stripHtml, + summaryOf, + textPreviewOf, + threadIdOf, +} from '../internal/domain/inbound-record.ts'; + +const parsed: ParsedMail = { + messageId: '', + inReplyTo: '', + references: ['', ''], + from: { address: 'buyer@acme.com', name: '张三' }, + to: [{ address: 'sales@we.com', name: '' }], + cc: [], + subject: '询价 RFQ-1', + text: '你好,请报价', + html: '

你好,请报价

', + date: new Date('2026-08-25T09:00:00Z'), + attachments: [{ name: 'bom.xlsx', contentType: 'application/vnd.ms-excel', bytes: new Uint8Array(10) }], +}; +const now = new Date('2026-08-25T10:00:00Z'); + +describe('线程与正文', () => { + it('线程 = In-Reply-To;没有就取 References 末项;都没有就是 undefined', () => { + expect(threadIdOf(parsed)).toBe(''); + expect(threadIdOf({ references: ['', ''] })).toBe(''); + expect(threadIdOf({ references: [] })).toBeUndefined(); + }); + it('缺 Message-ID 用 providerRef@域顶上,不抛', () => { + expect(fallbackMessageId('uid:7:12', 'sales@we.com')).toBe(''); + expect(fallbackMessageId('x', 'no-at')).toBe(''); + }); + it('纯 html 的信剥标签当正文;script / style 整段丢;实体解回来;空白收成一个', () => { + expect(stripHtml('

你好&世界

\n 再见')).toBe('你好&世界 再见'); + expect(bodyTextOf({ html: '
只有 html
' })).toBe('只有 html'); + expect(bodyTextOf({ text: '有 text', html: '忽略' })).toBe('有 text'); + expect(bodyTextOf({})).toBe(''); + }); + it('预览 200 字', () => { + expect(textPreviewOf('a'.repeat(300), null)).toHaveLength(200); + expect(textPreviewOf(null, '

来自 html

')).toBe('来自 html'); + }); +}); + +describe('附件与原件', () => { + it('超上限的附件不存,留注;下标按原顺序', () => { + const plan = planAttachments( + [ + { name: 'a.pdf', contentType: 'application/pdf', bytes: new Uint8Array(3) }, + { name: 'big.zip', contentType: 'application/zip', bytes: new Uint8Array(ATTACHMENT_CAP + 1) }, + ], + ATTACHMENT_CAP, + ); + expect(plan.map((p) => [p.index, p.store])).toEqual([ + [0, true], + [1, false], + ]); + expect(plan[1]!.note).toContain('8MB'); + }); + it('原件超上限留空 + 注', () => { + expect(rawOf(new Uint8Array(4), 8)).toEqual({ raw: new Uint8Array(4), note: '' }); + const big = rawOf(new Uint8Array(9), 8); + expect(big.raw).toBeNull(); + expect(big.note).not.toBe(''); + }); +}); + +describe('buildInboundRecord / 视图', () => { + const record = buildInboundRecord({ + id: 'in-1', + accountId: 'acc-1', + address: 'sales@we.com', + providerRef: 'uid:7:12', + parsed, + eml: new Uint8Array([1, 2, 3]), + receivedAt: now, + attachments: [{ index: 0, name: 'bom.xlsx', contentType: 'application/vnd.ms-excel', size: 10, ref: 'up-1', note: '' }], + now, + }); + + it('记录照抄解析结果,raw 存下来,size 是原件字节数', () => { + expect(record.messageId).toBe(''); + expect(record.inReplyTo).toBe(''); + expect(record.from).toEqual({ address: 'buyer@acme.com', name: '张三' }); + expect(record.size).toBe(3); + expect(record.raw).toEqual(new Uint8Array([1, 2, 3])); + expect(record.sentAt?.toISOString()).toBe('2026-08-25T09:00:00.000Z'); + }); + it('缺 from / 缺 Message-ID 也能落记录', () => { + const bare = buildInboundRecord({ + id: 'in-2', + accountId: 'acc-1', + address: 'sales@we.com', + providerRef: 'uid:7:13', + parsed: { references: [], to: [], cc: [], subject: '', attachments: [] }, + eml: new Uint8Array(), + receivedAt: now, + attachments: [], + now, + }); + expect(bare.messageId).toBe(''); + expect(bare.from).toEqual({ address: '', name: '' }); + }); + it('摘要不带正文只带预览;详情带正文、rawAvailable、投递', () => { + const deliveries = [{ mailId: 'in-1', subscriber: 'channel', state: 'done' as const, error: null, startedAt: now, finishedAt: now }]; + const summary = summaryOf(record, deliveries); + expect(summary.textPreview).toBe('你好,请报价'); + expect(summary.hasHtml).toBe(true); + expect(summary.deliveries[0]).toEqual({ subscriber: 'channel', state: 'done', error: null, startedAt: now.toISOString(), finishedAt: now.toISOString() }); + expect('text' in summary).toBe(false); + const detail = detailOf(record, deliveries); + expect(detail.text).toBe('你好,请报价'); + expect(detail.rawAvailable).toBe(true); + expect(detail.references).toEqual(['', '']); + }); +}); diff --git a/apps/api/src/modules/mail/__tests__/redact.test.ts b/apps/api/src/modules/mail/__tests__/redact.test.ts new file mode 100644 index 0000000..f057547 --- /dev/null +++ b/apps/api/src/modules/mail/__tests__/redact.test.ts @@ -0,0 +1,30 @@ +/** 脱敏三形:明文、base64、URL 编码 —— nodemailer 的 response 行会把 AUTH 明文 / 密文都带出来 */ +import { describe, expect, it } from 'vitest'; +import { errorText, redactSecrets, secretVariants } from '../internal/domain/redact.ts'; + +describe('redactSecrets', () => { + it('三形都换成 ***', () => { + const secret = 'p@ss w0rd'; + const text = `login p@ss w0rd failed; AUTH ${Buffer.from(secret).toString('base64')}; url ${encodeURIComponent(secret)}`; + expect(redactSecrets(text, [secret])).toBe('login *** failed; AUTH ***; url ***'); + }); + it('短于 4 字或空的密钥不脱(否则一封信的每个字母都成了 ***)', () => { + expect(secretVariants('')).toEqual([]); + expect(secretVariants('abc')).toEqual([]); + expect(redactSecrets('abc abc', ['abc'])).toBe('abc abc'); + }); + it('没命中原样返回;正则元字符当字面', () => { + expect(redactSecrets('nothing here', ['s3cret!'])).toBe('nothing here'); + expect(redactSecrets('key=a.b*c', ['a.b*c'])).toBe('key=***'); + }); +}); + +describe('errorText', () => { + it('Error 取 message;nodemailer 式的 response 一并带上;别的 String()', () => { + expect(errorText(new Error('boom'))).toBe('boom'); + const withResponse = Object.assign(new Error('Invalid login'), { response: '535 5.7.8 Error: authentication failed' }); + expect(errorText(withResponse)).toBe('Invalid login —— 535 5.7.8 Error: authentication failed'); + expect(errorText('raw')).toBe('raw'); + expect(errorText(undefined)).toBe('未知错误'); + }); +}); diff --git a/apps/api/src/modules/mail/index.ts b/apps/api/src/modules/mail/index.ts index 5db4c55..ff114a7 100644 --- a/apps/api/src/modules/mail/index.ts +++ b/apps/api/src/modules/mail/index.ts @@ -62,3 +62,15 @@ export { wecomWindow, } from './internal/domain/cursor.ts'; export { isDue, isPolling, leaseMsFor } from './internal/domain/due.ts'; +export { + ATTACHMENT_CAP, + bodyTextOf, + buildInboundRecord, + detailOf, + planAttachments, + RAW_CAP, + stripHtml, + summaryOf, + threadIdOf, +} from './internal/domain/inbound-record.ts'; +export { errorText, redactSecrets, secretVariants } from './internal/domain/redact.ts'; diff --git a/apps/api/src/modules/mail/internal/domain/inbound-record.ts b/apps/api/src/modules/mail/internal/domain/inbound-record.ts new file mode 100644 index 0000000..dc1b9d8 --- /dev/null +++ b/apps/api/src/modules/mail/internal/domain/inbound-record.ts @@ -0,0 +1,162 @@ +/** + * 解析结果 → 入站记录 / 视图(设计稿 §5.3)。零 IO:附件的存放由 usecase 按 `planAttachments` 去做, + * 存完把 ref 交回来再 `buildInboundRecord`。 + */ +import type { MailDeliveryView, MailInboundDetail, MailInboundSummary } from '@contracts'; +import type { DeliveryRecord, InboundAttachmentRecord, InboundRecord, ParsedAttachment, ParsedMail } from './records.ts'; + +/** 与 sys.upload 的上限同一个数(超了当场留注,不静默截断) */ +export const ATTACHMENT_CAP = 8 * 1024 * 1024; +export const RAW_CAP = 16 * 1024 * 1024; +export const PREVIEW_CHARS = 200; + +function domainOf(address: string): string { + const at = address.lastIndexOf('@'); + return at < 0 || at === address.length - 1 ? 'local' : address.slice(at + 1); +} + +/** 缺 Message-ID 的信(有的自动回复就没有)用 providerRef 顶上 —— 唯一键要的是一个稳定的串 */ +export function fallbackMessageId(providerRef: string, address: string): string { + return `<${providerRef}@${domainOf(address)}>`; +} + +/** 线程 = In-Reply-To(父)⊃ References 末项;归属解析①级拿它对出站的 Message-ID */ +export function threadIdOf(parsed: Pick): string | undefined { + if (parsed.inReplyTo !== undefined && parsed.inReplyTo !== '') return parsed.inReplyTo; + return parsed.references.at(-1); +} + +const ENTITIES: Readonly> = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' }; + +export function stripHtml(html: string): string { + return html + .replace(/<(script|style)[\s\S]*?<\/\1>/gi, ' ') + .replace(/|<\/(p|div|tr|li|h[1-6])>/gi, '\n') + .replace(/<[^>]+>/g, '') + .replace(/&(#\d+|#x[0-9a-f]+|[a-z]+);/gi, (whole, code: string) => { + if (code.startsWith('#x') || code.startsWith('#X')) return String.fromCodePoint(Number.parseInt(code.slice(2), 16)); + if (code.startsWith('#')) return String.fromCodePoint(Number(code.slice(1))); + return ENTITIES[code.toLowerCase()] ?? whole; + }) + .replace(/[ \t\r\f\v]+/g, ' ') + .replace(/\s*\n\s*/g, ' ') + .trim(); +} + +export function bodyTextOf(parsed: Pick): string { + if (parsed.text !== undefined && parsed.text.trim() !== '') return parsed.text; + if (parsed.html !== undefined) return stripHtml(parsed.html); + return ''; +} + +export function textPreviewOf(text: string | null, html: string | null): string { + const body = bodyTextOf({ ...(text === null ? {} : { text }), ...(html === null ? {} : { html }) }); + return body.slice(0, PREVIEW_CHARS); +} + +export type AttachmentPlan = { + readonly index: number; + readonly name: string; + readonly contentType: string; + readonly size: number; + readonly store: boolean; + readonly note: string; +}; + +export function planAttachments(attachments: readonly ParsedAttachment[], cap: number = ATTACHMENT_CAP): AttachmentPlan[] { + return attachments.map((a, index) => { + const size = a.bytes.byteLength; + const store = size <= cap; + return { + index, + name: a.name === '' ? `attachment-${index + 1}` : a.name, + contentType: a.contentType === '' ? 'application/octet-stream' : a.contentType, + size, + store, + note: store ? '' : `附件 ${(size / 1024 / 1024).toFixed(1)}MB,超过 ${Math.round(cap / 1024 / 1024)}MB 上限,未存原件`, + }; + }); +} + +export function rawOf(eml: Uint8Array, cap: number = RAW_CAP): { raw: Uint8Array | null; note: string } { + if (eml.byteLength <= cap) return { raw: eml, note: '' }; + return { raw: null, note: `原件 ${(eml.byteLength / 1024 / 1024).toFixed(1)}MB,超过 ${Math.round(cap / 1024 / 1024)}MB 上限,未存` }; +} + +export function buildInboundRecord(input: { + readonly id: string; + readonly accountId: string; + readonly address: string; + readonly providerRef: string; + readonly parsed: ParsedMail; + readonly eml: Uint8Array; + readonly receivedAt: Date; + readonly attachments: readonly InboundAttachmentRecord[]; + readonly now: Date; + readonly rawCap?: number | undefined; +}): InboundRecord { + const { parsed } = input; + const messageId = parsed.messageId !== undefined && parsed.messageId !== '' ? parsed.messageId : fallbackMessageId(input.providerRef, input.address); + const { raw, note } = rawOf(input.eml, input.rawCap ?? RAW_CAP); + return { + id: input.id, + accountId: input.accountId, + messageId, + providerRef: input.providerRef, + inReplyTo: parsed.inReplyTo ?? null, + references: [...parsed.references], + from: parsed.from ?? { address: '', name: '' }, + to: [...parsed.to], + cc: [...parsed.cc], + subject: parsed.subject, + text: parsed.text ?? null, + html: parsed.html ?? null, + sentAt: parsed.date ?? null, + receivedAt: input.receivedAt, + attachments: [...input.attachments], + raw, + rawNote: note, + size: input.eml.byteLength, + createdAt: input.now, + }; +} + +export function deliveryViewOf(d: DeliveryRecord): MailDeliveryView { + return { + subscriber: d.subscriber, + state: d.state, + error: d.error, + startedAt: d.startedAt.toISOString(), + finishedAt: d.finishedAt === null ? null : d.finishedAt.toISOString(), + }; +} + +export function summaryOf(record: InboundRecord, deliveries: readonly DeliveryRecord[]): MailInboundSummary { + return { + id: record.id, + accountId: record.accountId, + messageId: record.messageId, + from: record.from, + to: [...record.to], + cc: [...record.cc], + subject: record.subject, + sentAt: record.sentAt === null ? null : record.sentAt.toISOString(), + receivedAt: record.receivedAt.toISOString(), + textPreview: textPreviewOf(record.text, record.html), + hasHtml: record.html !== null, + attachments: record.attachments.map((a) => ({ ...a })), + deliveries: deliveries.map(deliveryViewOf), + }; +} + +export function detailOf(record: InboundRecord, deliveries: readonly DeliveryRecord[]): MailInboundDetail { + return { + ...summaryOf(record, deliveries), + inReplyTo: record.inReplyTo, + references: [...record.references], + text: record.text, + html: record.html, + size: record.size, + rawAvailable: record.raw !== null, + }; +} diff --git a/apps/api/src/modules/mail/internal/domain/redact.ts b/apps/api/src/modules/mail/internal/domain/redact.ts new file mode 100644 index 0000000..f5089de --- /dev/null +++ b/apps/api/src/modules/mail/internal/domain/redact.ts @@ -0,0 +1,36 @@ +/** + * 脱敏 —— 凡要落库 / 上接口的厂商原话都先过这里(设计稿 §9.4)。 + * 明文、base64(SMTP AUTH 的形)、URL 编码三形都换掉。 + */ +const MIN_SECRET_CHARS = 4; + +export function secretVariants(secret: string): string[] { + if (secret.length < MIN_SECRET_CHARS) return []; + const variants = [secret, Buffer.from(secret, 'utf8').toString('base64'), encodeURIComponent(secret)]; + return [...new Set(variants.filter((v) => v.length >= MIN_SECRET_CHARS))]; +} + +function escapeRegex(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +export function redactSecrets(text: string, secrets: readonly string[]): string { + let out = text; + for (const secret of secrets) { + for (const variant of secretVariants(secret)) { + out = out.replace(new RegExp(escapeRegex(variant), 'g'), '***'); + } + } + return out; +} + +/** 错误 → 一句话;nodemailer / imapflow 把服务器回话挂在 `response` / `responseText` 上,一并带上 */ +export function errorText(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 : ''; + return response === '' || response === error.message ? error.message : `${error.message} —— ${response}`; + } + if (error === undefined || error === null) return '未知错误'; + return String(error); +}