mail 用例:账号建改删与探测(草稿探测可借库里密文;wecom 探到地址回填);收发用例同份落地待测

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 01:08:48 +08:00
co-authored by Claude Fable 5
parent b9a4af9a2a
commit 1328aeb142
3 changed files with 600 additions and 0 deletions
@@ -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);
});
});
+3
View File
@@ -81,3 +81,6 @@ 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'; 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';
@@ -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<Record<MailAccountKind, MailProvider>>;
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<string, InboundHandler>();
async function requireAccount(accountId: string): Promise<MailAccountRecord> {
const record = await deps.accounts.get(accountId);
if (record === undefined) throw new MailAccountNotFoundError(accountId);
return record;
}
/** 明文只在这里打开、交给 provider 那一段;不进任何留痕 */
function openSecret(record: Pick<MailAccountRecord, 'id' | 'secretCipher'>): Record<string, string> {
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<string, unknown>).map(([k, v]) => [k, String(v)]));
}
function providerAccount(record: MailAccountRecord, secret: Record<string, string>): ProviderAccount {
return { id: record.id, kind: record.kind, address: record.address, displayName: record.displayName, settings: record.settings, secret };
}
function redactWith(secret: Record<string, string>): (text: string) => string {
const values = Object.values(secret);
return (text) => redactSecrets(text, values);
}
function redactReport(report: MailProbeReport, secret: Record<string, string>): 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<DeliveryRecord[]> {
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<InboundRecord | undefined> {
// 先按 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<MailPollOutcome> {
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<string, string> = {};
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<string, string>)
: 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(),
}));
},
};
}