/** 账号记录 ↔ 入参 / 视图(设计稿 §5.3);视图经 schema 过一遍,密文进不来 */ import type { MailAccountInput, MailAccountKind, 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); } /** * **只有收侧的键动了才谈得上重锚** —— 游标说的是「这个信箱 / 这个文件夹我读到哪儿了」。 * * 从前比的是全部 settings 键,连 `smtpHost` / `smtpPort` / `smtpTls` 都算: * 人把 465 改成 587(纯发信的事)→ 游标清空 → IMAP 重锚 `lastUid = uidNext − 1` → * 上次游标到重锚之间到达的信**永远不拉**(设计即「不回填」)。**改发信的设置不该丢来信**。 * `login` 两侧都用,算收侧(换了登录名就是换了一个信箱在读)。 */ const RECEIVE_KEYS: Readonly> = { 'imap-smtp': ['imapHost', 'imapPort', 'imapTls', 'login', 'folder'], wecom: ['corpId', 'apiBase'], }; function sameReceiveSettings(kind: MailAccountKind, a: Record, b: Record): boolean { return RECEIVE_KEYS[kind].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 || !sameReceiveSettings(input.kind, 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, }; }