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,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