Archived
mail domain:解析结果 → 入站记录 / 视图,附件与原件按上限分存留注,厂商原话脱敏三形
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TM4kUBvekALu9qqmp7D3wr
This commit is contained in:
@@ -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: '<m1@acme.com>',
|
||||||
|
inReplyTo: '<q1@we.com>',
|
||||||
|
references: ['<root@we.com>', '<q1@we.com>'],
|
||||||
|
from: { address: 'buyer@acme.com', name: '张三' },
|
||||||
|
to: [{ address: 'sales@we.com', name: '' }],
|
||||||
|
cc: [],
|
||||||
|
subject: '询价 RFQ-1',
|
||||||
|
text: '你好,请报价',
|
||||||
|
html: '<p>你好,请报价</p>',
|
||||||
|
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('<q1@we.com>');
|
||||||
|
expect(threadIdOf({ references: ['<a>', '<b>'] })).toBe('<b>');
|
||||||
|
expect(threadIdOf({ references: [] })).toBeUndefined();
|
||||||
|
});
|
||||||
|
it('缺 Message-ID 用 providerRef@域顶上,不抛', () => {
|
||||||
|
expect(fallbackMessageId('uid:7:12', 'sales@we.com')).toBe('<uid:7:12@we.com>');
|
||||||
|
expect(fallbackMessageId('x', 'no-at')).toBe('<x@local>');
|
||||||
|
});
|
||||||
|
it('纯 html 的信剥标签当正文;script / style 整段丢;实体解回来;空白收成一个', () => {
|
||||||
|
expect(stripHtml('<style>p{}</style><p>你好&<b>世界</b></p>\n<script>x()</script> 再见')).toBe('你好&世界 再见');
|
||||||
|
expect(bodyTextOf({ html: '<div>只有 html</div>' })).toBe('只有 html');
|
||||||
|
expect(bodyTextOf({ text: '有 text', html: '<i>忽略</i>' })).toBe('有 text');
|
||||||
|
expect(bodyTextOf({})).toBe('');
|
||||||
|
});
|
||||||
|
it('预览 200 字', () => {
|
||||||
|
expect(textPreviewOf('a'.repeat(300), null)).toHaveLength(200);
|
||||||
|
expect(textPreviewOf(null, '<p>来自 html</p>')).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('<m1@acme.com>');
|
||||||
|
expect(record.inReplyTo).toBe('<q1@we.com>');
|
||||||
|
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('<uid:7:13@we.com>');
|
||||||
|
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(['<root@we.com>', '<q1@we.com>']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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('未知错误');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -62,3 +62,15 @@ export {
|
|||||||
wecomWindow,
|
wecomWindow,
|
||||||
} from './internal/domain/cursor.ts';
|
} from './internal/domain/cursor.ts';
|
||||||
export { isDue, isPolling, leaseMsFor } from './internal/domain/due.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';
|
||||||
|
|||||||
@@ -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<ParsedMail, 'inReplyTo' | 'references'>): string | undefined {
|
||||||
|
if (parsed.inReplyTo !== undefined && parsed.inReplyTo !== '') return parsed.inReplyTo;
|
||||||
|
return parsed.references.at(-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ENTITIES: Readonly<Record<string, string>> = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' };
|
||||||
|
|
||||||
|
export function stripHtml(html: string): string {
|
||||||
|
return html
|
||||||
|
.replace(/<(script|style)[\s\S]*?<\/\1>/gi, ' ')
|
||||||
|
.replace(/<br\s*\/?>|<\/(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<ParsedMail, 'text' | 'html'>): 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user