mail domain:解析结果 → 入站记录 / 视图,附件与原件按上限分存留注,厂商原话脱敏三形

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 00:26:08 +08:00
co-authored by Claude Fable 5
parent 58d6d778ea
commit 6748ef01b7
5 changed files with 366 additions and 0 deletions
@@ -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);
}