diff --git a/apps/api/src/modules/mail/__tests__/account-view.test.ts b/apps/api/src/modules/mail/__tests__/account-view.test.ts new file mode 100644 index 0000000..4323bab --- /dev/null +++ b/apps/api/src/modules/mail/__tests__/account-view.test.ts @@ -0,0 +1,61 @@ +/** 账号记录 ↔ 入参 / 视图:视图不带密文;kind / settings 变了游标清空;secret 缺省保留 */ +import { describe, expect, it } from 'vitest'; +import { mailAccountInputSchema } from '@contracts'; +import type { MailAccountRecord } from '../internal/domain/records.ts'; +import { imapCursor } from '../internal/domain/cursor.ts'; +import { cursorReset, mergeAccount, secretOf, viewOf } from '../internal/domain/account-view.ts'; + +const now = new Date('2026-08-25T10:00:00Z'); +const input = mailAccountInputSchema.parse({ kind: 'imap-smtp', name: '销售', address: 'sales@we.com', secret: { password: 'pw-1234' } }); + +describe('mergeAccount / secretOf', () => { + it('新建:id / 时间 / 密文来自 base,游标空', () => { + const record = mergeAccount(input, { id: 'acc-1', existing: undefined, secretCipher: 'sealed', now }); + expect(record.id).toBe('acc-1'); + expect(record.address).toBe('sales@we.com'); + expect(record.secretCipher).toBe('sealed'); + expect(record.cursor).toBeNull(); + expect(record.createdAt).toBe(now); + expect(secretOf(input)).toBe(JSON.stringify({ password: 'pw-1234' })); + expect(secretOf(mailAccountInputSchema.parse({ kind: 'imap-smtp', name: 'x', address: 'x@we.com' }))).toBeUndefined(); + }); + it('wecom 的地址不由人填:新建是空串,更新沿用探到的', () => { + const wecom = mailAccountInputSchema.parse({ kind: 'wecom', name: '应用', settings: { corpId: 'ww1' } }); + expect(mergeAccount(wecom, { id: 'w', existing: undefined, secretCipher: null, now }).address).toBe(''); + const existing = mergeAccount(wecom, { id: 'w', existing: undefined, secretCipher: null, now }); + const probed: MailAccountRecord = { ...existing, address: 'app@corp.wecom.work' }; + expect(mergeAccount(wecom, { id: 'w', existing: probed, secretCipher: null, now }).address).toBe('app@corp.wecom.work'); + }); + it('更新:settings 没变游标保留;变了(或换 kind)游标与上次错误清空', () => { + const existing: MailAccountRecord = { + ...mergeAccount(input, { id: 'acc-1', existing: undefined, secretCipher: 's', now }), + cursor: imapCursor('7', 9), + lastPollError: '旧错误', + }; + expect(cursorReset(existing, input)).toBe(false); + const same = mergeAccount(input, { id: 'acc-1', existing, secretCipher: 's', now }); + expect(same.cursor).toEqual(imapCursor('7', 9)); + const changed = mailAccountInputSchema.parse({ ...input, settings: { ...input.settings, folder: 'Archive' } }); + expect(cursorReset(existing, changed)).toBe(true); + const reset = mergeAccount(changed, { id: 'acc-1', existing, secretCipher: 's', now }); + expect(reset.cursor).toBeNull(); + expect(reset.lastPollError).toBeNull(); + }); +}); + +describe('viewOf', () => { + it('不带密文只带 secretBound;status 带游标摘要与 polling', () => { + const record: MailAccountRecord = { + ...mergeAccount(input, { id: 'acc-1', existing: undefined, secretCipher: 'sealed', now }), + cursor: imapCursor('7', 9), + pollingUntil: new Date(now.getTime() + 1000), + }; + const view = viewOf(record, now); + expect(view.secretBound).toBe(true); + expect(JSON.stringify(view)).not.toContain('sealed'); + expect(view.status.cursor).toBe('UID 9 · validity 7'); + expect(view.status.polling).toBe(true); + expect(view.kind).toBe('imap-smtp'); + if (view.kind === 'imap-smtp') expect(view.settings.imapHost).toBe('imap.exmail.qq.com'); + }); +}); diff --git a/apps/api/src/modules/mail/__tests__/delivery.test.ts b/apps/api/src/modules/mail/__tests__/delivery.test.ts new file mode 100644 index 0000000..7aec6b9 --- /dev/null +++ b/apps/api/src/modules/mail/__tests__/delivery.test.ts @@ -0,0 +1,17 @@ +/** 分发状态机:dispatching 不能被重投;回调回 skipped 就是 skipped */ +import { describe, expect, it } from 'vitest'; +import { canRedeliver, stateOfOutcome } from '../internal/domain/delivery.ts'; + +describe('delivery', () => { + it('从没投过 / done / failed / skipped 都能重投;正在投的不能', () => { + expect(canRedeliver(undefined)).toBe(true); + expect(canRedeliver('done')).toBe(true); + expect(canRedeliver('failed')).toBe(true); + expect(canRedeliver('skipped')).toBe(true); + expect(canRedeliver('dispatching')).toBe(false); + }); + it('回调的返回值定状态', () => { + expect(stateOfOutcome(undefined)).toBe('done'); + expect(stateOfOutcome({ skipped: '账号未绑定通道' })).toBe('skipped'); + }); +}); diff --git a/apps/api/src/modules/mail/__tests__/send-input.test.ts b/apps/api/src/modules/mail/__tests__/send-input.test.ts new file mode 100644 index 0000000..0269661 --- /dev/null +++ b/apps/api/src/modules/mail/__tests__/send-input.test.ts @@ -0,0 +1,57 @@ +/** 发信入参校验与选账号 —— 指定的必须存在且启用;不指定用唯一默认;from 必须对上账号,不静默回落 */ +import { describe, expect, it } from 'vitest'; +import type { MailAccountRecord } from '../internal/domain/records.ts'; +import { MailAccountDisabledError, MailAccountNotFoundError, MailNoDefaultAccountError } from '../internal/domain/errors.ts'; +import { messageIdFor, pickAccount, SEND_ATTACHMENT_CAP, validateSend } from '../internal/domain/send-input.ts'; + +const now = new Date('2026-08-25T10:00:00Z'); +function account(over: Partial): MailAccountRecord { + return { + id: 'a', kind: 'imap-smtp', name: 'a', address: 'a@we.com', displayName: '', enabled: true, isDefault: false, + pollIntervalSec: 30, channelId: null, backfillFrom: null, settings: {}, secretCipher: 'c', cursor: null, + lastPolledAt: null, lastPollError: null, lastInboundAt: null, pollingUntil: null, createdAt: now, updatedAt: now, + ...over, + }; +} +const base = { to: ['x@y.com'], cc: [], bcc: [], subject: '主题', text: '正文', html: undefined, attachments: [] }; + +describe('validateSend', () => { + it('合法入参零问题', () => { + expect(validateSend(base, 'imap-smtp', [])).toEqual([]); + }); + it('正文 text / html 至少一个;附件单个 ≤8MB、总 ≤20MB', () => { + expect(validateSend({ ...base, text: undefined }, 'imap-smtp', [])).toEqual(['正文 text / html 至少一个']); + expect(validateSend(base, 'imap-smtp', [SEND_ATTACHMENT_CAP + 1])[0]).toContain('8MB'); + expect(validateSend(base, 'imap-smtp', [7_000_000, 7_000_000, 7_000_000])[0]).toContain('20MB'); + }); + it('wecom 按文档:正文 + 附件 ≤50MB、附件 ≤200 个', () => { + expect(validateSend(base, 'wecom', Array.from({ length: 201 }, () => 1))[0]).toContain('200'); + expect(validateSend(base, 'wecom', Array.from({ length: 3 }, () => 7_000_000))).toEqual([]); + }); +}); + +describe('pickAccount', () => { + const a = account({ id: 'a', isDefault: true }); + const b = account({ id: 'b', address: 'b@we.com', enabled: false }); + it('不指定 → 唯一默认;没有默认 → MailNoDefaultAccountError', () => { + expect(pickAccount([a, b], {}).id).toBe('a'); + expect(() => pickAccount([b], {})).toThrow(MailNoDefaultAccountError); + }); + it('指定 id:不存在 404、停用 412', () => { + expect(pickAccount([a, b], { accountId: 'a' }).id).toBe('a'); + expect(() => pickAccount([a, b], { accountId: 'zz' })).toThrow(MailAccountNotFoundError); + expect(() => pickAccount([a, b], { accountId: 'b' })).toThrow(MailAccountDisabledError); + }); + it('指定 from:按地址(不分大小写)对账号,对不上不回落默认', () => { + expect(pickAccount([a, b], { from: 'A@WE.com' }).id).toBe('a'); + expect(() => pickAccount([a, b], { from: 'nobody@we.com' })).toThrow(MailAccountNotFoundError); + expect(() => pickAccount([a, b], { from: 'b@we.com' })).toThrow(MailAccountDisabledError); + }); +}); + +describe('messageIdFor', () => { + it(';地址没有域用 local', () => { + expect(messageIdFor('u-1', 'a@we.com')).toBe(''); + expect(messageIdFor('u-1', '')).toBe(''); + }); +}); diff --git a/apps/api/src/modules/mail/index.ts b/apps/api/src/modules/mail/index.ts index ff114a7..feb3836 100644 --- a/apps/api/src/modules/mail/index.ts +++ b/apps/api/src/modules/mail/index.ts @@ -74,3 +74,6 @@ export { threadIdOf, } from './internal/domain/inbound-record.ts'; export { errorText, redactSecrets, secretVariants } from './internal/domain/redact.ts'; +export { messageIdFor, pickAccount, validateSend } from './internal/domain/send-input.ts'; +export { canRedeliver, stateOfOutcome } from './internal/domain/delivery.ts'; +export { cursorReset, mergeAccount, secretOf, viewOf } from './internal/domain/account-view.ts'; diff --git a/apps/api/src/modules/mail/internal/domain/account-view.ts b/apps/api/src/modules/mail/internal/domain/account-view.ts new file mode 100644 index 0000000..a70b60f --- /dev/null +++ b/apps/api/src/modules/mail/internal/domain/account-view.ts @@ -0,0 +1,78 @@ +/** 账号记录 ↔ 入参 / 视图(设计稿 §5.3);视图经 schema 过一遍,密文进不来 */ +import type { MailAccountInput, MailAccountView } from '@contracts'; +import { mailAccountViewSchema } from '@contracts'; +import { cursorSummary } from './cursor.ts'; +import { isPolling } from './due.ts'; +import type { MailAccountRecord } from './records.ts'; + +export function viewOf(record: MailAccountRecord, now: Date): MailAccountView { + return mailAccountViewSchema.parse({ + id: record.id, + kind: record.kind, + name: record.name, + address: record.address, + displayName: record.displayName, + enabled: record.enabled, + isDefault: record.isDefault, + pollIntervalSec: record.pollIntervalSec, + channelId: record.channelId, + backfillFrom: record.backfillFrom === null ? null : record.backfillFrom.toISOString(), + settings: record.settings, + secretBound: record.secretCipher !== null && record.secretCipher !== '', + status: { + lastPolledAt: record.lastPolledAt === null ? null : record.lastPolledAt.toISOString(), + lastPollError: record.lastPollError, + lastInboundAt: record.lastInboundAt === null ? null : record.lastInboundAt.toISOString(), + cursor: cursorSummary(record.cursor), + polling: isPolling(record.pollingUntil, now), + }, + createdAt: record.createdAt.toISOString(), + updatedAt: record.updatedAt.toISOString(), + }); +} + +/** 入参里的凭证 → 待密封的 JSON;没给就是 undefined(= 不改) */ +export function secretOf(input: MailAccountInput): string | undefined { + return input.secret === undefined ? undefined : JSON.stringify(input.secret); +} + +function sameSettings(a: Record, b: Record): boolean { + const keys = new Set([...Object.keys(a), ...Object.keys(b)]); + return [...keys].every((k) => JSON.stringify(a[k] ?? null) === JSON.stringify(b[k] ?? null)); +} + +/** kind 或 settings 变了,旧游标指的就不是同一个信箱 / 同一个文件夹 —— 清空重锚 */ +export function cursorReset(existing: MailAccountRecord | undefined, input: MailAccountInput): boolean { + if (existing === undefined) return false; + return existing.kind !== input.kind || !sameSettings(existing.settings, input.settings); +} + +export function mergeAccount( + input: MailAccountInput, + base: { readonly id: string; readonly existing: MailAccountRecord | undefined; readonly secretCipher: string | null; readonly now: Date }, +): MailAccountRecord { + const reset = cursorReset(base.existing, input); + const existing = base.existing; + return { + id: base.id, + kind: input.kind, + name: input.name, + // wecom 的地址由探测回填,人不填;换成 imap-smtp 时以填的为准 + address: input.kind === 'imap-smtp' ? input.address : existing?.kind === 'wecom' ? existing.address : '', + displayName: input.displayName, + enabled: input.enabled, + isDefault: input.isDefault, + pollIntervalSec: input.pollIntervalSec, + channelId: input.channelId, + backfillFrom: input.backfillFrom === null ? null : new Date(input.backfillFrom), + settings: { ...input.settings }, + secretCipher: base.secretCipher, + cursor: reset ? null : (existing?.cursor ?? null), + lastPolledAt: existing?.lastPolledAt ?? null, + lastPollError: reset ? null : (existing?.lastPollError ?? null), + lastInboundAt: existing?.lastInboundAt ?? null, + pollingUntil: existing?.pollingUntil ?? null, + createdAt: existing?.createdAt ?? base.now, + updatedAt: base.now, + }; +} diff --git a/apps/api/src/modules/mail/internal/domain/delivery.ts b/apps/api/src/modules/mail/internal/domain/delivery.ts new file mode 100644 index 0000000..ab7c3a2 --- /dev/null +++ b/apps/api/src/modules/mail/internal/domain/delivery.ts @@ -0,0 +1,10 @@ +/** 分发状态机(设计稿 §9.3):dispatching → done | failed | skipped;重投只从「不在投」出发 */ +import type { MailDeliveryState } from '@contracts'; + +export function canRedeliver(current: MailDeliveryState | undefined): boolean { + return current !== 'dispatching'; +} + +export function stateOfOutcome(outcome: void | { readonly skipped: string }): 'done' | 'skipped' { + return outcome !== undefined && typeof outcome === 'object' && 'skipped' in outcome ? 'skipped' : 'done'; +} diff --git a/apps/api/src/modules/mail/internal/domain/send-input.ts b/apps/api/src/modules/mail/internal/domain/send-input.ts new file mode 100644 index 0000000..b375f36 --- /dev/null +++ b/apps/api/src/modules/mail/internal/domain/send-input.ts @@ -0,0 +1,60 @@ +/** 发信校验与选账号(设计稿 §5.3 / §5.4) */ +import type { MailAccountKind, MailSendInput } from '@contracts'; +import { MailAccountDisabledError, MailAccountNotFoundError, MailNoDefaultAccountError } from './errors.ts'; +import type { MailAccountRecord } from './records.ts'; + +export const SEND_ATTACHMENT_CAP = 8 * 1024 * 1024; +export const SEND_TOTAL_CAP = 20 * 1024 * 1024; +/** 企业微信文档:所有附件加正文不超过 50M,附件个数不超过 200 */ +export const WECOM_TOTAL_CAP = 50 * 1024 * 1024; +export const WECOM_ATTACHMENT_COUNT_CAP = 200; + +type SendShape = Pick; + +export function validateSend(input: SendShape, kind: MailAccountKind, attachmentSizes: readonly number[]): string[] { + const problems: string[] = []; + if (input.to.length === 0) problems.push('收件人至少一个'); + if (input.subject.trim() === '') problems.push('主题不能为空'); + if ((input.text === undefined || input.text === '') && (input.html === undefined || input.html === '')) { + problems.push('正文 text / html 至少一个'); + } + const total = attachmentSizes.reduce((sum, n) => sum + n, 0); + if (kind === 'wecom') { + if (attachmentSizes.length > WECOM_ATTACHMENT_COUNT_CAP) problems.push(`企业微信附件不能超过 ${WECOM_ATTACHMENT_COUNT_CAP} 个`); + if (total + (input.text?.length ?? 0) + (input.html?.length ?? 0) > WECOM_TOTAL_CAP) problems.push('企业微信正文加附件不能超过 50MB'); + } else { + const oversize = attachmentSizes.findIndex((n) => n > SEND_ATTACHMENT_CAP); + if (oversize >= 0) problems.push(`第 ${oversize + 1} 个附件超过 8MB`); + if (total > SEND_TOTAL_CAP) problems.push('附件合计超过 20MB'); + } + return problems; +} + +export function pickAccount( + accounts: readonly MailAccountRecord[], + requested: { readonly accountId?: string | undefined; readonly from?: string | undefined }, +): MailAccountRecord { + if (requested.accountId !== undefined) { + const hit = accounts.find((a) => a.id === requested.accountId); + if (hit === undefined) throw new MailAccountNotFoundError(requested.accountId); + if (!hit.enabled) throw new MailAccountDisabledError(hit.id); + return hit; + } + if (requested.from !== undefined) { + const wanted = requested.from.trim().toLowerCase(); + const hit = accounts.find((a) => a.address.toLowerCase() === wanted); + // 对不上就是对不上:拿别的账号冒充这个发件地址,SMTP 会拒,人也会被骗 + if (hit === undefined) throw new MailAccountNotFoundError(requested.from); + if (!hit.enabled) throw new MailAccountDisabledError(hit.id); + return hit; + } + const fallback = accounts.find((a) => a.isDefault && a.enabled); + if (fallback === undefined) throw new MailNoDefaultAccountError(); + return fallback; +} + +export function messageIdFor(id: string, address: string): string { + const at = address.lastIndexOf('@'); + const domain = at < 0 || at === address.length - 1 ? 'local' : address.slice(at + 1); + return `<${id}@${domain}>`; +}