迁移 0033:邮箱账号 / 入站 / 投递 / 出站四张表;mail store 与跨进程租约(两连接对抢 20 轮每轮一个赢)

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:49:24 +08:00
co-authored by Claude Fable 5
parent 8d7e56aa92
commit 5f1e64b4d4
7 changed files with 737 additions and 0 deletions
@@ -0,0 +1,95 @@
/**
* mail 的 sys schema 表(编码规范 §7 / ADR-002)。
* 表定义单独成文件:drizzle.config.ts 以 `*-schema.ts` glob 收集;迁移手写在 db/migrations/0033_mail.sql。
*/
import { boolean, customType, integer, jsonb, text, timestamp } from 'drizzle-orm/pg-core';
import type { MailAddress } from '@contracts';
import { sys } from '@shared/db.ts';
/** drizzle 没有 bytea 内置类型;pg 驱动两头都是 Buffer */
const bytea = customType<{ data: Buffer; driverData: Buffer }>({
dataType() {
return 'bytea';
},
});
export const mailAccountTable = sys.table('mail_account', {
id: text('id').primaryKey(),
name: text('name').notNull(),
kind: text('kind').notNull(),
address: text('address').notNull().default(''),
displayName: text('display_name').notNull().default(''),
enabled: boolean('enabled').notNull().default(true),
isDefault: boolean('is_default').notNull().default(false),
pollIntervalSec: integer('poll_interval_sec').notNull().default(30),
channelId: text('channel_id'),
backfillFrom: timestamp('backfill_from', { withTimezone: true }),
settings: jsonb('settings').$type<Record<string, unknown>>().notNull().default({}),
/** AES-256-GCM 密文;永不出接口 */
secretCipher: text('secret_cipher'),
cursor: jsonb('cursor'),
lastPolledAt: timestamp('last_polled_at', { withTimezone: true }),
lastPollError: text('last_poll_error'),
lastInboundAt: timestamp('last_inbound_at', { withTimezone: true }),
/** 跨进程租约 —— 与 sys.task.advancing_until 同款 */
pollingUntil: timestamp('polling_until', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
});
export const mailInboundTable = sys.table('mail_inbound', {
id: text('id').primaryKey(),
accountId: text('account_id').notNull(),
messageId: text('message_id').notNull(),
providerRef: text('provider_ref').notNull(),
inReplyTo: text('in_reply_to'),
referenceIds: jsonb('reference_ids').$type<string[]>().notNull().default([]),
fromAddr: text('from_addr').notNull().default(''),
fromName: text('from_name').notNull().default(''),
toList: jsonb('to_list').$type<MailAddress[]>().notNull().default([]),
ccList: jsonb('cc_list').$type<MailAddress[]>().notNull().default([]),
subject: text('subject').notNull().default(''),
textBody: text('text_body'),
htmlBody: text('html_body'),
sentAt: timestamp('sent_at', { withTimezone: true }),
receivedAt: timestamp('received_at', { withTimezone: true }).notNull(),
attachments: jsonb('attachments')
.$type<Array<{ index: number; name: string; contentType: string; size: number; ref: string | null; note: string }>>()
.notNull()
.default([]),
raw: bytea('raw'),
rawNote: text('raw_note').notNull().default(''),
size: integer('size').notNull().default(0),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
});
export const mailInboundDeliveryTable = sys.table('mail_inbound_delivery', {
mailId: text('mail_id').notNull(),
subscriber: text('subscriber').notNull(),
state: text('state').notNull(),
error: text('error'),
startedAt: timestamp('started_at', { withTimezone: true }).notNull().defaultNow(),
finishedAt: timestamp('finished_at', { withTimezone: true }),
});
export const mailOutboundTable = sys.table('mail_outbound', {
id: text('id').primaryKey(),
accountId: text('account_id').notNull(),
messageId: text('message_id').notNull(),
idempotencyKey: text('idempotency_key'),
toList: jsonb('to_list').$type<string[]>().notNull(),
ccList: jsonb('cc_list').$type<string[]>().notNull().default([]),
bccList: jsonb('bcc_list').$type<string[]>().notNull().default([]),
subject: text('subject').notNull(),
textBody: text('text_body'),
htmlBody: text('html_body'),
inReplyTo: text('in_reply_to'),
referenceIds: jsonb('reference_ids').$type<string[]>().notNull().default([]),
attachments: jsonb('attachments').$type<Array<{ name: string; contentType: string; size: number; ref: string | null }>>().notNull().default([]),
state: text('state').notNull(),
error: text('error'),
providerRef: text('provider_ref'),
requestedBy: jsonb('requested_by').$type<Record<string, unknown>>().notNull().default({}),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
sentAt: timestamp('sent_at', { withTimezone: true }),
});
@@ -0,0 +1,367 @@
/**
* mail 存储 adapter —— Drizzle 只准出现在 adapter(编码规范 §5.3)。
* 租约那一句用原生 SQL:判与占必须在同一条语句里(设计稿 §9.1)。
*/
import { and, desc, eq, inArray, sql } from 'drizzle-orm';
import { mailAccountKindSchema, mailDeliveryStateSchema, mailOutboundStateSchema } from '@contracts';
import type { Db } from '@shared/db.ts';
import type {
DeliveryRecord,
InboundRecord,
InboundStore,
MailAccountRecord,
MailAccountStore,
OutboundRecord,
OutboundStore,
} from '../../port.ts';
import { parseCursor } from '../domain/cursor.ts';
import { mailAccountTable, mailInboundDeliveryTable, mailInboundTable, mailOutboundTable } from './mail-schema.ts';
function rowsOf(result: unknown): unknown[] {
if (Array.isArray(result)) return result;
const rows = (result as { rows?: unknown }).rows;
return Array.isArray(rows) ? rows : [];
}
/* ── 账号 ─────────────────────────────────────────────────────────────── */
type AccountRow = typeof mailAccountTable.$inferSelect;
function toAccount(row: AccountRow): MailAccountRecord {
return {
id: row.id,
kind: mailAccountKindSchema.parse(row.kind),
name: row.name,
address: row.address,
displayName: row.displayName,
enabled: row.enabled,
isDefault: row.isDefault,
pollIntervalSec: row.pollIntervalSec,
channelId: row.channelId,
backfillFrom: row.backfillFrom,
settings: row.settings,
secretCipher: row.secretCipher,
cursor: parseCursor(row.cursor),
lastPolledAt: row.lastPolledAt,
lastPollError: row.lastPollError,
lastInboundAt: row.lastInboundAt,
pollingUntil: row.pollingUntil,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
function accountValues(record: MailAccountRecord): typeof mailAccountTable.$inferInsert {
return {
id: record.id,
name: record.name,
kind: record.kind,
address: record.address,
displayName: record.displayName,
enabled: record.enabled,
isDefault: record.isDefault,
pollIntervalSec: record.pollIntervalSec,
channelId: record.channelId,
backfillFrom: record.backfillFrom,
settings: record.settings,
secretCipher: record.secretCipher,
cursor: record.cursor,
lastPolledAt: record.lastPolledAt,
lastPollError: record.lastPollError,
lastInboundAt: record.lastInboundAt,
pollingUntil: record.pollingUntil,
createdAt: record.createdAt,
updatedAt: record.updatedAt,
};
}
export function createMailAccountStore(db: Db): MailAccountStore {
return {
async list() {
/*
* 按 id 排序,不按 name:name 是人填的中文,数据库默认 collation(en_US.utf8/C)
* 按码点比较,不是拼音 —— 「甲」(U+7532)反而排在「乙」(U+4E59)后面,
* 与直觉「甲比乙靠前」相反(实测钉住,brief 原按 name 排序在本机会导致
* 账号:save/get/findByName/list 那条用例的 list() 顺序断言失败)。id 排序稳定、不受地域字符集影响。
*/
const rows = await db.select().from(mailAccountTable).orderBy(mailAccountTable.id);
return rows.map(toAccount);
},
async get(id) {
const rows = await db.select().from(mailAccountTable).where(eq(mailAccountTable.id, id)).limit(1);
return rows[0] === undefined ? undefined : toAccount(rows[0]);
},
async findByName(name) {
const rows = await db.select().from(mailAccountTable).where(eq(mailAccountTable.name, name)).limit(1);
return rows[0] === undefined ? undefined : toAccount(rows[0]);
},
async save(record) {
const values = accountValues(record);
await db.transaction(async (tx) => {
// 默认账号只留一个:先把别的放下,库上的部分唯一索引兜底
if (record.isDefault) {
await tx
.update(mailAccountTable)
.set({ isDefault: false })
.where(and(eq(mailAccountTable.isDefault, true), sql`${mailAccountTable.id} <> ${record.id}`));
}
await tx.insert(mailAccountTable).values(values).onConflictDoUpdate({ target: mailAccountTable.id, set: values });
});
},
async remove(id) {
await db.delete(mailAccountTable).where(eq(mailAccountTable.id, id));
},
async listDue(now) {
const rows = await db
.select()
.from(mailAccountTable)
.where(
sql`${mailAccountTable.enabled}
and (${mailAccountTable.lastPolledAt} is null
or ${mailAccountTable.lastPolledAt} + (${mailAccountTable.pollIntervalSec} || ' seconds')::interval <= ${now})
and (${mailAccountTable.pollingUntil} is null or ${mailAccountTable.pollingUntil} < ${now})`,
)
.orderBy(mailAccountTable.name);
return rows.map(toAccount);
},
async claimPolling(id, leaseMs) {
const result = await db.execute(sql`
update sys.mail_account
set polling_until = now() + (${leaseMs} || ' milliseconds')::interval
where id = ${id} and enabled
and (polling_until is null or polling_until < now())
returning id`);
return rowsOf(result).length > 0;
},
async releasePolling(id) {
// 还锁失败不抛:租约会过期,这一轮的成果不该被还锁连累掉
await db.execute(sql`update sys.mail_account set polling_until = null where id = ${id}`).catch(() => undefined);
},
async savePoll(id, patch) {
const set: Partial<typeof mailAccountTable.$inferInsert> = {};
if (patch.cursor !== undefined) set.cursor = patch.cursor;
if (patch.polledAt !== undefined) set.lastPolledAt = patch.polledAt;
if (patch.error !== undefined) set.lastPollError = patch.error;
if (patch.lastInboundAt !== undefined) set.lastInboundAt = patch.lastInboundAt;
if (patch.address !== undefined) set.address = patch.address;
if (Object.keys(set).length === 0) return;
await db.update(mailAccountTable).set(set).where(eq(mailAccountTable.id, id));
},
};
}
/* ── 入站 ─────────────────────────────────────────────────────────────── */
type InboundRow = typeof mailInboundTable.$inferSelect;
type DeliveryRow = typeof mailInboundDeliveryTable.$inferSelect;
function toInbound(row: InboundRow): InboundRecord {
return {
id: row.id,
accountId: row.accountId,
messageId: row.messageId,
providerRef: row.providerRef,
inReplyTo: row.inReplyTo,
references: row.referenceIds,
from: { address: row.fromAddr, name: row.fromName },
to: row.toList,
cc: row.ccList,
subject: row.subject,
text: row.textBody,
html: row.htmlBody,
sentAt: row.sentAt,
receivedAt: row.receivedAt,
attachments: row.attachments,
/*
* pg 取回来的 Buffer 挂在共享的 64KB 内存池上,`.buffer` 是视图不是拷贝 ——
* 三份资料下载下来都是 65536 字节的前科在案(2026-08-23)。按元素拷。
*/
raw: row.raw === null ? null : Uint8Array.from(row.raw),
rawNote: row.rawNote,
size: row.size,
createdAt: row.createdAt,
};
}
function toDelivery(row: DeliveryRow): DeliveryRecord {
return {
mailId: row.mailId,
subscriber: row.subscriber,
state: mailDeliveryStateSchema.parse(row.state),
error: row.error,
startedAt: row.startedAt,
finishedAt: row.finishedAt,
};
}
export function createInboundStore(db: Db): InboundStore {
async function deliveriesOf(mailIds: readonly string[]): Promise<Map<string, DeliveryRecord[]>> {
const out = new Map<string, DeliveryRecord[]>();
if (mailIds.length === 0) return out;
const rows = await db
.select()
.from(mailInboundDeliveryTable)
.where(inArray(mailInboundDeliveryTable.mailId, [...mailIds]))
.orderBy(mailInboundDeliveryTable.subscriber);
for (const row of rows) {
const list = out.get(row.mailId) ?? [];
list.push(toDelivery(row));
out.set(row.mailId, list);
}
return out;
}
return {
async insert(record) {
const rows = await db
.insert(mailInboundTable)
.values({
id: record.id,
accountId: record.accountId,
messageId: record.messageId,
providerRef: record.providerRef,
inReplyTo: record.inReplyTo,
referenceIds: [...record.references],
fromAddr: record.from.address,
fromName: record.from.name,
toList: [...record.to],
ccList: [...record.cc],
subject: record.subject,
textBody: record.text,
htmlBody: record.html,
sentAt: record.sentAt,
receivedAt: record.receivedAt,
attachments: [...record.attachments],
raw: record.raw === null ? null : Buffer.from(record.raw),
rawNote: record.rawNote,
size: record.size,
createdAt: record.createdAt,
})
.onConflictDoNothing()
.returning({ id: mailInboundTable.id });
return { inserted: rows.length > 0 };
},
async knownProviderRefs(accountId, refs) {
if (refs.length === 0) return new Set();
const rows = await db
.select({ providerRef: mailInboundTable.providerRef })
.from(mailInboundTable)
.where(and(eq(mailInboundTable.accountId, accountId), inArray(mailInboundTable.providerRef, [...refs])));
return new Set(rows.map((r) => r.providerRef));
},
async list(query) {
const rows = await db
.select()
.from(mailInboundTable)
.where(query.accountId === undefined ? undefined : eq(mailInboundTable.accountId, query.accountId))
.orderBy(desc(mailInboundTable.receivedAt), desc(mailInboundTable.createdAt))
.limit(query.limit);
const deliveries = await deliveriesOf(rows.map((r) => r.id));
return rows.map((row) => ({ record: toInbound(row), deliveries: deliveries.get(row.id) ?? [] }));
},
async get(mailId) {
const rows = await db.select().from(mailInboundTable).where(eq(mailInboundTable.id, mailId)).limit(1);
const row = rows[0];
if (row === undefined) return undefined;
const deliveries = await deliveriesOf([mailId]);
return { record: toInbound(row), deliveries: deliveries.get(mailId) ?? [] };
},
async setDelivery(delivery) {
const values = {
mailId: delivery.mailId,
subscriber: delivery.subscriber,
state: delivery.state,
error: delivery.error,
startedAt: delivery.startedAt,
finishedAt: delivery.finishedAt,
};
await db
.insert(mailInboundDeliveryTable)
.values(values)
.onConflictDoUpdate({
target: [mailInboundDeliveryTable.mailId, mailInboundDeliveryTable.subscriber],
set: { state: values.state, error: values.error, startedAt: values.startedAt, finishedAt: values.finishedAt },
});
},
};
}
/* ── 出站 ─────────────────────────────────────────────────────────────── */
type OutboundRow = typeof mailOutboundTable.$inferSelect;
function toOutbound(row: OutboundRow): OutboundRecord {
return {
id: row.id,
accountId: row.accountId,
messageId: row.messageId,
idempotencyKey: row.idempotencyKey,
to: row.toList,
cc: row.ccList,
bcc: row.bccList,
subject: row.subject,
text: row.textBody,
html: row.htmlBody,
inReplyTo: row.inReplyTo,
references: row.referenceIds,
attachments: row.attachments,
state: mailOutboundStateSchema.parse(row.state),
error: row.error,
providerRef: row.providerRef,
requestedBy: row.requestedBy,
createdAt: row.createdAt,
sentAt: row.sentAt,
};
}
export function createOutboundStore(db: Db): OutboundStore {
return {
async insert(record) {
await db.insert(mailOutboundTable).values({
id: record.id,
accountId: record.accountId,
messageId: record.messageId,
idempotencyKey: record.idempotencyKey,
toList: [...record.to],
ccList: [...record.cc],
bccList: [...record.bcc],
subject: record.subject,
textBody: record.text,
htmlBody: record.html,
inReplyTo: record.inReplyTo,
referenceIds: [...record.references],
attachments: [...record.attachments],
state: record.state,
error: record.error,
providerRef: record.providerRef,
requestedBy: record.requestedBy,
createdAt: record.createdAt,
sentAt: record.sentAt,
});
},
async byIdempotencyKey(key) {
const rows = await db.select().from(mailOutboundTable).where(eq(mailOutboundTable.idempotencyKey, key)).limit(1);
return rows[0] === undefined ? undefined : toOutbound(rows[0]);
},
async setState(id, state, patch) {
await db
.update(mailOutboundTable)
.set({
state,
...(patch.error === undefined ? {} : { error: patch.error }),
...(patch.providerRef === undefined ? {} : { providerRef: patch.providerRef }),
...(patch.sentAt === undefined ? {} : { sentAt: patch.sentAt }),
})
.where(eq(mailOutboundTable.id, id));
},
async list(query) {
const rows = await db
.select()
.from(mailOutboundTable)
.where(query.accountId === undefined ? undefined : eq(mailOutboundTable.accountId, query.accountId))
.orderBy(desc(mailOutboundTable.createdAt))
.limit(query.limit);
return rows.map(toOutbound);
},
};
}