mail adapter:postal-mime 解析器(GBK 字头 / 群组地址 / References 拆串 / 附件字节)+ 测试夹具

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:59:40 +08:00
co-authored by Claude Fable 5
parent d65579c0d8
commit b9a4af9a2a
4 changed files with 482 additions and 0 deletions
@@ -0,0 +1,337 @@
/**
* 测试夹具 —— 内存 store(用例测试用)、假 provider(邮件通道是外界,替身允许)、EML 构造。
* 系统内部机制在 IT 里真跑(mail-store.it.test.ts);这里的内存 store 只服务用例逻辑。
*/
import type { MailAccountKind, MailProbeReport } from '@contracts';
import { isDue } from '../internal/domain/due.ts';
import { imapCursor, wecomCursor } from '../internal/domain/cursor.ts';
import type {
AttachmentStore,
Clock,
DeliveryRecord,
IdGenerator,
InboundRecord,
InboundStore,
MailAccountRecord,
MailAccountStore,
MailCursor,
MailProvider,
OutboundMessage,
OutboundRecord,
OutboundStore,
RawMessage,
SecretBox,
} from '../port.ts';
const CRLF = '\r\n';
export function fixedClock(iso = '2026-08-25T10:00:00Z'): Clock & { tick(ms: number): void } {
let t = new Date(iso).getTime();
return {
now: () => new Date(t),
tick(ms) {
t += ms;
},
};
}
export function seqIds(prefix = 'id'): IdGenerator {
let n = 0;
return {
next: () => {
n += 1;
return `${prefix}-${n}`;
},
};
}
export const plainSecrets: SecretBox = {
seal: (s) => `sealed:${s}`,
open: (c) => {
if (!c.startsWith('sealed:')) throw new Error('密文格式非法');
return c.slice('sealed:'.length);
},
};
export function account(over: Partial<MailAccountRecord> = {}): MailAccountRecord {
const now = new Date('2026-08-25T09:00:00Z');
return {
id: 'acc-1',
kind: 'imap-smtp',
name: '销售信箱',
address: 'sales@we.com',
displayName: '销售部',
enabled: true,
isDefault: true,
pollIntervalSec: 30,
channelId: 'ch-1',
backfillFrom: null,
settings: { folder: 'INBOX' },
secretCipher: `sealed:${JSON.stringify({ password: 'pw-1234' })}`,
cursor: null,
lastPolledAt: null,
lastPollError: null,
lastInboundAt: null,
pollingUntil: null,
createdAt: now,
updatedAt: now,
...over,
};
}
export function memoryAccounts(clock: Clock): MailAccountStore & { rows: Map<string, MailAccountRecord> } {
const rows = new Map<string, MailAccountRecord>();
return {
rows,
async list() {
return [...rows.values()].sort((a, b) => a.name.localeCompare(b.name));
},
async get(id) {
return rows.get(id);
},
async findByName(name) {
return [...rows.values()].find((r) => r.name === name);
},
async save(record) {
if (record.isDefault) {
for (const [k, v] of rows) if (k !== record.id && v.isDefault) rows.set(k, { ...v, isDefault: false });
}
rows.set(record.id, record);
},
async remove(id) {
rows.delete(id);
},
async listDue(now) {
return [...rows.values()].filter((r) => isDue(r, now));
},
async claimPolling(id, leaseMs) {
const r = rows.get(id);
const now = clock.now();
if (r === undefined || !r.enabled) return false;
if (r.pollingUntil !== null && r.pollingUntil.getTime() > now.getTime()) return false;
rows.set(id, { ...r, pollingUntil: new Date(now.getTime() + leaseMs) });
return true;
},
async releasePolling(id) {
const r = rows.get(id);
if (r !== undefined) rows.set(id, { ...r, pollingUntil: null });
},
async savePoll(id, patch) {
const r = rows.get(id);
if (r === undefined) return;
rows.set(id, {
...r,
...(patch.cursor === undefined ? {} : { cursor: patch.cursor }),
...(patch.polledAt === undefined ? {} : { lastPolledAt: patch.polledAt }),
...(patch.error === undefined ? {} : { lastPollError: patch.error }),
...(patch.lastInboundAt === undefined ? {} : { lastInboundAt: patch.lastInboundAt }),
...(patch.address === undefined ? {} : { address: patch.address }),
});
},
};
}
export function memoryInbound(): InboundStore & { rows: Map<string, InboundRecord>; deliveries: Map<string, DeliveryRecord> } {
const rows = new Map<string, InboundRecord>();
const deliveries = new Map<string, DeliveryRecord>();
// 与 DB store 同序:按订阅方名排(测试里对顺序有断言)
const of = (mailId: string): DeliveryRecord[] =>
[...deliveries.values()].filter((d) => d.mailId === mailId).sort((a, b) => a.subscriber.localeCompare(b.subscriber));
return {
rows,
deliveries,
async insert(record) {
for (const r of rows.values()) {
if (r.accountId === record.accountId && (r.messageId === record.messageId || r.providerRef === record.providerRef)) {
return { inserted: false };
}
}
rows.set(record.id, record);
return { inserted: true };
},
async knownProviderRefs(accountId, refs) {
return new Set([...rows.values()].filter((r) => r.accountId === accountId && refs.includes(r.providerRef)).map((r) => r.providerRef));
},
async list(query) {
return [...rows.values()]
.filter((r) => query.accountId === undefined || r.accountId === query.accountId)
.sort((a, b) => b.receivedAt.getTime() - a.receivedAt.getTime())
.slice(0, query.limit)
.map((record) => ({ record, deliveries: of(record.id) }));
},
async get(mailId) {
const record = rows.get(mailId);
return record === undefined ? undefined : { record, deliveries: of(mailId) };
},
async setDelivery(delivery) {
deliveries.set(`${delivery.mailId}|${delivery.subscriber}`, delivery);
},
};
}
export function memoryOutbound(): OutboundStore & { rows: Map<string, OutboundRecord> } {
const rows = new Map<string, OutboundRecord>();
return {
rows,
async insert(record) {
if (record.idempotencyKey !== null && [...rows.values()].some((r) => r.idempotencyKey === record.idempotencyKey)) {
throw new Error('duplicate key value violates unique constraint "mail_outbound_idem_key"');
}
rows.set(record.id, record);
},
async byIdempotencyKey(key) {
return [...rows.values()].find((r) => r.idempotencyKey === key);
},
async setState(id, state, patch) {
const r = rows.get(id);
if (r === undefined) return;
rows.set(id, {
...r,
state,
...(patch.error === undefined ? {} : { error: patch.error }),
...(patch.providerRef === undefined ? {} : { providerRef: patch.providerRef }),
...(patch.sentAt === undefined ? {} : { sentAt: patch.sentAt }),
});
},
async list(query) {
return [...rows.values()]
.filter((r) => query.accountId === undefined || r.accountId === query.accountId)
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
.slice(0, query.limit);
},
};
}
export function memoryAttachments(): AttachmentStore & { files: Map<string, { name: string; contentType: string; bytes: Uint8Array; note: string }> } {
const files = new Map<string, { name: string; contentType: string; bytes: Uint8Array; note: string }>();
let n = 0;
return {
files,
async put(file) {
n += 1;
const ref = `up-${n}`;
files.set(ref, { ...file });
return { ref };
},
async get(ref) {
const f = files.get(ref);
return f === undefined ? undefined : { name: f.name, contentType: f.contentType, bytes: f.bytes };
},
};
}
export type FakeProvider = MailProvider & {
readonly sent: OutboundMessage[];
readonly pullCalls: Array<{ cursor: MailCursor; limit: number }>;
queue(...messages: RawMessage[]): void;
failPullWith(error: Error | undefined): void;
failSendWith(error: Error | undefined): void;
probeWith(report: MailProbeReport | undefined): void;
};
/** 可脚本化的假 provider:排队的信按 limit 交出去,IMAP 形逐封带 cursorAfter */
export function fakeProvider(kind: MailAccountKind = 'imap-smtp'): FakeProvider {
const pending: RawMessage[] = [];
const sent: OutboundMessage[] = [];
const pullCalls: Array<{ cursor: MailCursor; limit: number }> = [];
let pullError: Error | undefined;
let sendError: Error | undefined;
let probeReport: MailProbeReport | undefined;
let uidSeq = 0;
return {
kind,
sent,
pullCalls,
queue(...messages) {
pending.push(...messages);
},
failPullWith(error) {
pullError = error;
},
failSendWith(error) {
sendError = error;
},
probeWith(report) {
probeReport = report;
},
async probe(accountArg) {
return (
probeReport ?? {
kind,
address: accountArg.address,
receive: { ok: true, latencyMs: 1 },
send: kind === 'wecom' ? { checked: false, note: '发信权限只能在真发时验证' } : { checked: true, ok: true },
}
);
},
async anchor(_accountArg, _backfillFrom, now) {
return kind === 'imap-smtp' ? imapCursor('v1', 0) : wecomCursor(Math.floor(now.getTime() / 1000));
},
async pull(_accountArg, cursor, opts) {
pullCalls.push({ cursor, limit: opts.limit });
if (pullError !== undefined) throw pullError;
const batch = pending.splice(0, opts.limit).map((m) => {
if (kind !== 'imap-smtp' || m.cursorAfter !== undefined) return m;
uidSeq += 1;
return { ...m, cursorAfter: imapCursor('v1', uidSeq) };
});
const last = batch.at(-1)?.cursorAfter;
const next = kind === 'imap-smtp' ? (last ?? cursor) : wecomCursor(Math.floor(opts.now.getTime() / 1000));
return { messages: batch, cursor: next, more: pending.length > 0 };
},
async send(_accountArg, message) {
if (sendError !== undefined) throw sendError;
sent.push(message);
return { providerRef: `fake:${sent.length}` };
},
};
}
export function rawMessage(providerRef: string, eml: Uint8Array, receivedAt = new Date('2026-08-25T09:30:00Z')): RawMessage {
return { providerRef, eml, receivedAt };
}
/** 极简 RFC822 构造:够 postal-mime 解出地址、主题、正文、附件 */
export function eml(parts: {
from?: string;
to?: string;
cc?: string;
subject?: string;
text?: string;
html?: string;
messageId?: string;
inReplyTo?: string;
references?: string;
date?: string;
attachment?: { name: string; contentType: string; content: string };
}): Uint8Array {
const headers = [
`From: ${parts.from ?? 'buyer@acme.com'}`,
`To: ${parts.to ?? 'sales@we.com'}`,
...(parts.cc === undefined ? [] : [`Cc: ${parts.cc}`]),
`Subject: ${parts.subject ?? '询价'}`,
...(parts.messageId === undefined ? [] : [`Message-ID: ${parts.messageId}`]),
...(parts.inReplyTo === undefined ? [] : [`In-Reply-To: ${parts.inReplyTo}`]),
...(parts.references === undefined ? [] : [`References: ${parts.references}`]),
`Date: ${parts.date ?? 'Tue, 25 Aug 2026 09:00:00 +0800'}`,
'MIME-Version: 1.0',
];
const text = parts.text ?? '你好,请报价';
if (parts.html === undefined && parts.attachment === undefined) {
return new TextEncoder().encode([...headers, 'Content-Type: text/plain; charset=utf-8', '', text, ''].join(CRLF));
}
const lines = [...headers, 'Content-Type: multipart/mixed; boundary="B1"', '', '--B1', 'Content-Type: text/plain; charset=utf-8', '', text];
if (parts.html !== undefined) lines.push('--B1', 'Content-Type: text/html; charset=utf-8', '', parts.html);
if (parts.attachment !== undefined) {
lines.push(
'--B1',
`Content-Type: ${parts.attachment.contentType}; name="${parts.attachment.name}"`,
`Content-Disposition: attachment; filename="${parts.attachment.name}"`,
'Content-Transfer-Encoding: base64',
'',
Buffer.from(parts.attachment.content, 'utf8').toString('base64'),
);
}
lines.push('--B1--', '');
return new TextEncoder().encode(lines.join(CRLF));
}
@@ -0,0 +1,81 @@
/** MIME 解析 —— 真 postal-mime:GBK 编码字头、群组地址、References 拆串、纯 html、附件字节 */
import { describe, expect, it } from 'vitest';
import { createPostalMimeParser } from '../internal/adapter/postal-mime-parser.ts';
import { eml } from './fakes.ts';
const parser = createPostalMimeParser();
const CRLF = '\r\n';
describe('createPostalMimeParser', () => {
it('地址 / 主题 / 正文 / Message-ID / In-Reply-To / References / 日期', async () => {
const parsed = await parser.parse(
eml({
from: '"张三" <buyer@acme.com>',
to: 'sales@we.com, "Ops" <ops@we.com>',
subject: '询价 RFQ-1',
messageId: '<m1@acme.com>',
inReplyTo: '<q1@we.com>',
references: '<root@we.com> <q1@we.com>',
text: '你好,请报价',
}),
);
expect(parsed.from).toEqual({ address: 'buyer@acme.com', name: '张三' });
expect(parsed.to).toEqual([
{ address: 'sales@we.com', name: '' },
{ address: 'ops@we.com', name: 'Ops' },
]);
expect(parsed.subject).toBe('询价 RFQ-1');
expect(parsed.messageId).toBe('<m1@acme.com>');
expect(parsed.inReplyTo).toBe('<q1@we.com>');
expect(parsed.references).toEqual(['<root@we.com>', '<q1@we.com>']);
expect(parsed.text?.trim()).toBe('你好,请报价');
expect(parsed.date?.toISOString()).toBe('2026-08-25T01:00:00.000Z');
});
it('GBK 的 =?GBK?B?…?= 主题与 charset=gbk 正文解成中文(Node 自带 TextDecoder)', async () => {
const subject = Buffer.from([0xd1, 0xaf, 0xbc, 0xdb]).toString('base64'); // 询价
const body = Buffer.from([0xc4, 0xe3, 0xba, 0xc3]).toString('base64'); // 你好
const raw = [
'From: buyer@acme.com',
'To: sales@we.com',
`Subject: =?GBK?B?${subject}?=`,
'Content-Type: text/plain; charset=gbk',
'Content-Transfer-Encoding: base64',
'',
body,
'',
].join(CRLF);
const parsed = await parser.parse(new TextEncoder().encode(raw));
expect(parsed.subject).toBe('询价');
expect(parsed.text?.trim()).toBe('你好');
});
it('群组地址摊平;没有 Cc 就是空数组', async () => {
const raw = ['From: a@x.com', 'To: team: b@x.com, "C" <c@x.com>;', 'Subject: s', '', 'body', ''].join(CRLF);
const parsed = await parser.parse(new TextEncoder().encode(raw));
expect(parsed.to).toEqual([
{ address: 'b@x.com', name: '' },
{ address: 'c@x.com', name: 'C' },
]);
expect(parsed.cc).toEqual([]);
});
it('纯 html 的信 text 缺席、html 在;没有 Message-ID / From 也不抛', async () => {
const raw = ['Subject: x', 'Content-Type: text/html', '', '<b>only html</b>', ''].join(CRLF);
const parsed = await parser.parse(new TextEncoder().encode(raw));
expect(parsed.text).toBeUndefined();
expect(parsed.html).toContain('only html');
expect(parsed.messageId).toBeUndefined();
expect(parsed.from).toBeUndefined();
expect(parsed.date).toBeUndefined();
});
it('附件:文件名、类型、字节(ArrayBuffer → Uint8Array)', async () => {
const parsed = await parser.parse(eml({ attachment: { name: 'bom.xlsx', contentType: 'application/vnd.ms-excel', content: 'PKxyz' } }));
expect(parsed.attachments).toHaveLength(1);
expect(parsed.attachments[0]?.name).toBe('bom.xlsx');
expect(parsed.attachments[0]?.contentType).toBe('application/vnd.ms-excel');
expect(parsed.attachments[0]?.bytes).toBeInstanceOf(Uint8Array);
expect(new TextDecoder().decode(parsed.attachments[0]?.bytes)).toBe('PKxyz');
});
});
+1
View File
@@ -80,3 +80,4 @@ export { cursorReset, mergeAccount, secretOf, viewOf } from './internal/domain/a
export { createInboundStore, createMailAccountStore, createOutboundStore } from './internal/adapter/mail-store.ts'; export { createInboundStore, createMailAccountStore, createOutboundStore } from './internal/adapter/mail-store.ts';
export { mailAccountTable, mailInboundDeliveryTable, mailInboundTable, mailOutboundTable } from './internal/adapter/mail-schema.ts'; export { mailAccountTable, mailInboundDeliveryTable, mailInboundTable, mailOutboundTable } from './internal/adapter/mail-schema.ts';
export { createPostalMimeParser } from './internal/adapter/postal-mime-parser.ts';
@@ -0,0 +1,63 @@
/**
* MIME 解析 —— postal-mime(零依赖;GBK / GB18030 走 Node 自带 TextDecoder)。
* 两条接入路共用:IMAP 拉到的 source 与 wecom `read_mail` 的 EML 都从这里过。
*/
import PostalMime from 'postal-mime';
import type { Address } from 'postal-mime';
import type { MailAddress } from '@contracts';
import type { MimeParser, ParsedMail } from '../../port.ts';
function mailboxesOf(list: readonly Address[] | undefined): MailAddress[] {
const out: MailAddress[] = [];
for (const entry of list ?? []) {
if (entry.group !== undefined) {
for (const member of entry.group) out.push({ address: member.address, name: member.name });
} else if (entry.address !== undefined) {
out.push({ address: entry.address, name: entry.name });
}
}
return out;
}
/** References / In-Reply-To 是空白分隔的一串 <id>;拆开、去空 */
function idsOf(value: string | undefined): string[] {
if (value === undefined) return [];
return value
.split(/\s+/)
.map((s) => s.trim())
.filter((s) => s !== '');
}
function bytesOf(content: ArrayBuffer | Uint8Array | string): Uint8Array {
if (typeof content === 'string') return new TextEncoder().encode(content);
return content instanceof Uint8Array ? content : new Uint8Array(content);
}
export function createPostalMimeParser(): MimeParser {
return {
async parse(eml): Promise<ParsedMail> {
const email = await PostalMime.parse(eml, { attachmentEncoding: 'arraybuffer' });
const from =
email.from !== undefined && email.from.address !== undefined ? { address: email.from.address, name: email.from.name } : undefined;
const date = email.date === undefined ? undefined : new Date(email.date);
const inReplyTo = idsOf(email.inReplyTo)[0];
return {
...(email.messageId === undefined ? {} : { messageId: email.messageId }),
...(inReplyTo === undefined ? {} : { inReplyTo }),
references: idsOf(email.references),
...(from === undefined ? {} : { from }),
to: mailboxesOf(email.to),
cc: mailboxesOf(email.cc),
subject: email.subject ?? '',
...(email.text === undefined ? {} : { text: email.text }),
...(email.html === undefined ? {} : { html: email.html }),
...(date === undefined || Number.isNaN(date.getTime()) ? {} : { date }),
attachments: email.attachments.map((a) => ({
name: a.filename ?? '',
contentType: a.mimeType,
bytes: bytesOf(a.content),
})),
};
},
};
}