From 1328aeb14222d386e51a916b9b6fef145043beb2 Mon Sep 17 00:00:00 2001 From: xcs Date: Wed, 26 Aug 2026 01:08:48 +0800 Subject: [PATCH] =?UTF-8?q?mail=20=E7=94=A8=E4=BE=8B:=E8=B4=A6=E5=8F=B7?= =?UTF-8?q?=E5=BB=BA=E6=94=B9=E5=88=A0=E4=B8=8E=E6=8E=A2=E6=B5=8B(?= =?UTF-8?q?=E8=8D=89=E7=A8=BF=E6=8E=A2=E6=B5=8B=E5=8F=AF=E5=80=9F=E5=BA=93?= =?UTF-8?q?=E9=87=8C=E5=AF=86=E6=96=87;wecom=20=E6=8E=A2=E5=88=B0=E5=9C=B0?= =?UTF-8?q?=E5=9D=80=E5=9B=9E=E5=A1=AB);=E6=94=B6=E5=8F=91=E7=94=A8?= =?UTF-8?q?=E4=BE=8B=E5=90=8C=E4=BB=BD=E8=90=BD=E5=9C=B0=E5=BE=85=E6=B5=8B?= 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 --- .../__tests__/mail-service-accounts.test.ts | 100 ++++ apps/api/src/modules/mail/index.ts | 3 + .../mail/internal/usecase/mail-service.ts | 497 ++++++++++++++++++ 3 files changed, 600 insertions(+) create mode 100644 apps/api/src/modules/mail/__tests__/mail-service-accounts.test.ts create mode 100644 apps/api/src/modules/mail/internal/usecase/mail-service.ts diff --git a/apps/api/src/modules/mail/__tests__/mail-service-accounts.test.ts b/apps/api/src/modules/mail/__tests__/mail-service-accounts.test.ts new file mode 100644 index 0000000..a62f27a --- /dev/null +++ b/apps/api/src/modules/mail/__tests__/mail-service-accounts.test.ts @@ -0,0 +1,100 @@ +/** 账号用例:建 / 改 / 删 / 探测;密文只进库不出接口;名字唯一;secret 缺省保留;kind 变了游标清空 */ +import { describe, expect, it } from 'vitest'; +import { mailAccountInputSchema } from '@contracts'; +import { MailAccountNameTakenError, MailAccountNotFoundError, MailSecretMissingError } from '../contract.ts'; +import { createMailService } from '../internal/usecase/mail-service.ts'; +import { createPostalMimeParser } from '../internal/adapter/postal-mime-parser.ts'; +import { fakeProvider, fixedClock, memoryAccounts, memoryAttachments, memoryInbound, memoryOutbound, plainSecrets, seqIds } from './fakes.ts'; + +function harness() { + const clock = fixedClock(); + const accounts = memoryAccounts(clock); + const imap = fakeProvider('imap-smtp'); + const wecom = fakeProvider('wecom'); + const changes: string[] = []; + const service = createMailService({ + accounts, + inbound: memoryInbound(), + outbound: memoryOutbound(), + attachments: memoryAttachments(), + providers: { 'imap-smtp': imap, wecom }, + mime: createPostalMimeParser(), + secrets: plainSecrets, + clock, + ids: seqIds('acc'), + notify: { changed: (r) => void changes.push(r) }, + }); + return { service, accounts, imap, wecom, clock, changes }; +} +// 入参是判别联合:`{...imapInput, address}` 若停在联合上,TS 会拿 wecom 那一形来判超额属性(它没有 address)。 +// 先窄到 imap-smtp 这一形,下面各处展开才成立 —— 窄的是夹具的静态型,值一个字节没变。 +const parsedImapInput = mailAccountInputSchema.parse({ kind: 'imap-smtp', name: '销售', address: 'sales@we.com', isDefault: true, secret: { password: 'pw-1234' } }); +if (parsedImapInput.kind !== 'imap-smtp') throw new Error('夹具:imapInput 应是 imap-smtp 那一形'); +const imapInput = parsedImapInput; + +describe('账号', () => { + it('建:视图不带密文、secretBound 真;库里密文是密封过的', async () => { + const { service, accounts } = harness(); + const view = await service.createAccount(imapInput); + expect(view.id).toBe('acc-1'); + expect(view.secretBound).toBe(true); + expect(JSON.stringify(view)).not.toContain('pw-1234'); + expect(accounts.rows.get('acc-1')?.secretCipher).toBe(`sealed:${JSON.stringify({ password: 'pw-1234' })}`); + expect((await service.listAccounts()).map((a) => a.name)).toEqual(['销售']); + }); + + it('名字唯一:重名 409;改名撞别人也 409', async () => { + const { service } = harness(); + await service.createAccount(imapInput); + await expect(service.createAccount(imapInput)).rejects.toBeInstanceOf(MailAccountNameTakenError); + const second = await service.createAccount({ ...imapInput, name: '采购', address: 'buy@we.com' }); + await expect(service.updateAccount(second.id, { ...imapInput, name: '销售' })).rejects.toBeInstanceOf(MailAccountNameTakenError); + }); + + it('改:secret 缺省保留原密文;给了就换;换 kind 没给 secret 时旧密文作废', async () => { + const { service, accounts } = harness(); + const created = await service.createAccount(imapInput); + await service.updateAccount(created.id, { ...imapInput, secret: undefined, displayName: '销售一部' }); + expect(accounts.rows.get(created.id)?.secretCipher).toContain('pw-1234'); + await service.updateAccount(created.id, { ...imapInput, secret: { password: 'new-pw-9' } }); + expect(accounts.rows.get(created.id)?.secretCipher).toContain('new-pw-9'); + const wecomInput = mailAccountInputSchema.parse({ kind: 'wecom', name: '销售', settings: { corpId: 'ww1' } }); + const switched = await service.updateAccount(created.id, wecomInput); + expect(switched.secretBound).toBe(false); + expect(switched.address).toBe(''); + }); + + it('默认账号只有一个;删了就没了(404)', async () => { + const { service } = harness(); + const a = await service.createAccount(imapInput); + const b = await service.createAccount({ ...imapInput, name: '采购', address: 'buy@we.com', isDefault: true }); + expect((await service.getAccount(a.id)).isDefault).toBe(false); + expect((await service.getAccount(b.id)).isDefault).toBe(true); + await service.deleteAccount(a.id); + await expect(service.getAccount(a.id)).rejects.toBeInstanceOf(MailAccountNotFoundError); + await expect(service.deleteAccount(a.id)).rejects.toBeInstanceOf(MailAccountNotFoundError); + }); + + it('探测(已存):用库里凭证,报告里的厂商原话脱敏;wecom 探到地址回填', async () => { + const { service, wecom, imap } = harness(); + imap.probeWith({ kind: 'imap-smtp', receive: { ok: false, error: 'LOGIN sales@we.com pw-1234 failed' }, send: { checked: true, ok: true } }); + const a = await service.createAccount(imapInput); + const report = await service.probe({ accountId: a.id }); + expect(report.receive.error).toBe('LOGIN sales@we.com *** failed'); + const w = await service.createAccount(mailAccountInputSchema.parse({ kind: 'wecom', name: '应用', settings: { corpId: 'ww1' }, secret: { corpSecret: 'cs-9999' } })); + wecom.probeWith({ kind: 'wecom', address: 'app@corp.wecom.work', receive: { ok: true }, send: { checked: false, note: '发信权限只能在真发时验证' } }); + await service.probe({ accountId: w.id }); + expect((await service.getAccount(w.id)).address).toBe('app@corp.wecom.work'); + }); + + it('探测(草稿):不落库;没 secret 又没 accountId → 412;给了 accountId 就借库里的密文', async () => { + const { service, accounts } = harness(); + const report = await service.probe({ input: imapInput }); + expect(report.receive.ok).toBe(true); + expect(accounts.rows.size).toBe(0); + await expect(service.probe({ input: { ...imapInput, secret: undefined } })).rejects.toBeInstanceOf(MailSecretMissingError); + const a = await service.createAccount(imapInput); + const borrowed = await service.probe({ input: { ...imapInput, secret: undefined, displayName: '改过' }, accountId: a.id }); + expect(borrowed.receive.ok).toBe(true); + }); +}); diff --git a/apps/api/src/modules/mail/index.ts b/apps/api/src/modules/mail/index.ts index 79fadcf..c5d07b3 100644 --- a/apps/api/src/modules/mail/index.ts +++ b/apps/api/src/modules/mail/index.ts @@ -81,3 +81,6 @@ export { cursorReset, mergeAccount, secretOf, viewOf } from './internal/domain/a export { createInboundStore, createMailAccountStore, createOutboundStore } from './internal/adapter/mail-store.ts'; export { mailAccountTable, mailInboundDeliveryTable, mailInboundTable, mailOutboundTable } from './internal/adapter/mail-schema.ts'; export { createPostalMimeParser } from './internal/adapter/postal-mime-parser.ts'; + +export type { MailServiceDeps } from './internal/usecase/mail-service.ts'; +export { createMailService } from './internal/usecase/mail-service.ts'; diff --git a/apps/api/src/modules/mail/internal/usecase/mail-service.ts b/apps/api/src/modules/mail/internal/usecase/mail-service.ts new file mode 100644 index 0000000..9ffe893 --- /dev/null +++ b/apps/api/src/modules/mail/internal/usecase/mail-service.ts @@ -0,0 +1,497 @@ +/** + * mail 用例 —— 账号、收、发(编码规范 §5.3;设计稿 §5.4;ADR-013)。 + * + * 收:抢租约 → 无游标先锚 → 循环 pull(封顶 50 封 / 20s)→ 逐封解析 / 存附件 / 入库(唯一键去重) + * → 逐封推进游标 → 新入库的分发给订阅方 → 还租约。任何异常:记 last_poll_error(脱敏),游标停在最后一封成功处。 + * 发:选账号 → 幂等键 → 校验 → 落 sending → provider → sent / failed(原话脱敏);厂商失败不抛。 + */ +import type { MailAccountKind, MailPollOutcome, MailProbeReport, MailSendResult } from '@contracts'; +import type { InboundHandler, MailService } from '../../contract.ts'; +import { mergeAccount, secretOf, viewOf } from '../domain/account-view.ts'; +import { canRedeliver, stateOfOutcome } from '../domain/delivery.ts'; +import { leaseMsFor } from '../domain/due.ts'; +import { + MailAccountDisabledError, + MailAccountNameTakenError, + MailAccountNotFoundError, + MailAttachmentNotFoundError, + MailInboundNotFoundError, + MailPollBusyError, + MailSecretMissingError, + MailSendInvalidError, + MailSubscriberTakenError, + MailSubscriberUnknownError, +} from '../domain/errors.ts'; +import { ATTACHMENT_CAP, buildInboundRecord, deliveryViewOf, detailOf, planAttachments, RAW_CAP, summaryOf } from '../domain/inbound-record.ts'; +import type { DeliveryRecord, InboundAttachmentRecord, InboundRecord, MailAccountRecord, OutboundRecord, ParsedAttachment } from '../domain/records.ts'; +import { errorText, redactSecrets } from '../domain/redact.ts'; +import { messageIdFor, pickAccount, validateSend } from '../domain/send-input.ts'; +import type { + AttachmentStore, + ChangeNotifier, + Clock, + IdGenerator, + InboundStore, + MailAccountStore, + MailProvider, + MimeParser, + OutboundStore, + ProviderAccount, + RawMessage, + SecretBox, +} from '../../port.ts'; + +export type MailServiceDeps = { + readonly accounts: MailAccountStore; + readonly inbound: InboundStore; + readonly outbound: OutboundStore; + readonly attachments: AttachmentStore; + readonly providers: Readonly>; + readonly mime: MimeParser; + readonly secrets: SecretBox; + readonly clock: Clock; + readonly ids: IdGenerator; + readonly notify: ChangeNotifier; + readonly limits?: + | { + readonly perTick?: number | undefined; + readonly tickBudgetMs?: number | undefined; + readonly attachmentBytes?: number | undefined; + readonly rawBytes?: number | undefined; + } + | undefined; +}; + +const DEFAULT_LIST_LIMIT = 50; +const MAX_LIST_LIMIT = 200; + +type PollTally = { accountId: string; pulled: number; stored: number; dispatched: number; more: boolean; error?: string }; + +function tallyToOutcome(t: PollTally): MailPollOutcome { + return { + accountId: t.accountId, + pulled: t.pulled, + stored: t.stored, + dispatched: t.dispatched, + more: t.more, + ...(t.error === undefined ? {} : { error: t.error }), + }; +} + +function safeFileName(messageId: string): string { + const cleaned = messageId.replace(/[^A-Za-z0-9._-]+/g, '_').replace(/^_+|_+$/g, ''); + return cleaned === '' ? 'mail' : cleaned; +} + +function resultOf(record: OutboundRecord): MailSendResult { + return { + outboundId: record.id, + accountId: record.accountId, + messageId: record.messageId, + state: record.state === 'sent' ? 'sent' : 'failed', + ...(record.error === null ? {} : { error: record.error }), + ...(record.providerRef === null ? {} : { providerRef: record.providerRef }), + }; +} + +export function createMailService(deps: MailServiceDeps): MailService { + const limits = { + perTick: deps.limits?.perTick ?? 50, + tickBudgetMs: deps.limits?.tickBudgetMs ?? 20_000, + attachmentBytes: deps.limits?.attachmentBytes ?? ATTACHMENT_CAP, + rawBytes: deps.limits?.rawBytes ?? RAW_CAP, + }; + const subscribers = new Map(); + + async function requireAccount(accountId: string): Promise { + const record = await deps.accounts.get(accountId); + if (record === undefined) throw new MailAccountNotFoundError(accountId); + return record; + } + + /** 明文只在这里打开、交给 provider 那一段;不进任何留痕 */ + function openSecret(record: Pick): Record { + if (record.secretCipher === null || record.secretCipher === '') throw new MailSecretMissingError(record.id); + const parsed: unknown = JSON.parse(deps.secrets.open(record.secretCipher)); + if (typeof parsed !== 'object' || parsed === null) throw new MailSecretMissingError(record.id); + return Object.fromEntries(Object.entries(parsed as Record).map(([k, v]) => [k, String(v)])); + } + + function providerAccount(record: MailAccountRecord, secret: Record): ProviderAccount { + return { id: record.id, kind: record.kind, address: record.address, displayName: record.displayName, settings: record.settings, secret }; + } + + function redactWith(secret: Record): (text: string) => string { + const values = Object.values(secret); + return (text) => redactSecrets(text, values); + } + + function redactReport(report: MailProbeReport, secret: Record): MailProbeReport { + const redact = redactWith(secret); + return { + ...report, + receive: { ...report.receive, ...(report.receive.error === undefined ? {} : { error: redact(report.receive.error) }) }, + send: { ...report.send, ...(report.send.error === undefined ? {} : { error: redact(report.send.error) }) }, + }; + } + + function limitOf(query: { limit?: number | undefined }): number { + return Math.min(MAX_LIST_LIMIT, Math.max(1, query.limit ?? DEFAULT_LIST_LIMIT)); + } + + /* ── 分发 ────────────────────────────────────────────────────────── */ + + async function dispatch(record: InboundRecord, ctx: { accountId: string; channelId: string | null }, only?: string): Promise { + const existing = (await deps.inbound.get(record.id))?.deliveries ?? []; + const out: DeliveryRecord[] = []; + for (const [name, handler] of subscribers) { + if (only !== undefined && name !== only) continue; + const current = existing.find((d) => d.subscriber === name); + // 正在投的那条不重投:进程还没回话,再投一遍就是两条线同时改一件事 + if (!canRedeliver(current?.state)) { + if (current !== undefined) out.push(current); + continue; + } + const startedAt = deps.clock.now(); + const dispatching: DeliveryRecord = { mailId: record.id, subscriber: name, state: 'dispatching', error: null, startedAt, finishedAt: null }; + await deps.inbound.setDelivery(dispatching); + let settled: DeliveryRecord; + try { + const outcome = await handler(detailOf(record, existing), { accountId: ctx.accountId, channelId: ctx.channelId ?? undefined }); + const state = stateOfOutcome(outcome); + settled = { + ...dispatching, + state, + // skipped 的理由也放 error 列:人在列表上看得见「为什么没投」 + error: state === 'skipped' && outcome !== undefined && typeof outcome === 'object' ? outcome.skipped : null, + finishedAt: deps.clock.now(), + }; + } catch (error) { + settled = { ...dispatching, state: 'failed', error: errorText(error), finishedAt: deps.clock.now() }; + } + await deps.inbound.setDelivery(settled); + out.push(settled); + } + return out; + } + + /* ── 拉取 ────────────────────────────────────────────────────────── */ + + async function storeOne(record: MailAccountRecord, message: RawMessage): Promise { + // 先按 provider 给的引用查重,免得重复拉到的信把附件再存一遍 + const known = await deps.inbound.knownProviderRefs(record.id, [message.providerRef]); + if (known.has(message.providerRef)) return undefined; + const parsed = await deps.mime.parse(message.eml); + const attachments: InboundAttachmentRecord[] = []; + for (const plan of planAttachments(parsed.attachments, limits.attachmentBytes)) { + let ref: string | null = null; + if (plan.store) { + const bytes = parsed.attachments[plan.index]?.bytes ?? new Uint8Array(); + ref = (await deps.attachments.put({ name: plan.name, contentType: plan.contentType, bytes, note: `邮件附件:${parsed.subject}` })).ref; + } + attachments.push({ index: plan.index, name: plan.name, contentType: plan.contentType, size: plan.size, ref, note: plan.note }); + } + const built = buildInboundRecord({ + id: deps.ids.next(), + accountId: record.id, + address: record.address, + providerRef: message.providerRef, + parsed, + eml: message.eml, + receivedAt: message.receivedAt, + attachments, + now: deps.clock.now(), + rawCap: limits.rawBytes, + }); + const { inserted } = await deps.inbound.insert(built); + return inserted ? built : undefined; + } + + async function pollAccount(record: MailAccountRecord): Promise { + const claimed = await deps.accounts.claimPolling(record.id, leaseMsFor(record.pollIntervalSec)); + if (!claimed) throw new MailPollBusyError(record.id); + const tally: PollTally = { accountId: record.id, pulled: 0, stored: 0, dispatched: 0, more: false }; + let secret: Record = {}; + try { + secret = openSecret(record); + const provider = deps.providers[record.kind]; + const account = providerAccount(record, secret); + const started = deps.clock.now(); + let cursor = record.cursor; + if (cursor === null) { + cursor = await provider.anchor(account, record.backfillFrom, started); + await deps.accounts.savePoll(record.id, { cursor }); + } + let budget = limits.perTick; + let more = true; + while (more && budget > 0 && deps.clock.now().getTime() - started.getTime() < limits.tickBudgetMs) { + const batch = await provider.pull(account, cursor, { + limit: budget, + now: deps.clock.now(), + backfillFrom: record.backfillFrom, + known: (refs) => deps.inbound.knownProviderRefs(record.id, refs), + }); + tally.pulled += batch.messages.length; + budget -= batch.messages.length; + for (const message of batch.messages) { + const stored = await storeOne(record, message); + // 逐封推进:中途栽了,下一轮从这一封之后接着 + if (message.cursorAfter !== undefined) { + cursor = message.cursorAfter; + await deps.accounts.savePoll(record.id, { cursor }); + } + if (stored !== undefined) { + tally.stored += 1; + await deps.accounts.savePoll(record.id, { lastInboundAt: stored.receivedAt }); + await dispatch(stored, { accountId: record.id, channelId: record.channelId }); + tally.dispatched += 1; + } + } + cursor = batch.cursor; + await deps.accounts.savePoll(record.id, { cursor }); + more = batch.more; + if (batch.messages.length === 0) break; + } + tally.more = more; + await deps.accounts.savePoll(record.id, { polledAt: deps.clock.now(), error: null }); + } catch (error) { + const text = redactWith(secret)(errorText(error)); + tally.error = text; + await deps.accounts.savePoll(record.id, { polledAt: deps.clock.now(), error: text }); + } finally { + await deps.accounts.releasePolling(record.id); + } + if (tally.stored > 0 || tally.error !== undefined) deps.notify.changed('inbound'); + return tallyToOutcome(tally); + } + + /* ── 服务 ────────────────────────────────────────────────────────── */ + + return { + async listAccounts() { + const now = deps.clock.now(); + return (await deps.accounts.list()).map((r) => viewOf(r, now)); + }, + + async getAccount(accountId) { + return viewOf(await requireAccount(accountId), deps.clock.now()); + }, + + async createAccount(input) { + if ((await deps.accounts.findByName(input.name)) !== undefined) throw new MailAccountNameTakenError(input.name); + const secretJson = secretOf(input); + const now = deps.clock.now(); + const record = mergeAccount(input, { + id: deps.ids.next(), + existing: undefined, + secretCipher: secretJson === undefined ? null : deps.secrets.seal(secretJson), + now, + }); + await deps.accounts.save(record); + return viewOf(record, now); + }, + + async updateAccount(accountId, input) { + const existing = await requireAccount(accountId); + const clash = await deps.accounts.findByName(input.name); + if (clash !== undefined && clash.id !== accountId) throw new MailAccountNameTakenError(input.name); + const secretJson = secretOf(input); + // 只写不读:secret 留空即保持原密文;换了 kind 旧密文不是这一形的,作废 + const secretCipher = + secretJson !== undefined ? deps.secrets.seal(secretJson) : input.kind === existing.kind ? existing.secretCipher : null; + const now = deps.clock.now(); + // kind / settings 变了游标清空重锚 —— 判定在 mergeAccount 里(cursorReset) + const record = mergeAccount(input, { id: accountId, existing, secretCipher, now }); + await deps.accounts.save(record); + return viewOf(record, now); + }, + + async deleteAccount(accountId) { + await requireAccount(accountId); + await deps.accounts.remove(accountId); + }, + + async probe(target) { + if ('input' in target) { + const stored = target.accountId === undefined ? undefined : await requireAccount(target.accountId); + const secretJson = secretOf(target.input); + const secret = + secretJson !== undefined + ? (JSON.parse(secretJson) as Record) + : stored !== undefined && stored.kind === target.input.kind + ? openSecret(stored) + : (() => { + throw new MailSecretMissingError(target.accountId ?? 'draft'); + })(); + const draft = mergeAccount(target.input, { id: stored?.id ?? 'draft', existing: stored, secretCipher: null, now: deps.clock.now() }); + const report = await deps.providers[target.input.kind].probe(providerAccount(draft, secret)); + return redactReport(report, secret); + } + const record = await requireAccount(target.accountId); + const secret = openSecret(record); + const report = redactReport(await deps.providers[record.kind].probe(providerAccount(record, secret)), secret); + // wecom 的地址由探测回填;imap-smtp 的地址是人填的,不改 + if (record.kind === 'wecom' && report.address !== undefined && report.address !== record.address) { + await deps.accounts.savePoll(record.id, { address: report.address }); + } + return report; + }, + + async pollDue(now) { + const due = await deps.accounts.listDue(now); + // 账号间并行,单账号内串行;一个账号的异常只记在它自己头上 + const settled = await Promise.allSettled(due.map((record) => pollAccount(record))); + return settled.map((r, i) => + r.status === 'fulfilled' + ? r.value + : { accountId: due[i]?.id ?? '?', pulled: 0, stored: 0, dispatched: 0, more: false, error: errorText(r.reason) }, + ); + }, + + async pollNow(accountId) { + const record = await requireAccount(accountId); + if (!record.enabled) throw new MailAccountDisabledError(accountId); + return pollAccount(record); + }, + + subscribe(name, handler) { + if (subscribers.has(name)) throw new MailSubscriberTakenError(name); + subscribers.set(name, handler); + return () => { + subscribers.delete(name); + }; + }, + + async listInbound(query) { + const rows = await deps.inbound.list({ accountId: query.accountId, limit: limitOf(query) }); + return rows.map((r) => summaryOf(r.record, r.deliveries)); + }, + + async getInbound(mailId) { + const hit = await deps.inbound.get(mailId); + if (hit === undefined) throw new MailInboundNotFoundError(mailId); + return detailOf(hit.record, hit.deliveries); + }, + + async attachment(mailId, index) { + const hit = await deps.inbound.get(mailId); + if (hit === undefined) throw new MailInboundNotFoundError(mailId); + const meta = hit.record.attachments.find((a) => a.index === index); + if (meta === undefined || meta.ref === null) throw new MailAttachmentNotFoundError(mailId, index); + const file = await deps.attachments.get(meta.ref); + if (file === undefined) throw new MailAttachmentNotFoundError(mailId, index); + return { name: meta.name, contentType: meta.contentType, bytes: file.bytes }; + }, + + async raw(mailId) { + const hit = await deps.inbound.get(mailId); + if (hit === undefined) throw new MailInboundNotFoundError(mailId); + if (hit.record.raw === null) throw new MailAttachmentNotFoundError(mailId, -1); + return { name: `${safeFileName(hit.record.messageId)}.eml`, bytes: hit.record.raw }; + }, + + async redeliver(mailId, subscriber) { + const hit = await deps.inbound.get(mailId); + if (hit === undefined) throw new MailInboundNotFoundError(mailId); + if (subscriber !== undefined && !subscribers.has(subscriber)) throw new MailSubscriberUnknownError(subscriber); + const owner = await deps.accounts.get(hit.record.accountId); + const deliveries = await dispatch(hit.record, { accountId: hit.record.accountId, channelId: owner?.channelId ?? null }, subscriber); + deps.notify.changed('inbound'); + return deliveries.map(deliveryViewOf); + }, + + async send(input) { + const account = pickAccount(await deps.accounts.list(), { accountId: input.accountId, from: input.from }); + // 幂等键:放行重放不发两封;上一次失败 / 中断的那一行复用,重发 + const prior = input.idempotencyKey === undefined ? undefined : await deps.outbound.byIdempotencyKey(input.idempotencyKey); + if (prior !== undefined && prior.state === 'sent') return resultOf(prior); + + const files: ParsedAttachment[] = []; + for (const a of input.attachments) { + if (a.dataBase64 !== undefined) { + files.push({ name: a.name, contentType: a.contentType, bytes: new Uint8Array(Buffer.from(a.dataBase64, 'base64')) }); + } else if (a.uploadRef !== undefined) { + const got = await deps.attachments.get(a.uploadRef); + if (got === undefined) throw new MailSendInvalidError([`附件不存在:${a.uploadRef}`]); + files.push({ name: a.name, contentType: a.contentType === 'application/octet-stream' ? got.contentType : a.contentType, bytes: got.bytes }); + } else { + throw new MailSendInvalidError([`附件 ${a.name} 既没给字节也没给 uploadRef`]); + } + } + const problems = validateSend(input, account.kind, files.map((f) => f.bytes.byteLength)); + if (problems.length > 0) throw new MailSendInvalidError(problems); + const secret = openSecret(account); + + const now = deps.clock.now(); + const id = prior?.id ?? deps.ids.next(); + const messageId = prior?.messageId ?? messageIdFor(id, account.address); + if (prior === undefined) { + const record: OutboundRecord = { + id, + accountId: account.id, + messageId, + idempotencyKey: input.idempotencyKey ?? null, + to: [...input.to], + cc: [...input.cc], + bcc: [...input.bcc], + subject: input.subject, + text: input.text ?? null, + html: input.html ?? null, + inReplyTo: input.inReplyTo ?? null, + references: [...input.references], + attachments: files.map((f, i) => ({ name: f.name, contentType: f.contentType, size: f.bytes.byteLength, ref: input.attachments[i]?.uploadRef ?? null })), + state: 'sending', + error: null, + providerRef: null, + requestedBy: { ...input.requestedBy }, + createdAt: now, + sentAt: null, + }; + await deps.outbound.insert(record); + } + + try { + const sent = await deps.providers[account.kind].send(providerAccount(account, secret), { + messageId, + from: { address: account.address, name: account.displayName }, + to: input.to, + cc: input.cc, + bcc: input.bcc, + subject: input.subject, + ...(input.text === undefined ? {} : { text: input.text }), + ...(input.html === undefined ? {} : { html: input.html }), + ...(input.inReplyTo === undefined ? {} : { inReplyTo: input.inReplyTo }), + references: input.references, + attachments: files, + }); + await deps.outbound.setState(id, 'sent', { sentAt: deps.clock.now(), ...(sent.providerRef === undefined ? {} : { providerRef: sent.providerRef }) }); + deps.notify.changed('outbound'); + return { outboundId: id, accountId: account.id, messageId, state: 'sent', ...(sent.providerRef === undefined ? {} : { providerRef: sent.providerRef }) }; + } catch (error) { + const text = redactWith(secret)(errorText(error)); + await deps.outbound.setState(id, 'failed', { error: text }); + deps.notify.changed('outbound'); + return { outboundId: id, accountId: account.id, messageId, state: 'failed', error: text }; + } + }, + + async listOutbound(query) { + const rows = await deps.outbound.list({ accountId: query.accountId, limit: limitOf(query) }); + return rows.map((r) => ({ + id: r.id, + accountId: r.accountId, + messageId: r.messageId, + to: [...r.to], + cc: [...r.cc], + bcc: [...r.bcc], + subject: r.subject, + state: r.state, + error: r.error, + providerRef: r.providerRef, + requestedBy: r.requestedBy, + createdAt: r.createdAt.toISOString(), + sentAt: r.sentAt === null ? null : r.sentAt.toISOString(), + })); + }, + }; +}