Archived
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:
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user