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:
xcs
2026-08-26 00:28:02 +08:00
co-authored by Claude Fable 5
parent 6748ef01b7
commit 8d7e56aa92
7 changed files with 286 additions and 0 deletions
@@ -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}>`;
}