Archived
迁移 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:
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user