Archived
mail domain:发信校验与选账号(from 不静默回落)、分发状态机、账号记录与视图(密文进不了视图)
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,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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>): 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('<id@域>;地址没有域用 local', () => {
|
||||||
|
expect(messageIdFor('u-1', 'a@we.com')).toBe('<u-1@we.com>');
|
||||||
|
expect(messageIdFor('u-1', '')).toBe('<u-1@local>');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -74,3 +74,6 @@ export {
|
|||||||
threadIdOf,
|
threadIdOf,
|
||||||
} from './internal/domain/inbound-record.ts';
|
} from './internal/domain/inbound-record.ts';
|
||||||
export { errorText, redactSecrets, secretVariants } from './internal/domain/redact.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';
|
||||||
|
|||||||
@@ -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<string, unknown>, b: Record<string, unknown>): 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
}
|
||||||
@@ -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<MailSendInput, 'to' | 'cc' | 'bcc' | 'subject' | 'text' | 'html' | 'attachments'>;
|
||||||
|
|
||||||
|
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}>`;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user