diff --git a/apps/api/src/app/__tests__/mail-routes-registry.test.ts b/apps/api/src/app/__tests__/mail-routes-registry.test.ts new file mode 100644 index 0000000..937861f --- /dev/null +++ b/apps/api/src/app/__tests__/mail-routes-registry.test.ts @@ -0,0 +1,33 @@ +/** + * 邮件路由的归属:15 条全部进契约,注册表建得起来(字面 / 参数模版互不撞), + * 读标两端、写归运营中心(设计稿 §7.2)。 + */ +import { describe, expect, it } from 'vitest'; +import * as contracts from '@contracts'; +import { MAIL_ROUTES } from '@contracts'; +import { buildRouteRegistry, routesOf } from '../route-registry.ts'; + +describe('邮件路由契约', () => { + it('15 条全在契约命名空间里,注册表能建起来', () => { + const all = routesOf(Object.values(contracts)); + const registry = buildRouteRegistry(all); + expect(MAIL_ROUTES).toHaveLength(15); + for (const route of MAIL_ROUTES) { + expect(registry.has(route.method, route.path)).toBe(true); + } + }); + + it('读标两端,写归 ops', () => { + for (const route of MAIL_ROUTES) { + if (route.method === 'GET') expect(route.access).toEqual(['design', 'ops']); + else expect(route.access).toBe('ops'); + } + }); + + it('未存草稿探测与已存账号探测是两条不同的路', () => { + const registry = buildRouteRegistry(routesOf(Object.values(contracts))); + expect(registry.lookup('POST', '/api/gov/mail/accounts/probe')).toBe('ops'); + expect(registry.lookup('POST', '/api/gov/mail/accounts/acc-1/probe')).toBe('ops'); + expect(registry.lookup('GET', '/api/gov/mail/inbound/m-1/attachments/0')).toEqual(['design', 'ops']); + }); +}); diff --git a/packages/contracts/src/api/index.ts b/packages/contracts/src/api/index.ts index 69dfaef..297cfdd 100644 --- a/packages/contracts/src/api/index.ts +++ b/packages/contracts/src/api/index.ts @@ -2,3 +2,4 @@ export * from './http.ts'; export * from './workbench.ts'; export * from './governance.ts'; export * from './ingest.ts'; +export * from './mail.ts'; diff --git a/packages/contracts/src/api/mail.ts b/packages/contracts/src/api/mail.ts new file mode 100644 index 0000000..e58f4e3 --- /dev/null +++ b/packages/contracts/src/api/mail.ts @@ -0,0 +1,178 @@ +/** + * 邮件收发的路由契约(设计稿 §7.2;ADR-013)。 + * + * 读标两端(设计中心要看通道对上了哪个信箱,运营中心管配置);写归运营中心。 + * `/api/gov/*` 的写路由由 app 中间件自动广播 `{kind:'system', id:'gov'}`。 + * 探测 / 试发 / 拉取的**厂商失败不是接口错误**:200 的 body 里带原话(脱敏)。 + */ +import { z } from 'zod'; +import type { RouteContract } from './http.ts'; +import { + mailAccountInputSchema, + mailAccountViewSchema, + mailDeliveryViewSchema, + mailInboundDetailSchema, + mailInboundSummarySchema, + mailOutboundViewSchema, + mailPollOutcomeSchema, + mailProbeReportSchema, + mailSendResultSchema, +} from '../mail.ts'; + +const accountParams = z.object({ accountId: z.string().min(1) }); +const mailParams = z.object({ mailId: z.string().min(1) }); +const listQuery = z.object({ + accountId: z.string().optional(), + /** 默认 50,上限 200 */ + limit: z.coerce.number().int().min(1).max(200).optional(), +}); + +export const listMailAccountsRoute = { + method: 'GET', + path: '/api/gov/mail/accounts', + access: ['design', 'ops'], + output: z.object({ accounts: z.array(mailAccountViewSchema) }), +} as const satisfies RouteContract; + +export const createMailAccountRoute = { + method: 'POST', + path: '/api/gov/mail/accounts', + access: 'ops', + body: mailAccountInputSchema, + output: mailAccountViewSchema, +} as const satisfies RouteContract; + +/** 未存的配置探测 —— 表单上正填的那份,一个字节都不落库 */ +export const probeMailDraftRoute = { + method: 'POST', + path: '/api/gov/mail/accounts/probe', + access: 'ops', + /** 改已存账号时带上 ?accountId=:表单没重填密码就借库里那份密文 */ + query: z.object({ accountId: z.string().optional() }), + body: mailAccountInputSchema, + output: mailProbeReportSchema, +} as const satisfies RouteContract; + +export const getMailAccountRoute = { + method: 'GET', + path: '/api/gov/mail/accounts/:accountId', + access: ['design', 'ops'], + params: accountParams, + output: mailAccountViewSchema, +} as const satisfies RouteContract; + +/** secret 缺省 = 不改密文;kind / settings 变了游标清空重锚 */ +export const updateMailAccountRoute = { + method: 'PUT', + path: '/api/gov/mail/accounts/:accountId', + access: 'ops', + params: accountParams, + body: mailAccountInputSchema, + output: mailAccountViewSchema, +} as const satisfies RouteContract; + +export const deleteMailAccountRoute = { + method: 'DELETE', + path: '/api/gov/mail/accounts/:accountId', + access: 'ops', + params: accountParams, + output: z.object({ ok: z.literal(true) }), +} as const satisfies RouteContract; + +/** 已存账号探测(用库里凭证);凭证空 412 */ +export const probeMailAccountRoute = { + method: 'POST', + path: '/api/gov/mail/accounts/:accountId/probe', + access: 'ops', + params: accountParams, + output: mailProbeReportSchema, +} as const satisfies RouteContract; + +/** 立即拉一轮;另一进程占着租约 409;停用 412 */ +export const pollMailAccountRoute = { + method: 'POST', + path: '/api/gov/mail/accounts/:accountId/poll', + access: 'ops', + params: accountParams, + output: mailPollOutcomeSchema, +} as const satisfies RouteContract; + +export const sendTestMailRoute = { + method: 'POST', + path: '/api/gov/mail/accounts/:accountId/send-test', + access: 'ops', + params: accountParams, + body: z.object({ to: z.email(), subject: z.string().min(1), text: z.string().min(1) }), + output: mailSendResultSchema, +} as const satisfies RouteContract; + +export const listMailInboundRoute = { + method: 'GET', + path: '/api/gov/mail/inbound', + access: ['design', 'ops'], + query: listQuery, + output: z.object({ mails: z.array(mailInboundSummarySchema) }), +} as const satisfies RouteContract; + +export const getMailInboundRoute = { + method: 'GET', + path: '/api/gov/mail/inbound/:mailId', + access: ['design', 'ops'], + params: mailParams, + output: mailInboundDetailSchema, +} as const satisfies RouteContract; + +/** 附件原件(JSON + base64,与 /api/gov/upload/:id 同形);可带 ?ticket= 下载(ADR-012 §6) */ +export const mailAttachmentRoute = { + method: 'GET', + path: '/api/gov/mail/inbound/:mailId/attachments/:index', + access: ['design', 'ops'], + params: z.object({ mailId: z.string().min(1), index: z.coerce.number().int().min(0) }), + output: z.object({ name: z.string(), contentType: z.string(), dataBase64: z.string() }), +} as const satisfies RouteContract; + +/** 原件 .eml;超限没存 404 */ +export const mailRawRoute = { + method: 'GET', + path: '/api/gov/mail/inbound/:mailId/raw', + access: ['design', 'ops'], + params: mailParams, + output: z.object({ name: z.string(), contentType: z.literal('message/rfc822'), dataBase64: z.string() }), +} as const satisfies RouteContract; + +/** 人按的钮:重新投给订阅方(缺省全部);从此是至少一次 */ +export const redeliverMailInboundRoute = { + method: 'POST', + path: '/api/gov/mail/inbound/:mailId/redeliver', + access: 'ops', + params: mailParams, + body: z.object({ subscriber: z.string().min(1).optional() }).default({}), + output: z.object({ deliveries: z.array(mailDeliveryViewSchema) }), +} as const satisfies RouteContract; + +export const listMailOutboundRoute = { + method: 'GET', + path: '/api/gov/mail/outbound', + access: ['design', 'ops'], + query: listQuery, + output: z.object({ mails: z.array(mailOutboundViewSchema) }), +} as const satisfies RouteContract; + +/** 全部 15 条 —— 文档总表与覆盖测试按它数 */ +export const MAIL_ROUTES: readonly RouteContract[] = [ + listMailAccountsRoute, + createMailAccountRoute, + probeMailDraftRoute, + getMailAccountRoute, + updateMailAccountRoute, + deleteMailAccountRoute, + probeMailAccountRoute, + pollMailAccountRoute, + sendTestMailRoute, + listMailInboundRoute, + getMailInboundRoute, + mailAttachmentRoute, + mailRawRoute, + redeliverMailInboundRoute, + listMailOutboundRoute, +];